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 argparse 16import base64 17import collections 18import hashlib 19import os 20import re 21import sys 22import zipfile 23from pathlib import Path 24 25 26def commonpath(path1, path2): 27 ret = [] 28 for a, b in zip(path1.split(os.path.sep), path2.split(os.path.sep)): 29 if a != b: 30 break 31 ret.append(a) 32 return os.path.sep.join(ret) 33 34 35def escape_filename_segment(segment): 36 """Escapes a filename segment per https://www.python.org/dev/peps/pep-0427/#escaping-and-unicode""" 37 return re.sub(r"[^\w\d.]+", "_", segment, re.UNICODE) 38 39 40class WheelMaker(object): 41 def __init__( 42 self, 43 name, 44 version, 45 build_tag, 46 python_tag, 47 abi, 48 platform, 49 outfile=None, 50 strip_path_prefixes=None, 51 ): 52 self._name = name 53 self._version = version 54 self._build_tag = build_tag 55 self._python_tag = python_tag 56 self._abi = abi 57 self._platform = platform 58 self._outfile = outfile 59 self._strip_path_prefixes = ( 60 strip_path_prefixes if strip_path_prefixes is not None else [] 61 ) 62 63 self._distinfo_dir = ( 64 escape_filename_segment(self._name) 65 + "-" 66 + escape_filename_segment(self._version) 67 + ".dist-info/" 68 ) 69 self._zipfile = None 70 # Entries for the RECORD file as (filename, hash, size) tuples. 71 self._record = [] 72 73 def __enter__(self): 74 self._zipfile = zipfile.ZipFile( 75 self.filename(), mode="w", compression=zipfile.ZIP_DEFLATED 76 ) 77 return self 78 79 def __exit__(self, type, value, traceback): 80 self._zipfile.close() 81 self._zipfile = None 82 83 def wheelname(self) -> str: 84 components = [self._name, self._version] 85 if self._build_tag: 86 components.append(self._build_tag) 87 components += [self._python_tag, self._abi, self._platform] 88 return "-".join(components) + ".whl" 89 90 def filename(self) -> str: 91 if self._outfile: 92 return self._outfile 93 return self.wheelname() 94 95 def disttags(self): 96 return ["-".join([self._python_tag, self._abi, self._platform])] 97 98 def distinfo_path(self, basename): 99 return self._distinfo_dir + basename 100 101 def _serialize_digest(self, hash): 102 # https://www.python.org/dev/peps/pep-0376/#record 103 # "base64.urlsafe_b64encode(digest) with trailing = removed" 104 digest = base64.urlsafe_b64encode(hash.digest()) 105 digest = b"sha256=" + digest.rstrip(b"=") 106 return digest 107 108 def add_string(self, filename, contents): 109 """Add given 'contents' as filename to the distribution.""" 110 if sys.version_info[0] > 2 and isinstance(contents, str): 111 contents = contents.encode("utf-8", "surrogateescape") 112 self._zipfile.writestr(filename, contents) 113 hash = hashlib.sha256() 114 hash.update(contents) 115 self._add_to_record(filename, self._serialize_digest(hash), len(contents)) 116 117 def add_file(self, package_filename, real_filename): 118 """Add given file to the distribution.""" 119 120 def arcname_from(name): 121 # Always use unix path separators. 122 normalized_arcname = name.replace(os.path.sep, "/") 123 # Don't manipulate names filenames in the .distinfo directory. 124 if normalized_arcname.startswith(self._distinfo_dir): 125 return normalized_arcname 126 for prefix in self._strip_path_prefixes: 127 if normalized_arcname.startswith(prefix): 128 return normalized_arcname[len(prefix) :] 129 130 return normalized_arcname 131 132 if os.path.isdir(real_filename): 133 directory_contents = os.listdir(real_filename) 134 for file_ in directory_contents: 135 self.add_file( 136 "{}/{}".format(package_filename, file_), 137 "{}/{}".format(real_filename, file_), 138 ) 139 return 140 141 arcname = arcname_from(package_filename) 142 143 self._zipfile.write(real_filename, arcname=arcname) 144 # Find the hash and length 145 hash = hashlib.sha256() 146 size = 0 147 with open(real_filename, "rb") as f: 148 while True: 149 block = f.read(2**20) 150 if not block: 151 break 152 hash.update(block) 153 size += len(block) 154 self._add_to_record(arcname, self._serialize_digest(hash), size) 155 156 def add_wheelfile(self): 157 """Write WHEEL file to the distribution""" 158 # TODO(pstradomski): Support non-purelib wheels. 159 wheel_contents = """\ 160Wheel-Version: 1.0 161Generator: bazel-wheelmaker 1.0 162Root-Is-Purelib: {} 163""".format( 164 "true" if self._platform == "any" else "false" 165 ) 166 for tag in self.disttags(): 167 wheel_contents += "Tag: %s\n" % tag 168 self.add_string(self.distinfo_path("WHEEL"), wheel_contents) 169 170 def add_metadata(self, metadata, name, description, version): 171 """Write METADATA file to the distribution.""" 172 # https://www.python.org/dev/peps/pep-0566/ 173 # https://packaging.python.org/specifications/core-metadata/ 174 metadata = re.sub("^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE) 175 metadata += "Version: %s\n\n" % version 176 # setuptools seems to insert UNKNOWN as description when none is 177 # provided. 178 metadata += description if description else "UNKNOWN" 179 metadata += "\n" 180 self.add_string(self.distinfo_path("METADATA"), metadata) 181 182 def add_recordfile(self): 183 """Write RECORD file to the distribution.""" 184 record_path = self.distinfo_path("RECORD") 185 entries = self._record + [(record_path, b"", b"")] 186 entries.sort() 187 contents = b"" 188 for filename, digest, size in entries: 189 if sys.version_info[0] > 2 and isinstance(filename, str): 190 filename = filename.lstrip("/").encode("utf-8", "surrogateescape") 191 contents += b"%s,%s,%s\n" % (filename, digest, size) 192 self.add_string(record_path, contents) 193 194 def _add_to_record(self, filename, hash, size): 195 size = str(size).encode("ascii") 196 self._record.append((filename, hash, size)) 197 198 199def get_files_to_package(input_files): 200 """Find files to be added to the distribution. 201 202 input_files: list of pairs (package_path, real_path) 203 """ 204 files = {} 205 for package_path, real_path in input_files: 206 files[package_path] = real_path 207 return files 208 209 210def resolve_argument_stamp( 211 argument: str, volatile_status_stamp: Path, stable_status_stamp: Path 212) -> str: 213 """Resolve workspace status stamps format strings found in the argument string 214 215 Args: 216 argument (str): The raw argument represenation for the wheel (may include stamp variables) 217 volatile_status_stamp (Path): The path to a volatile workspace status file 218 stable_status_stamp (Path): The path to a stable workspace status file 219 220 Returns: 221 str: A resolved argument string 222 """ 223 lines = ( 224 volatile_status_stamp.read_text().splitlines() 225 + stable_status_stamp.read_text().splitlines() 226 ) 227 for line in lines: 228 if not line: 229 continue 230 key, value = line.split(" ", maxsplit=1) 231 stamp = "{" + key + "}" 232 argument = argument.replace(stamp, value) 233 234 return argument 235 236 237def parse_args() -> argparse.Namespace: 238 parser = argparse.ArgumentParser(description="Builds a python wheel") 239 metadata_group = parser.add_argument_group("Wheel name, version and platform") 240 metadata_group.add_argument( 241 "--name", required=True, type=str, help="Name of the distribution" 242 ) 243 metadata_group.add_argument( 244 "--version", required=True, type=str, help="Version of the distribution" 245 ) 246 metadata_group.add_argument( 247 "--build_tag", 248 type=str, 249 default="", 250 help="Optional build tag for the distribution", 251 ) 252 metadata_group.add_argument( 253 "--python_tag", 254 type=str, 255 default="py3", 256 help="Python version, e.g. 'py2' or 'py3'", 257 ) 258 metadata_group.add_argument("--abi", type=str, default="none") 259 metadata_group.add_argument( 260 "--platform", type=str, default="any", help="Target platform. " 261 ) 262 263 output_group = parser.add_argument_group("Output file location") 264 output_group.add_argument( 265 "--out", type=str, default=None, help="Override name of ouptut file" 266 ) 267 output_group.add_argument( 268 "--name_file", 269 type=Path, 270 help="A file where the canonical name of the " "wheel will be written", 271 ) 272 273 output_group.add_argument( 274 "--strip_path_prefix", 275 type=str, 276 action="append", 277 default=[], 278 help="Path prefix to be stripped from input package files' path. " 279 "Can be supplied multiple times. Evaluated in order.", 280 ) 281 282 wheel_group = parser.add_argument_group("Wheel metadata") 283 wheel_group.add_argument( 284 "--metadata_file", 285 type=Path, 286 help="Contents of the METADATA file (before appending contents of " 287 "--description_file)", 288 ) 289 wheel_group.add_argument( 290 "--description_file", help="Path to the file with package description" 291 ) 292 wheel_group.add_argument( 293 "--description_content_type", help="Content type of the package description" 294 ) 295 wheel_group.add_argument( 296 "--entry_points_file", 297 help="Path to a correctly-formatted entry_points.txt file", 298 ) 299 300 contents_group = parser.add_argument_group("Wheel contents") 301 contents_group.add_argument( 302 "--input_file", 303 action="append", 304 help="'package_path;real_path' pairs listing " 305 "files to be included in the wheel. " 306 "Can be supplied multiple times.", 307 ) 308 contents_group.add_argument( 309 "--input_file_list", 310 action="append", 311 help="A file that has all the input files defined as a list to avoid " 312 "the long command", 313 ) 314 contents_group.add_argument( 315 "--extra_distinfo_file", 316 action="append", 317 help="'filename;real_path' pairs listing extra files to include in" 318 "dist-info directory. Can be supplied multiple times.", 319 ) 320 321 build_group = parser.add_argument_group("Building requirements") 322 build_group.add_argument( 323 "--volatile_status_file", 324 type=Path, 325 help="Pass in the stamp info file for stamping", 326 ) 327 build_group.add_argument( 328 "--stable_status_file", 329 type=Path, 330 help="Pass in the stamp info file for stamping", 331 ) 332 333 return parser.parse_args(sys.argv[1:]) 334 335 336def main() -> None: 337 arguments = parse_args() 338 339 if arguments.input_file: 340 input_files = [i.split(";") for i in arguments.input_file] 341 else: 342 input_files = [] 343 344 if arguments.extra_distinfo_file: 345 extra_distinfo_file = [i.split(";") for i in arguments.extra_distinfo_file] 346 else: 347 extra_distinfo_file = [] 348 349 if arguments.input_file_list: 350 for input_file in arguments.input_file_list: 351 with open(input_file) as _file: 352 input_file_list = _file.read().splitlines() 353 for _input_file in input_file_list: 354 input_files.append(_input_file.split(";")) 355 356 all_files = get_files_to_package(input_files) 357 # Sort the files for reproducible order in the archive. 358 all_files = sorted(all_files.items()) 359 360 strip_prefixes = [p for p in arguments.strip_path_prefix] 361 362 if arguments.volatile_status_file and arguments.stable_status_file: 363 name = resolve_argument_stamp( 364 arguments.name, 365 arguments.volatile_status_file, 366 arguments.stable_status_file, 367 ) 368 else: 369 name = arguments.name 370 371 if arguments.volatile_status_file and arguments.stable_status_file: 372 version = resolve_argument_stamp( 373 arguments.version, 374 arguments.volatile_status_file, 375 arguments.stable_status_file, 376 ) 377 else: 378 version = arguments.version 379 380 with WheelMaker( 381 name=name, 382 version=version, 383 build_tag=arguments.build_tag, 384 python_tag=arguments.python_tag, 385 abi=arguments.abi, 386 platform=arguments.platform, 387 outfile=arguments.out, 388 strip_path_prefixes=strip_prefixes, 389 ) as maker: 390 for package_filename, real_filename in all_files: 391 maker.add_file(package_filename, real_filename) 392 maker.add_wheelfile() 393 394 description = None 395 if arguments.description_file: 396 if sys.version_info[0] == 2: 397 with open(arguments.description_file, "rt") as description_file: 398 description = description_file.read() 399 else: 400 with open( 401 arguments.description_file, "rt", encoding="utf-8" 402 ) as description_file: 403 description = description_file.read() 404 405 metadata = None 406 if sys.version_info[0] == 2: 407 with open(arguments.metadata_file, "rt") as metadata_file: 408 metadata = metadata_file.read() 409 else: 410 with open(arguments.metadata_file, "rt", encoding="utf-8") as metadata_file: 411 metadata = metadata_file.read() 412 413 maker.add_metadata( 414 metadata=metadata, name=name, description=description, version=version 415 ) 416 417 if arguments.entry_points_file: 418 maker.add_file( 419 maker.distinfo_path("entry_points.txt"), arguments.entry_points_file 420 ) 421 422 # Sort the files for reproducible order in the archive. 423 for filename, real_path in sorted(extra_distinfo_file): 424 maker.add_file(maker.distinfo_path(filename), real_path) 425 426 maker.add_recordfile() 427 428 # Since stamping may otherwise change the target name of the 429 # wheel, the canonical name (with stamps resolved) is written 430 # to a file so consumers of the wheel can easily determine 431 # the correct name. 432 arguments.name_file.write_text(maker.wheelname()) 433 434 435if __name__ == "__main__": 436 main() 437