mirror of https://github.com/pypa/hatch.git
433 lines
17 KiB
Python
433 lines
17 KiB
Python
import hashlib
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from contextlib import closing
|
|
|
|
from ..__about__ import __version__
|
|
from ..metadata.utils import DEFAULT_METADATA_VERSION, get_core_metadata_constructors
|
|
from .plugin.interface import BuilderInterface
|
|
from .utils import (
|
|
format_file_hash,
|
|
get_known_python_major_versions,
|
|
get_reproducible_timestamp,
|
|
normalize_archive_path,
|
|
normalize_file_permissions,
|
|
replace_file,
|
|
set_zip_info_mode,
|
|
)
|
|
|
|
try:
|
|
from io import StringIO
|
|
except ImportError: # no cov
|
|
from StringIO import StringIO
|
|
|
|
try:
|
|
from editables import EditableProject
|
|
except ImportError: # no cov
|
|
EditableProject = None
|
|
|
|
EDITABLES_MINIMUM_VERSION = '0.2'
|
|
|
|
|
|
class WheelArchive(object):
|
|
def __init__(self, metadata_directory, reproducible):
|
|
"""
|
|
https://www.python.org/dev/peps/pep-0427/#abstract
|
|
"""
|
|
self.metadata_directory = metadata_directory
|
|
self.reproducible = reproducible
|
|
if self.reproducible:
|
|
self.time_tuple = self.get_reproducible_time_tuple()
|
|
else:
|
|
self.time_tuple = None
|
|
|
|
raw_fd, self.path = tempfile.mkstemp(suffix='.whl')
|
|
self.fd = os.fdopen(raw_fd, 'w+b')
|
|
self.zf = zipfile.ZipFile(self.fd, 'w', compression=zipfile.ZIP_DEFLATED)
|
|
|
|
if sys.version_info >= (3, 6):
|
|
|
|
@staticmethod
|
|
def get_reproducible_time_tuple():
|
|
from datetime import datetime, timezone
|
|
|
|
d = datetime.fromtimestamp(get_reproducible_timestamp(), timezone.utc)
|
|
return d.year, d.month, d.day, d.hour, d.minute, d.second
|
|
|
|
def add_file(self, included_file):
|
|
relative_path = normalize_archive_path(included_file.distribution_path)
|
|
file_stat = os.stat(included_file.path)
|
|
|
|
if self.reproducible:
|
|
zip_info = zipfile.ZipInfo(relative_path, self.time_tuple)
|
|
|
|
# https://github.com/takluyver/flit/pull/66
|
|
new_mode = normalize_file_permissions(file_stat.st_mode)
|
|
set_zip_info_mode(zip_info, new_mode & 0xFFFF)
|
|
if stat.S_ISDIR(file_stat.st_mode): # no cov
|
|
zip_info.external_attr |= 0x10
|
|
else:
|
|
zip_info = zipfile.ZipInfo.from_file(included_file.path, relative_path)
|
|
|
|
zip_info.compress_type = zipfile.ZIP_DEFLATED
|
|
|
|
hash_obj = hashlib.sha256()
|
|
with open(included_file.path, 'rb') as in_file, self.zf.open(zip_info, 'w') as out_file:
|
|
while True:
|
|
chunk = in_file.read(16384)
|
|
if not chunk:
|
|
break
|
|
|
|
hash_obj.update(chunk)
|
|
out_file.write(chunk)
|
|
|
|
hash_digest = format_file_hash(hash_obj.digest())
|
|
return relative_path, hash_digest, file_stat.st_size
|
|
|
|
else: # no cov
|
|
|
|
@staticmethod
|
|
def get_reproducible_time_tuple():
|
|
from datetime import datetime
|
|
|
|
d = datetime.utcfromtimestamp(get_reproducible_timestamp())
|
|
return d.year, d.month, d.day, d.hour, d.minute, d.second
|
|
|
|
def add_file(self, included_file):
|
|
relative_path = normalize_archive_path(included_file.distribution_path)
|
|
self.zf.write(included_file.path, arcname=relative_path)
|
|
|
|
hash_obj = hashlib.sha256()
|
|
with open(included_file.path, 'rb') as in_file:
|
|
while True:
|
|
chunk = in_file.read(16384)
|
|
if not chunk:
|
|
break
|
|
|
|
hash_obj.update(chunk)
|
|
|
|
hash_digest = format_file_hash(hash_obj.digest())
|
|
return relative_path, hash_digest, os.stat(included_file.path).st_size
|
|
|
|
def write_metadata(self, relative_path, contents):
|
|
relative_path = '{}/{}'.format(self.metadata_directory, normalize_archive_path(relative_path))
|
|
return self.write_file(relative_path, contents)
|
|
|
|
def write_file(self, relative_path, contents):
|
|
if not isinstance(contents, bytes):
|
|
contents = contents.encode('utf-8')
|
|
|
|
time_tuple = self.time_tuple or (2020, 2, 2, 0, 0, 0)
|
|
zip_info = zipfile.ZipInfo(relative_path, time_tuple)
|
|
set_zip_info_mode(zip_info)
|
|
|
|
hash_obj = hashlib.sha256(contents)
|
|
hash_digest = format_file_hash(hash_obj.digest())
|
|
self.zf.writestr(zip_info, contents, compress_type=zipfile.ZIP_DEFLATED)
|
|
|
|
return relative_path, hash_digest, len(contents)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
self.zf.close()
|
|
self.fd.close()
|
|
|
|
|
|
class WheelBuilder(BuilderInterface):
|
|
"""
|
|
Build a binary distribution (.whl file)
|
|
"""
|
|
|
|
PLUGIN_NAME = 'wheel'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(WheelBuilder, self).__init__(*args, **kwargs)
|
|
|
|
self.__core_metadata_constructor = None
|
|
self.__zip_safe = None
|
|
|
|
def get_version_api(self):
|
|
return {'standard': self.build_standard, 'editable': self.build_editable}
|
|
|
|
def get_default_versions(self):
|
|
return ['standard']
|
|
|
|
def clean(self, directory, versions):
|
|
for filename in os.listdir(directory):
|
|
if filename.endswith('.whl'):
|
|
os.remove(os.path.join(directory, filename))
|
|
|
|
def build_standard(self, directory, **build_data):
|
|
if 'tag' not in build_data:
|
|
if build_data['infer_tag']:
|
|
from packaging.tags import sys_tags
|
|
|
|
best_matching_tag = next(sys_tags())
|
|
tag_parts = (best_matching_tag.interpreter, best_matching_tag.abi, best_matching_tag.platform)
|
|
build_data['tag'] = '-'.join(tag_parts)
|
|
else:
|
|
build_data['tag'] = self.get_default_tag()
|
|
|
|
metadata_directory = '{}.dist-info'.format(self.project_id)
|
|
with WheelArchive(metadata_directory, self.reproducible) as archive, closing(StringIO()) as records:
|
|
for included_file in self.recurse_project_files():
|
|
record = archive.add_file(included_file)
|
|
records.write(self.format_record(record))
|
|
|
|
self.write_metadata(archive, records, build_data)
|
|
|
|
records.write(u'{}/RECORD,,\n'.format(metadata_directory))
|
|
archive.write_metadata('RECORD', records.getvalue())
|
|
|
|
target = os.path.join(directory, '{}-{}.whl'.format(self.project_id, build_data['tag']))
|
|
|
|
replace_file(archive.path, target)
|
|
return target
|
|
|
|
def build_editable(self, directory, **build_data):
|
|
if sys.version_info[0] < 3 or self.dev_mode_dirs:
|
|
return self.build_editable_pth(directory, **build_data)
|
|
else:
|
|
return self.build_editable_standard(directory, **build_data)
|
|
|
|
def build_editable_standard(self, directory, **build_data):
|
|
build_data['tag'] = self.get_default_tag()
|
|
|
|
metadata_directory = '{}.dist-info'.format(self.project_id)
|
|
with WheelArchive(metadata_directory, self.reproducible) as archive, closing(StringIO()) as records:
|
|
exposed_packages = {}
|
|
for included_file in self.recurse_project_files():
|
|
relative_path = included_file.relative_path
|
|
distribution_path = included_file.distribution_path
|
|
|
|
path_parts = relative_path.split(os.sep)
|
|
|
|
# Root file
|
|
if len(path_parts) == 1: # no cov
|
|
exposed_packages[os.path.splitext(relative_path)[0]] = os.path.join(self.root, relative_path)
|
|
continue
|
|
|
|
# Root package
|
|
root_module = path_parts[0]
|
|
if distribution_path == relative_path:
|
|
exposed_packages[root_module] = os.path.join(self.root, root_module)
|
|
else:
|
|
distribution_module = distribution_path.split(os.sep)[0]
|
|
exposed_packages[distribution_module] = os.path.join(
|
|
self.root,
|
|
'{}{}'.format(relative_path[: relative_path.index(distribution_path)], distribution_module),
|
|
)
|
|
|
|
editable_project = EditableProject(self.metadata.core.name.replace('-', '_'), self.root)
|
|
for module, relative_path in exposed_packages.items():
|
|
editable_project.map(module, relative_path)
|
|
|
|
for filename, content in sorted(editable_project.files()):
|
|
record = archive.write_file(filename, content)
|
|
records.write(self.format_record(record))
|
|
|
|
extra_dependencies = []
|
|
for dependency in editable_project.dependencies():
|
|
if dependency == 'editables':
|
|
dependency += '~={}'.format(EDITABLES_MINIMUM_VERSION)
|
|
else: # no cov
|
|
pass
|
|
|
|
extra_dependencies.append(dependency)
|
|
|
|
self.write_metadata(archive, records, build_data, extra_dependencies=extra_dependencies)
|
|
|
|
records.write(u'{}/RECORD,,\n'.format(metadata_directory))
|
|
archive.write_metadata('RECORD', records.getvalue())
|
|
|
|
target = os.path.join(directory, '{}-{}.whl'.format(self.project_id, build_data['tag']))
|
|
|
|
replace_file(archive.path, target)
|
|
return target
|
|
|
|
if sys.version_info[0] >= 3:
|
|
|
|
def build_editable_pth(self, directory, **build_data):
|
|
build_data['tag'] = self.get_default_tag()
|
|
|
|
metadata_directory = '{}.dist-info'.format(self.project_id)
|
|
with WheelArchive(metadata_directory, self.reproducible) as archive, closing(StringIO()) as records:
|
|
editable_project = EditableProject(self.metadata.core.name.replace('-', '_'), self.root)
|
|
|
|
for relative_directory in self.dev_mode_dirs:
|
|
editable_project.add_to_path(relative_directory)
|
|
|
|
for filename, content in sorted(editable_project.files()):
|
|
record = archive.write_file(filename, content)
|
|
records.write(self.format_record(record))
|
|
|
|
extra_dependencies = []
|
|
for dependency in editable_project.dependencies():
|
|
if dependency == 'editables':
|
|
dependency += '~={}'.format(EDITABLES_MINIMUM_VERSION)
|
|
else: # no cov
|
|
pass
|
|
|
|
extra_dependencies.append(dependency)
|
|
|
|
self.write_metadata(archive, records, build_data, extra_dependencies=extra_dependencies)
|
|
|
|
records.write(u'{}/RECORD,,\n'.format(metadata_directory))
|
|
archive.write_metadata('RECORD', records.getvalue())
|
|
|
|
target = os.path.join(directory, '{}-{}.whl'.format(self.project_id, build_data['tag']))
|
|
|
|
replace_file(archive.path, target)
|
|
return target
|
|
|
|
else: # no cov
|
|
|
|
def build_editable_pth(self, directory, **build_data):
|
|
build_data['tag'] = self.get_default_tag()
|
|
|
|
metadata_directory = '{}.dist-info'.format(self.project_id)
|
|
with WheelArchive(metadata_directory, self.reproducible) as archive, closing(StringIO()) as records:
|
|
directories = []
|
|
for relative_directory in self.dev_mode_dirs:
|
|
directories.append(os.path.normpath(os.path.join(self.root, relative_directory)))
|
|
|
|
record = archive.write_file(
|
|
'{}.pth'.format(self.metadata.core.name.replace('-', '_')), '\n'.join(directories)
|
|
)
|
|
records.write(self.format_record(record))
|
|
|
|
self.write_metadata(archive, records, build_data)
|
|
|
|
records.write(u'{}/RECORD,,\n'.format(metadata_directory))
|
|
archive.write_metadata('RECORD', records.getvalue())
|
|
|
|
target = os.path.join(directory, '{}-{}.whl'.format(self.project_id, build_data['tag']))
|
|
|
|
replace_file(archive.path, target)
|
|
return target
|
|
|
|
def write_metadata(self, archive, records, build_data, extra_dependencies=()):
|
|
# <<< IMPORTANT >>>
|
|
# Ensure calls are ordered by file name
|
|
|
|
# license_files/
|
|
self.add_licenses(archive, records)
|
|
|
|
# METADATA
|
|
self.write_project_metadata(archive, records, extra_dependencies=extra_dependencies)
|
|
|
|
# WHEEL
|
|
self.write_archive_metadata(archive, records, build_data)
|
|
|
|
# entry_points.txt
|
|
self.write_entry_points_file(archive, records)
|
|
|
|
def write_archive_metadata(self, archive, records, build_data):
|
|
from packaging.tags import parse_tag
|
|
|
|
metadata = 'Wheel-Version: 1.0\nGenerator: hatch {}\nRoot-Is-Purelib: {}\n'.format(
|
|
__version__, 'true' if build_data.get('zip_safe', self.zip_safe) else 'false'
|
|
)
|
|
|
|
for tag in sorted(map(str, parse_tag(build_data['tag']))):
|
|
metadata += 'Tag: {}\n'.format(tag)
|
|
|
|
record = archive.write_metadata('WHEEL', metadata)
|
|
records.write(self.format_record(record))
|
|
|
|
def write_entry_points_file(self, archive, records):
|
|
record = archive.write_metadata('entry_points.txt', self.construct_entry_points_file())
|
|
records.write(self.format_record(record))
|
|
|
|
def write_project_metadata(self, archive, records, extra_dependencies=()):
|
|
record = archive.write_metadata(
|
|
'METADATA', self.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies)
|
|
)
|
|
records.write(self.format_record(record))
|
|
|
|
def add_licenses(self, archive, records):
|
|
for relative_path in self.metadata.core.license_files:
|
|
license_file = os.path.normpath(os.path.join(self.root, relative_path))
|
|
with open(license_file, 'rb') as f:
|
|
record = archive.write_metadata('license_files/{}'.format(relative_path), f.read())
|
|
records.write(self.format_record(record))
|
|
|
|
def construct_entry_points_file(self):
|
|
core_metadata = self.metadata.core
|
|
metadata_file = ''
|
|
|
|
if core_metadata.scripts:
|
|
metadata_file += '\n[console_scripts]\n'
|
|
for name, object_ref in core_metadata.scripts.items():
|
|
metadata_file += '{} = {}\n'.format(name, object_ref)
|
|
|
|
if core_metadata.gui_scripts:
|
|
metadata_file += '\n[gui_scripts]\n'
|
|
for name, object_ref in core_metadata.gui_scripts.items():
|
|
metadata_file += '{} = {}\n'.format(name, object_ref)
|
|
|
|
if core_metadata.entry_points:
|
|
for group, entry_points in core_metadata.entry_points.items():
|
|
metadata_file += '\n[{}]\n'.format(group)
|
|
for name, object_ref in entry_points.items():
|
|
metadata_file += '{} = {}\n'.format(name, object_ref)
|
|
|
|
return metadata_file.lstrip()
|
|
|
|
def get_default_tag(self):
|
|
supported_python_versions = []
|
|
for major_version in get_known_python_major_versions():
|
|
for minor_version in range(100):
|
|
if self.metadata.core.python_constraint.contains('{}.{}'.format(major_version, minor_version)):
|
|
supported_python_versions.append('py{}'.format(major_version))
|
|
break
|
|
|
|
return '{}-none-any'.format('.'.join(supported_python_versions))
|
|
|
|
def get_default_build_data(self):
|
|
return {'infer_tag': False}
|
|
|
|
def ignore_directory(self, directory):
|
|
return self.include_spec is None and directory.startswith('test')
|
|
|
|
def ignore_files(self, files):
|
|
return self.include_spec is None and '__init__.py' not in files
|
|
|
|
@property
|
|
def core_metadata_constructor(self):
|
|
if self.__core_metadata_constructor is None:
|
|
core_metadata_version = self.target_config.get('core-metadata-version', DEFAULT_METADATA_VERSION)
|
|
if not isinstance(core_metadata_version, str):
|
|
raise TypeError(
|
|
'Field `tool.hatch.build.targets.{}.core-metadata-version` must be a string'.format(
|
|
self.PLUGIN_NAME
|
|
)
|
|
)
|
|
|
|
constructors = get_core_metadata_constructors()
|
|
if core_metadata_version not in constructors:
|
|
raise ValueError(
|
|
'Unknown metadata version `{}` for field `tool.hatch.build.targets.{}.core-metadata-version`. '
|
|
'Available: {}'.format(core_metadata_version, self.PLUGIN_NAME, ', '.join(sorted(constructors)))
|
|
)
|
|
|
|
self.__core_metadata_constructor = constructors[core_metadata_version]
|
|
|
|
return self.__core_metadata_constructor
|
|
|
|
@property
|
|
def zip_safe(self):
|
|
if self.__zip_safe is None:
|
|
self.__zip_safe = bool(self.target_config.get('zip-safe', True))
|
|
|
|
return self.__zip_safe
|
|
|
|
@staticmethod
|
|
def format_record(record):
|
|
return u'{},sha256={},{}\n'.format(*record)
|