blob: 050599fa534ce1d0ee53df3995427da1f89abb43 [file] [log] [blame]
# Copyright 2018 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import base64
import collections
import hashlib
import os
import os.path
import re
import sys
import zipfile
def commonpath(path1, path2):
ret = []
for a, b in zip(path1.split(os.path.sep), path2.split(os.path.sep)):
if a != b:
break
ret.append(a)
return os.path.sep.join(ret)
def escape_filename_segment(segment):
"""Escapes a filename segment per https://www.python.org/dev/peps/pep-0427/#escaping-and-unicode"""
return re.sub(r"[^\w\d.]+", "_", segment, re.UNICODE)
class WheelMaker(object):
def __init__(self, name, version, build_tag, python_tag, abi, platform,
outfile=None, strip_path_prefixes=None):
self._name = name
self._version = version
self._build_tag = build_tag
self._python_tag = python_tag
self._abi = abi
self._platform = platform
self._outfile = outfile
self._strip_path_prefixes = strip_path_prefixes if strip_path_prefixes is not None else []
self._distinfo_dir = (escape_filename_segment(self._name) + '-' +
escape_filename_segment(self._version) +
'.dist-info/')
self._zipfile = None
self._record = []
def __enter__(self):
self._zipfile = zipfile.ZipFile(self.filename(), mode="w",
compression=zipfile.ZIP_DEFLATED)
return self
def __exit__(self, type, value, traceback):
self._zipfile.close()
self._zipfile = None
def filename(self):
if self._outfile:
return self._outfile
components = [self._name, self._version]
if self._build_tag:
components.append(self._build_tag)
components += [self._python_tag, self._abi, self._platform]
return '-'.join(components) + '.whl'
def disttags(self):
return ['-'.join([self._python_tag, self._abi, self._platform])]
def distinfo_path(self, basename):
return self._distinfo_dir + basename
def _serialize_digest(self, hash):
# https://www.python.org/dev/peps/pep-0376/#record
# "base64.urlsafe_b64encode(digest) with trailing = removed"
digest = base64.urlsafe_b64encode(hash.digest())
digest = b'sha256=' + digest.rstrip(b'=')
return digest
def add_string(self, filename, contents):
"""Add given 'contents' as filename to the distribution."""
if sys.version_info[0] > 2 and isinstance(contents, str):
contents = contents.encode('utf-8', 'surrogateescape')
self._zipfile.writestr(filename, contents)
hash = hashlib.sha256()
hash.update(contents)
self._add_to_record(filename, self._serialize_digest(hash),
len(contents))
def add_file(self, package_filename, real_filename):
"""Add given file to the distribution."""
def arcname_from(name):
# Always use unix path separators.
normalized_arcname = name.replace(os.path.sep, '/')
for prefix in self._strip_path_prefixes:
if normalized_arcname.startswith(prefix):
return normalized_arcname[len(prefix):]
return normalized_arcname
if os.path.isdir(real_filename):
directory_contents = os.listdir(real_filename)
for file_ in directory_contents:
self.add_file("{}/{}".format(package_filename, file_),
"{}/{}".format(real_filename, file_))
return
arcname = arcname_from(package_filename)
self._zipfile.write(real_filename, arcname=arcname)
# Find the hash and length
hash = hashlib.sha256()
size = 0
with open(real_filename, 'rb') as f:
while True:
block = f.read(2 ** 20)
if not block:
break
hash.update(block)
size += len(block)
self._add_to_record(arcname, self._serialize_digest(hash), size)
def add_wheelfile(self):
"""Write WHEEL file to the distribution"""
# TODO(pstradomski): Support non-purelib wheels.
wheel_contents = """\
Wheel-Version: 1.0
Generator: bazel-wheelmaker 1.0
Root-Is-Purelib: {}
""".format("true" if self._platform == "any" else "false")
for tag in self.disttags():
wheel_contents += "Tag: %s\n" % tag
self.add_string(self.distinfo_path('WHEEL'), wheel_contents)
def add_metadata(self, extra_headers, description, classifiers, python_requires,
requires, extra_requires):
"""Write METADATA file to the distribution."""
# https://www.python.org/dev/peps/pep-0566/
# https://packaging.python.org/specifications/core-metadata/
metadata = []
metadata.append("Metadata-Version: 2.1")
metadata.append("Name: %s" % self._name)
metadata.append("Version: %s" % self._version)
metadata.extend(extra_headers)
for classifier in classifiers:
metadata.append("Classifier: %s" % classifier)
if python_requires:
metadata.append("Requires-Python: %s" % python_requires)
for requirement in requires:
metadata.append("Requires-Dist: %s" % requirement)
extra_requires = sorted(extra_requires.items())
for option, option_requires in extra_requires:
metadata.append("Provides-Extra: %s" % option)
for requirement in option_requires:
metadata.append(
"Requires-Dist: %s; extra == '%s'" % (requirement, option))
metadata = '\n'.join(metadata) + '\n\n'
# setuptools seems to insert UNKNOWN as description when none is
# provided.
metadata += description if description else "UNKNOWN"
metadata += "\n"
self.add_string(self.distinfo_path('METADATA'), metadata)
def add_recordfile(self):
"""Write RECORD file to the distribution."""
record_path = self.distinfo_path('RECORD')
entries = self._record + [(record_path, b'', b'')]
entries.sort()
contents = b''
for filename, digest, size in entries:
if sys.version_info[0] > 2 and isinstance(filename, str):
filename = filename.encode('utf-8', 'surrogateescape')
contents += b'%s,%s,%s\n' % (filename, digest, size)
self.add_string(record_path, contents)
def _add_to_record(self, filename, hash, size):
size = str(size).encode('ascii')
self._record.append((filename, hash, size))
def get_files_to_package(input_files):
"""Find files to be added to the distribution.
input_files: list of pairs (package_path, real_path)
"""
files = {}
for package_path, real_path in input_files:
files[package_path] = real_path
return files
def main():
parser = argparse.ArgumentParser(description='Builds a python wheel')
metadata_group = parser.add_argument_group(
"Wheel name, version and platform")
metadata_group.add_argument('--name', required=True,
type=str,
help="Name of the distribution")
metadata_group.add_argument('--version', required=True,
type=str,
help="Version of the distribution")
metadata_group.add_argument('--build_tag', type=str, default='',
help="Optional build tag for the distribution")
metadata_group.add_argument('--python_tag', type=str, default='py3',
help="Python version, e.g. 'py2' or 'py3'")
metadata_group.add_argument('--abi', type=str, default='none')
metadata_group.add_argument('--platform', type=str, default='any',
help="Target platform. ")
output_group = parser.add_argument_group("Output file location")
output_group.add_argument('--out', type=str, default=None,
help="Override name of ouptut file")
output_group.add_argument('--strip_path_prefix',
type=str,
action="append",
default=[],
help="Path prefix to be stripped from input package files' path. "
"Can be supplied multiple times. "
"Evaluated in order."
)
wheel_group = parser.add_argument_group("Wheel metadata")
wheel_group.add_argument(
'--header', action='append',
help="Additional headers to be embedded in the package metadata. "
"Can be supplied multiple times.")
wheel_group.add_argument('--classifier', action='append',
help="Classifiers to embed in package metadata. "
"Can be supplied multiple times")
wheel_group.add_argument('--python_requires',
help="Version of python that the wheel will work with")
wheel_group.add_argument('--description_file',
help="Path to the file with package description")
wheel_group.add_argument('--entry_points_file',
help="Path to a correctly-formatted entry_points.txt file")
contents_group = parser.add_argument_group("Wheel contents")
contents_group.add_argument(
'--input_file', action='append',
help="'package_path;real_path' pairs listing "
"files to be included in the wheel. "
"Can be supplied multiple times.")
contents_group.add_argument(
'--input_file_list', action='append',
help='A file that has all the input files defined as a list to avoid the long command'
)
requirements_group = parser.add_argument_group("Package requirements")
requirements_group.add_argument(
'--requires', type=str, action='append',
help="List of package requirements. Can be supplied multiple times.")
requirements_group.add_argument(
'--extra_requires', type=str, action='append',
help="List of optional requirements in a 'requirement;option name'. "
"Can be supplied multiple times.")
arguments = parser.parse_args(sys.argv[1:])
if arguments.input_file:
input_files = [i.split(';') for i in arguments.input_file]
else:
input_files = []
if arguments.input_file_list:
for input_file in arguments.input_file_list:
with open(input_file) as _file:
input_file_list = _file.read().splitlines()
for _input_file in input_file_list:
input_files.append(_input_file.split(';'))
all_files = get_files_to_package(input_files)
# Sort the files for reproducible order in the archive.
all_files = sorted(all_files.items())
strip_prefixes = [p for p in arguments.strip_path_prefix]
with WheelMaker(name=arguments.name,
version=arguments.version,
build_tag=arguments.build_tag,
python_tag=arguments.python_tag,
abi=arguments.abi,
platform=arguments.platform,
outfile=arguments.out,
strip_path_prefixes=strip_prefixes
) as maker:
for package_filename, real_filename in all_files:
maker.add_file(package_filename, real_filename)
maker.add_wheelfile()
description = None
if arguments.description_file:
if sys.version_info[0] == 2:
with open(arguments.description_file,
'rt') as description_file:
description = description_file.read()
else:
with open(arguments.description_file, 'rt',
encoding='utf-8') as description_file:
description = description_file.read()
extra_requires = collections.defaultdict(list)
if arguments.extra_requires:
for extra in arguments.extra_requires:
req, option = extra.rsplit(';', 1)
extra_requires[option].append(req)
classifiers = arguments.classifier or []
python_requires = arguments.python_requires or ""
requires = arguments.requires or []
extra_headers = arguments.header or []
maker.add_metadata(extra_headers=extra_headers,
description=description,
classifiers=classifiers,
python_requires=python_requires,
requires=requires,
extra_requires=extra_requires)
if arguments.entry_points_file:
maker.add_file(maker.distinfo_path(
"entry_points.txt"), arguments.entry_points_file)
maker.add_recordfile()
if __name__ == '__main__':
main()