Source code for friendly_dist_manager.package_formats.wheel.wheel_file

"""Primitives for manipulating Python wheel files"""
import zipfile
import hashlib
from pathlib import Path
import shutil
from base64 import urlsafe_b64encode
from textwrap import dedent
import tempfile
from os import environ
from friendly_dist_manager import __version__
from .metadata_file import MetadataFile, ExtraRequirement


[docs]class WheelFile: # pylint: disable=too-many-instance-attributes """Abstraction around a Python wheel file References: * (Latest Spec) https://packaging.python.org/specifications/binary-distribution-format/ * (Accepted PEP) https://www.python.org/dev/peps/pep-0427/ * (Proposed PEP) https://www.python.org/dev/peps/pep-0491/ """ def __init__(self, dist_name, version): """ Args: dist_name (str): name of the distribution package being built version (str): version number associated with the new distribution package """ self._file_version = "1.0" self._python_tag = "py3" self._abi_tag = "none" self._platform_tag = "any" self._build_tag = None self._is_pure = True self._metadata = MetadataFile(dist_name, version) self._temp_dir_cache = tempfile.TemporaryDirectory()
[docs] @classmethod def from_pyproject(cls, pyproject_config): """Factory method that instantiates an instance of this class from data stored in a pyproject.toml configuration file Args: pyproject_config (PyProjectParser): config file for a PEP518 compliant Python package Returns: WheelFile: Instance of the class pre-initialized from data provided by the given config file """ retval = cls(pyproject_config.project.name, pyproject_config.project.version) retval.metadata.summary = pyproject_config.project.description retval.metadata.authors = pyproject_config.project.authors retval.metadata.license = pyproject_config.project.license retval.metadata.keywords = pyproject_config.project.keywords retval.metadata.classifiers = pyproject_config.project.classifiers retval.metadata.maintainers = pyproject_config.project.maintainers retval.metadata.requirements = pyproject_config.project.dependencies retval.metadata.python_requirements = [pyproject_config.project.python_requirement] for cur_url in pyproject_config.project.urls: if cur_url.label.lower() == "download": retval.metadata.download_url = cur_url elif cur_url.label.lower() == "homepage": retval.metadata.homepage = cur_url else: retval.metadata.project_urls.append(cur_url) for cur_id in pyproject_config.project.optional_dependency_identifiers: for cur_dep in pyproject_config.project.get_optional_dependencies(cur_id): retval.metadata.extra_requirements.append(ExtraRequirement(cur_id, cur_dep)) return retval
@property def metadata(self): """MetadataFile: properties describing this distribution package""" return self._metadata @property def _temp_dir(self): """pathlib.Path: Gets a reference to the temporary folder where this object stores package data for building""" if "TFC_TEMP_DIR" not in environ: return Path(self._temp_dir_cache.name) retval = Path(environ["TFC_TEMP_DIR"]) # pragma: no cover if not retval.exists(): # pragma: no cover retval.mkdir() return retval # pragma: no cover @property def filename(self): """str: gets the fully qualified file name of the wheel file to be generated""" retval = f"{self.metadata.distribution_name}-{self.metadata.distribution_version}" if self._build_tag: retval += f"-{self._build_tag}" retval += f"-{self._python_tag}-{self._abi_tag}-{self._platform_tag}.whl" return retval
[docs] def add_file(self, src_file, target_path): """Adds a new file to the package Args: src_file (pathlib.Path): reference to the source file to be packaged target_path (pathlib.Path): location, relative to the root of the wheel file, where this new file should be deployed """ temp = self._temp_dir / target_path if not temp.exists(): temp.mkdir(parents=True) shutil.copy(src_file, temp)
@staticmethod def _sha_256(src_file): """Calculates the SHA256 checksum for a file Args: src_file (pathlib.Path): reference to the source file to process Returns: str: b64 encoded SHA256 hash of the file, in UTF-8 format suitable for writing to disk in certain metadata files """ retval = hashlib.sha256() with src_file.open("rb") as src: buffer = src.read(1024 * 8) while buffer: retval.update(buffer) buffer = src.read(1024 * 8) return urlsafe_b64encode(retval.digest()).decode("utf-8").rstrip("=") @staticmethod def _clean_data(data): """Cleans text data before writing to a metadata file * Removes blank lines * left-justifies all data blocks Args: data (str): raw character string data to be written to disk Returns: str: cleaned and processed text data that is safe to write to disk """ return dedent("\n".join(line for line in data.split("\n") if line)) def _make_dist_info(self): """Constructs the dist-info folder for the wheel file""" info_dir = \ self._temp_dir / \ f"{self.metadata.distribution_name}-{self.metadata.distribution_version}.dist-info" wheel_file = info_dir / "WHEEL" meta_file = info_dir / "METADATA" record_file = info_dir / "RECORD" info_dir.mkdir(parents=True) dist_name = __name__.split(".")[0] wheel_data = f""" Wheel-Version: {self._file_version} Generator: {dist_name} ({__version__}) Root-Is-Purelib: {self._is_pure} Tag: {self._python_tag}-{self._abi_tag}-{self._platform_tag} """ wheel_file.write_text(self._clean_data(wheel_data)) meta_file.write_text(self.metadata.raw) record_data = "" for cur_file in self._temp_dir.glob("**/*"): if cur_file.is_dir(): continue record_data += f"{cur_file.relative_to(self._temp_dir)}," \ f"sha256={self._sha_256(cur_file)}," \ f"{cur_file.stat().st_size}\n" # We have to include the RECORD file itself in the index but # we need to exclude the hash and size fields record_data += f"{record_file.relative_to(self._temp_dir)},,\n" record_file.write_text(record_data)
[docs] def build(self, output_path): """Constructs a wheel file from the metadata stored in this class Args: output_path (pathlib.Path): folder where the wheel file should be generated Returns: pathlib.Path: Reference to the newly generated wheel file """ output_file = output_path / self.filename if output_file.exists(): raise FileExistsError(f"File already exists: {output_file}") self._make_dist_info() with zipfile.ZipFile(output_file, mode="w", compression=zipfile.ZIP_DEFLATED) as zip_file: for cur_file in self._temp_dir.glob("**/*"): if cur_file.is_dir(): continue rel_path = cur_file.relative_to(self._temp_dir) zinfo = zipfile.ZipInfo(str(rel_path)) with cur_file.open("rb") as src: zip_file.writestr(zinfo, src.read(), compress_type=zipfile.ZIP_DEFLATED) return output_file