/
niceSOFT
/
python3-hatchling
Обзор
Документация
Войти
/
niceSOFT
/
python3-hatchling
Код
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
backend/src/hatchling/builders/wheel.py
901 строка
37 KB
Cary Hawkins
Addresses bug where blank lines are dropped when there is no shebang (#2328)
28 июн 2026, 08:32
Не верифицирован
28 июн 2026, 08:32
e3d9b95
Код
Авторство
О чём код?
from __future__ import annotations import csv import hashlib import os import stat import sys import tempfile import zipfile from functools import cached_property from io import StringIO from typing import TYPE_CHECKING, Any, NamedTuple, cast from hatchling.__about__ import __version__ from hatchling.builders.config import BuilderConfig from hatchling.builders.constants import EDITABLES_REQUIREMENT from hatchling.builders.plugin.interface import BuilderInterface from hatchling.builders.utils import ( format_file_hash, get_known_python_major_versions, get_reproducible_timestamp, normalize_archive_path, normalize_artifact_permissions, normalize_file_permissions, normalize_inclusion_map, replace_file, set_zip_info_mode, ) from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors if TYPE_CHECKING: from collections.abc import Callable, Iterable, Sequence from types import TracebackType from hatchling.builders.plugin.interface import IncludedFile TIME_TUPLE = tuple[int, int, int, int, int, int] # The Linux kernel only reads the first BINPRM_BUF_SIZE (256) bytes of a shebang # line, so anything longer cannot be a functional shebang and is left untouched. MAX_SHEBANG_LENGTH = 256 class FileSelectionOptions(NamedTuple): include: list[str] exclude: list[str] packages: list[str] only_include: list[str] class RecordFile: def __init__(self) -> None: self.__file_obj = StringIO() self.__writer = csv.writer(self.__file_obj, delimiter=",", quotechar='"', lineterminator="\n") def write(self, record: Iterable[Any]) -> None: self.__writer.writerow(record) def construct(self) -> str: return self.__file_obj.getvalue() def __enter__(self) -> RecordFile: # noqa: PYI034 return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None ) -> None: self.__file_obj.close() class _WheelZipFile(zipfile.ZipFile): def open(self, name, mode="r", pwd=None, *, force_zip64=False): filename = name.filename if isinstance(name, zipfile.ZipInfo) else name if mode == "w" and filename in self.NameToInfo: message = ( f"A second file is being added to the wheel archive at the same path: `{filename}`.\n\n" f"The most likely cause of this is an entry in the " f"`tool.hatch.build.targets.wheel.force-include` table. See: " f"https://hatch.pypa.io/1.8/config/build/#forced-inclusion\n\n" ) raise ValueError(message) return super().open(name, mode, pwd, force_zip64=force_zip64) class WheelArchive: def __init__(self, project_id: str, *, reproducible: bool) -> None: """ https://peps.python.org/pep-0427/#abstract """ self.metadata_directory = f"{project_id}.dist-info" self.shared_data_directory = f"{project_id}.data" self.time_tuple: TIME_TUPLE | None = None 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 = _WheelZipFile(self.fd, "w", compression=zipfile.ZIP_DEFLATED) @staticmethod def get_reproducible_time_tuple() -> TIME_TUPLE: from datetime import datetime, timezone # `zipfile.ZipInfo` does not support timestamps before 1980 min_ts = 315532800 # 1980-01-01T00:00:00Z d = datetime.fromtimestamp(max(get_reproducible_timestamp(), min_ts), timezone.utc) return d.year, d.month, d.day, d.hour, d.minute, d.second def add_file(self, included_file: IncludedFile) -> tuple[str, str, str]: 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, cast(TIME_TUPLE, 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) if stat.S_ISDIR(file_stat.st_mode): # no cov zip_info.external_attr |= 0x10 else: zip_info.file_size = file_stat.st_size 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, f"sha256={hash_digest}", str(file_stat.st_size) def write_metadata(self, relative_path: str, contents: str | bytes) -> tuple[str, str, str]: relative_path = f"{self.metadata_directory}/{normalize_archive_path(relative_path)}" return self.write_file(relative_path, contents) def write_shared_script(self, included_file: IncludedFile, contents: str | bytes) -> tuple[str, str, str]: relative_path = ( f"{self.shared_data_directory}/scripts/{normalize_archive_path(included_file.distribution_path)}" ) if sys.platform == "win32": return self.write_file(relative_path, contents) file_stat = os.stat(included_file.path) return self.write_file( relative_path, contents, mode=normalize_file_permissions(file_stat.st_mode) if self.reproducible else file_stat.st_mode, ) def add_shared_file(self, shared_file: IncludedFile) -> tuple[str, str, str]: shared_file.distribution_path = f"{self.shared_data_directory}/data/{shared_file.distribution_path}" return self.add_file(shared_file) def add_shared_script(self, shared_script: IncludedFile) -> tuple[str, str, str]: shared_script.distribution_path = f"{self.shared_data_directory}/scripts/{shared_script.distribution_path}" return self.add_file(shared_script) def add_extra_metadata_file(self, extra_metadata_file: IncludedFile) -> tuple[str, str, str]: extra_metadata_file.distribution_path = ( f"{self.metadata_directory}/extra_metadata/{extra_metadata_file.distribution_path}" ) return self.add_file(extra_metadata_file) def add_sbom_file(self, sbom_file: IncludedFile) -> tuple[str, str, str]: """Add SBOM file to .dist-info/sboms/ directory.""" sbom_file.distribution_path = f"{self.metadata_directory}/sboms/{sbom_file.distribution_path}" return self.add_file(sbom_file) def write_file( self, relative_path: str, contents: str | bytes, *, mode: int | None = None, ) -> tuple[str, str, str]: 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) if mode is None: set_zip_info_mode(zip_info) else: set_zip_info_mode(zip_info, mode) 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, f"sha256={hash_digest}", str(len(contents)) def __enter__(self) -> WheelArchive: # noqa: PYI034 return self def __exit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None ) -> None: self.zf.close() self.fd.close() class WheelBuilderConfig(BuilderConfig): def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.__core_metadata_constructor: Callable[..., str] | None = None self.__shared_data: dict[str, str] | None = None self.__shared_scripts: dict[str, str] | None = None self.__extra_metadata: dict[str, str] | None = None self.__strict_naming: bool | None = None self.__macos_max_compat: bool | None = None @cached_property def default_file_selection_options(self) -> FileSelectionOptions: include = self.target_config.get("include", self.build_config.get("include", [])) exclude = self.target_config.get("exclude", self.build_config.get("exclude", [])) packages = self.target_config.get("packages", self.build_config.get("packages", [])) only_include = self.target_config.get("only-include", self.build_config.get("only-include", [])) if include or packages or only_include: return FileSelectionOptions(include, exclude, packages, only_include) project_names: set[str] = set() for project_name in ( self.builder.normalize_file_name_component(self.builder.metadata.core.raw_name), self.builder.normalize_file_name_component(self.builder.metadata.core.name), ): if os.path.isfile(os.path.join(self.root, project_name, "__init__.py")): normalized_project_name = self.get_raw_fs_path_name(self.root, project_name) return FileSelectionOptions([], exclude, [normalized_project_name], []) if os.path.isfile(os.path.join(self.root, "src", project_name, "__init__.py")): normalized_project_name = self.get_raw_fs_path_name(os.path.join(self.root, "src"), project_name) return FileSelectionOptions([], exclude, [f"src/{normalized_project_name}"], []) module_file = f"{project_name}.py" if os.path.isfile(os.path.join(self.root, module_file)): return FileSelectionOptions([], exclude, [], [module_file]) from glob import glob possible_namespace_packages = glob(os.path.join(self.root, "*", project_name, "__init__.py")) if len(possible_namespace_packages) == 1: relative_path = os.path.relpath(possible_namespace_packages[0], self.root) namespace = relative_path.split(os.sep)[0] return FileSelectionOptions([], exclude, [namespace], []) project_names.add(project_name) if self.bypass_selection or self.build_artifact_spec is not None or self.get_force_include(): self.set_exclude_all() return FileSelectionOptions([], exclude, [], []) project_names_text = " or ".join(sorted(project_names)) message = ( f"Unable to determine which files to ship inside the wheel using the following heuristics: " f"https://hatch.pypa.io/latest/plugins/builder/wheel/#default-file-selection\n\n" f"The most likely cause of this is that there is no directory that matches the name of your " f"project ({project_names_text}).\n\n" f"At least one file selection option must be defined in the `tool.hatch.build.targets.wheel` " f"table, see: https://hatch.pypa.io/latest/config/build/\n\n" f"As an example, if you intend to ship a directory named `foo` that resides within a `src` " f"directory located at the root of your project, you can define the following:\n\n" f"[tool.hatch.build.targets.wheel]\n" f'packages = ["src/foo"]' ) raise ValueError(message) def default_include(self) -> list[str]: return self.default_file_selection_options.include def default_exclude(self) -> list[str]: return self.default_file_selection_options.exclude def default_packages(self) -> list[str]: return self.default_file_selection_options.packages def default_only_include(self) -> list[str]: return self.default_file_selection_options.only_include @property def core_metadata_constructor(self) -> Callable[..., str]: 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): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.core-metadata-version` must be a string" raise TypeError(message) constructors = get_core_metadata_constructors() if core_metadata_version not in constructors: message = ( f"Unknown metadata version `{core_metadata_version}` for field " f"`tool.hatch.build.targets.{self.plugin_name}.core-metadata-version`. " f"Available: {', '.join(sorted(constructors))}" ) raise ValueError(message) self.__core_metadata_constructor = constructors[core_metadata_version] return self.__core_metadata_constructor @property def shared_data(self) -> dict[str, str]: if self.__shared_data is None: shared_data = self.target_config.get("shared-data", {}) if not isinstance(shared_data, dict): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.shared-data` must be a mapping" raise TypeError(message) for i, (source, relative_path) in enumerate(shared_data.items(), 1): if not source: message = ( f"Source #{i} in field `tool.hatch.build.targets.{self.plugin_name}.shared-data` " f"cannot be an empty string" ) raise ValueError(message) if not isinstance(relative_path, str): message = ( f"Path for source `{source}` in field " f"`tool.hatch.build.targets.{self.plugin_name}.shared-data` must be a string" ) raise TypeError(message) if not relative_path: message = ( f"Path for source `{source}` in field " f"`tool.hatch.build.targets.{self.plugin_name}.shared-data` cannot be an empty string" ) raise ValueError(message) self.__shared_data = normalize_inclusion_map(shared_data, self.root) return self.__shared_data @property def shared_scripts(self) -> dict[str, str]: if self.__shared_scripts is None: shared_scripts = self.target_config.get("shared-scripts", {}) if not isinstance(shared_scripts, dict): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.shared-scripts` must be a mapping" raise TypeError(message) for i, (source, relative_path) in enumerate(shared_scripts.items(), 1): if not source: message = ( f"Source #{i} in field `tool.hatch.build.targets.{self.plugin_name}.shared-scripts` " f"cannot be an empty string" ) raise ValueError(message) if not isinstance(relative_path, str): message = ( f"Path for source `{source}` in field " f"`tool.hatch.build.targets.{self.plugin_name}.shared-scripts` must be a string" ) raise TypeError(message) if not relative_path: message = ( f"Path for source `{source}` in field " f"`tool.hatch.build.targets.{self.plugin_name}.shared-scripts` cannot be an empty string" ) raise ValueError(message) self.__shared_scripts = normalize_inclusion_map(shared_scripts, self.root) return self.__shared_scripts @property def extra_metadata(self) -> dict[str, str]: if self.__extra_metadata is None: extra_metadata = self.target_config.get("extra-metadata", {}) if not isinstance(extra_metadata, dict): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.extra-metadata` must be a mapping" raise TypeError(message) for i, (source, relative_path) in enumerate(extra_metadata.items(), 1): if not source: message = ( f"Source #{i} in field `tool.hatch.build.targets.{self.plugin_name}.extra-metadata` " f"cannot be an empty string" ) raise ValueError(message) if not isinstance(relative_path, str): message = ( f"Path for source `{source}` in field " f"`tool.hatch.build.targets.{self.plugin_name}.extra-metadata` must be a string" ) raise TypeError(message) if not relative_path: message = ( f"Path for source `{source}` in field " f"`tool.hatch.build.targets.{self.plugin_name}.extra-metadata` cannot be an empty string" ) raise ValueError(message) self.__extra_metadata = normalize_inclusion_map(extra_metadata, self.root) return self.__extra_metadata @property def sbom_files(self) -> list[str]: """ https://peps.python.org/pep-0770/ """ sbom_files = self.target_config.get("sbom-files", []) if not isinstance(sbom_files, list): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.sbom-files` must be an array" raise TypeError(message) for i, sbom_file in enumerate(sbom_files, 1): if not isinstance(sbom_file, str): message = ( f"SBOM file #{i} in field `tool.hatch.build.targets.{self.plugin_name}.sbom-files` must be a string" ) raise TypeError(message) return sbom_files @property def strict_naming(self) -> bool: if self.__strict_naming is None: if "strict-naming" in self.target_config: strict_naming = self.target_config["strict-naming"] if not isinstance(strict_naming, bool): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.strict-naming` must be a boolean" raise TypeError(message) else: strict_naming = self.build_config.get("strict-naming", True) if not isinstance(strict_naming, bool): message = "Field `tool.hatch.build.strict-naming` must be a boolean" raise TypeError(message) self.__strict_naming = strict_naming return self.__strict_naming @property def macos_max_compat(self) -> bool: if self.__macos_max_compat is None: macos_max_compat = self.target_config.get("macos-max-compat", False) if not isinstance(macos_max_compat, bool): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.macos-max-compat` must be a boolean" raise TypeError(message) self.__macos_max_compat = macos_max_compat return self.__macos_max_compat @cached_property def bypass_selection(self) -> bool: bypass_selection = self.target_config.get("bypass-selection", False) if not isinstance(bypass_selection, bool): message = f"Field `tool.hatch.build.targets.{self.plugin_name}.bypass-selection` must be a boolean" raise TypeError(message) return bypass_selection if sys.platform in {"darwin", "win32"}: @staticmethod def get_raw_fs_path_name(directory: str, name: str) -> str: normalized = name.casefold() entries = os.listdir(directory) for entry in entries: if entry.casefold() == normalized: return entry return name # no cov else: @staticmethod def get_raw_fs_path_name(directory: str, name: str) -> str: # noqa: ARG004 return name class WheelBuilder(BuilderInterface): """ Build a binary distribution (.whl file) """ PLUGIN_NAME = "wheel" def get_version_api(self) -> dict[str, Callable]: return {"standard": self.build_standard, "editable": self.build_editable} def get_default_versions(self) -> list[str]: # noqa: PLR6301 return ["standard"] def clean( # noqa: PLR6301 self, directory: str, versions: list[str], # noqa: ARG002 ) -> None: for filename in os.listdir(directory): if filename.endswith(".whl"): os.remove(os.path.join(directory, filename)) def build_standard(self, directory: str, **build_data: Any) -> str: if "tag" not in build_data: if build_data["infer_tag"]: build_data["tag"] = self.get_best_matching_tag() else: build_data["tag"] = self.get_default_tag() with ( WheelArchive(self.artifact_project_id, reproducible=self.config.reproducible) as archive, RecordFile() as records, ): for included_file in self.recurse_included_files(): record = archive.add_file(included_file) records.write(record) self.write_data(archive, records, build_data, build_data["dependencies"]) records.write((f"{archive.metadata_directory}/RECORD", "", "")) archive.write_metadata("RECORD", records.construct()) target = os.path.join(directory, f"{self.artifact_project_id}-{build_data['tag']}.whl") replace_file(archive.path, target) normalize_artifact_permissions(target) return target def build_editable(self, directory: str, **build_data: Any) -> str: if self.config.dev_mode_dirs: return self.build_editable_explicit(directory, **build_data) return self.build_editable_detection(directory, **build_data) def build_editable_detection(self, directory: str, **build_data: Any) -> str: from editables import EditableProject build_data["tag"] = self.get_default_tag() with ( WheelArchive(self.artifact_project_id, reproducible=self.config.reproducible) as archive, RecordFile() as records, ): exposed_packages = {} for included_file in self.recurse_selected_project_files(): if not included_file.path.endswith(".py"): continue 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] try: exposed_packages[distribution_module] = os.path.join( self.root, f"{relative_path[: relative_path.index(distribution_path)]}{distribution_module}", ) except ValueError: message = ( "Dev mode installations are unsupported when any path rewrite in the `sources` option " "changes a prefix rather than removes it, see: " "https://github.com/pfmoore/editables/issues/20" ) raise ValueError(message) from None editable_project = EditableProject(self.metadata.core.name, self.root) if self.config.dev_mode_exact: for module, relative_path in exposed_packages.items(): editable_project.map(module, relative_path) else: for relative_path in exposed_packages.values(): editable_project.add_to_path(os.path.dirname(relative_path)) for raw_filename, content in sorted(editable_project.files()): filename = raw_filename if filename.endswith(".pth") and not filename.startswith("_"): filename = f"_{filename}" record = archive.write_file(filename, content) records.write(record) for included_file in self.recurse_forced_files(self.get_forced_inclusion_map(build_data)): record = archive.add_file(included_file) records.write(record) extra_dependencies = list(build_data["dependencies"]) for raw_dependency in editable_project.dependencies(): dependency = raw_dependency if dependency == "editables": dependency = EDITABLES_REQUIREMENT else: # no cov pass extra_dependencies.append(dependency) self.write_data(archive, records, build_data, extra_dependencies) records.write((f"{archive.metadata_directory}/RECORD", "", "")) archive.write_metadata("RECORD", records.construct()) target = os.path.join(directory, f"{self.artifact_project_id}-{build_data['tag']}.whl") replace_file(archive.path, target) normalize_artifact_permissions(target) return target def build_editable_explicit(self, directory: str, **build_data: Any) -> str: build_data["tag"] = self.get_default_tag() with ( WheelArchive(self.artifact_project_id, reproducible=self.config.reproducible) as archive, RecordFile() as records, ): directories = sorted( os.path.normpath(os.path.join(self.root, relative_directory)) for relative_directory in self.config.dev_mode_dirs ) record = archive.write_file(f"_{self.metadata.core.name.replace('-', '_')}.pth", "\n".join(directories)) records.write(record) for included_file in self.recurse_forced_files(self.get_forced_inclusion_map(build_data)): record = archive.add_file(included_file) records.write(record) self.write_data(archive, records, build_data, build_data["dependencies"]) records.write((f"{archive.metadata_directory}/RECORD", "", "")) archive.write_metadata("RECORD", records.construct()) target = os.path.join(directory, f"{self.artifact_project_id}-{build_data['tag']}.whl") replace_file(archive.path, target) normalize_artifact_permissions(target) return target def write_data( self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any], extra_dependencies: Sequence[str] ) -> None: self.add_shared_data(archive, records, build_data) self.add_shared_scripts(archive, records, build_data) # Ensure metadata is written last, see https://peps.python.org/pep-0427/#recommended-archiver-features self.write_metadata(archive, records, build_data, extra_dependencies=extra_dependencies) def add_shared_data(self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any]) -> None: shared_data = dict(self.config.shared_data) shared_data.update(normalize_inclusion_map(build_data["shared_data"], self.root)) for shared_file in self.recurse_explicit_files(shared_data): record = archive.add_shared_file(shared_file) records.write(record) def add_shared_scripts(self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any]) -> None: import re from io import BytesIO # https://packaging.python.org/en/latest/specifications/binary-distribution-format/#recommended-installer-features shebang = re.compile(rb"^#!.*(?:pythonw?|pypyw?)[0-9.]*(.*)", flags=re.DOTALL) shared_scripts = dict(self.config.shared_scripts) shared_scripts.update(normalize_inclusion_map(build_data["shared_scripts"], self.root)) for shared_script in self.recurse_explicit_files(shared_scripts): with open(shared_script.path, "rb") as f: prefix = f.read(2) # A shebang is only honored on the first line starting at the very # first byte, so anything else is kept as is. if prefix != b"#!": record = archive.add_shared_script(shared_script) else: line = prefix + f.readline(MAX_SHEBANG_LENGTH - len(prefix)) if len(line) == MAX_SHEBANG_LENGTH and not line.endswith(b"\n"): # The line is too long to be a functional shebang. record = archive.add_shared_script(shared_script) elif (match := shebang.match(line)) is None: record = archive.add_shared_script(shared_script) else: content = BytesIO() content.write(b"#!python") if remaining := match.group(1): content.write(remaining) content.write(f.read()) record = archive.write_shared_script(shared_script, content.getvalue()) records.write(record) def add_sboms(self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any]) -> None: sbom_files = self.config.sbom_files sbom_files.extend(build_data["sbom_files"]) if not sbom_files: return for sbom_file in sbom_files: sbom_path = os.path.join(self.root, sbom_file) if not os.path.isfile(sbom_path): message = f"SBOM file not found: {sbom_file}" raise FileNotFoundError(message) sbom_map = {os.path.join(self.root, sbom_file): os.path.basename(sbom_file) for sbom_file in sbom_files} for included_file in self.recurse_explicit_files(sbom_map): record = archive.add_sbom_file(included_file) records.write(record) def write_metadata( self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any], extra_dependencies: Sequence[str] = (), ) -> None: # <<< IMPORTANT >>> # Ensure calls are ordered by the number of path components followed by the name of the components # 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) # licenses/ self.add_licenses(archive, records) # sboms/ self.add_sboms(archive, records, build_data) # extra_metadata/ - write last self.add_extra_metadata(archive, records, build_data) @staticmethod def write_archive_metadata(archive: WheelArchive, records: RecordFile, build_data: dict[str, Any]) -> None: from packaging.tags import parse_tag metadata = f"""\ Wheel-Version: 1.0 Generator: hatchling {__version__} Root-Is-Purelib: {"true" if build_data["pure_python"] else "false"} """ for tag in sorted(map(str, parse_tag(build_data["tag"]))): metadata += f"Tag: {tag}\n" record = archive.write_metadata("WHEEL", metadata) records.write(record) def write_entry_points_file(self, archive: WheelArchive, records: RecordFile) -> None: entry_points_file = self.construct_entry_points_file() if entry_points_file: record = archive.write_metadata("entry_points.txt", entry_points_file) records.write(record) def write_project_metadata( self, archive: WheelArchive, records: RecordFile, extra_dependencies: Sequence[str] = () ) -> None: record = archive.write_metadata( "METADATA", self.config.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies) ) records.write(record) def add_licenses(self, archive: WheelArchive, records: RecordFile) -> None: 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(f"licenses/{relative_path}", f.read()) records.write(record) def add_extra_metadata(self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any]) -> None: extra_metadata = dict(self.config.extra_metadata) extra_metadata.update(normalize_inclusion_map(build_data["extra_metadata"], self.root)) for extra_metadata_file in self.recurse_explicit_files(extra_metadata): record = archive.add_extra_metadata_file(extra_metadata_file) records.write(record) def construct_entry_points_file(self) -> str: 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 += f"{name} = {object_ref}\n" if core_metadata.gui_scripts: metadata_file += "\n[gui_scripts]\n" for name, object_ref in core_metadata.gui_scripts.items(): metadata_file += f"{name} = {object_ref}\n" if core_metadata.entry_points: for group, entry_points in core_metadata.entry_points.items(): metadata_file += f"\n[{group}]\n" for name, object_ref in entry_points.items(): metadata_file += f"{name} = {object_ref}\n" return metadata_file.lstrip() def get_default_tag(self) -> str: known_major_versions = list(get_known_python_major_versions()) max_version_part = 100 supported_python_versions = [] for major_version in known_major_versions: for minor_version in range(max_version_part): # Try an artificially high patch version to account for common cases like `>=3.11.4` or `>=3.10,<3.11` if self.metadata.core.python_constraint.contains(f"{major_version}.{minor_version}.{max_version_part}"): supported_python_versions.append(f"py{major_version}") break # Slow path, try all permutations to account for narrow support ranges like `<=3.11.4` if not supported_python_versions: for major_version in known_major_versions: for minor_version in range(max_version_part): for patch_version in range(max_version_part): if self.metadata.core.python_constraint.contains( f"{major_version}.{minor_version}.{patch_version}" ): supported_python_versions.append(f"py{major_version}") break else: continue break return f"{'.'.join(supported_python_versions)}-none-any" def get_best_matching_tag(self) -> str: import sys from packaging.tags import sys_tags # Linux tag is after many/musl; packaging tools are required to skip # many/musl, see https://github.com/pypa/packaging/issues/160 tag = next(iter(t for t in sys_tags() if "manylinux" not in t.platform and "musllinux" not in t.platform)) tag_parts = [tag.interpreter, tag.abi, tag.platform] if sys.platform == "darwin": from hatchling.builders.macos import process_macos_plat_tag tag_parts[2] = process_macos_plat_tag(tag_parts[2], compat=self.config.macos_max_compat) return "-".join(tag_parts) def get_default_build_data(self) -> dict[str, Any]: # noqa: PLR6301 return { "infer_tag": False, "pure_python": True, "dependencies": [], "force_include_editable": {}, "extra_metadata": {}, "shared_data": {}, "shared_scripts": {}, "sbom_files": [], } def get_forced_inclusion_map(self, build_data: dict[str, Any]) -> dict[str, str]: if not build_data["force_include_editable"]: return self.config.get_force_include() return normalize_inclusion_map(build_data["force_include_editable"], self.root) @property def artifact_project_id(self) -> str: return ( self.project_id if self.config.strict_naming else f"{self.normalize_file_name_component(self.metadata.core.raw_name)}-{self.metadata.version}" ) @classmethod def get_config_class(cls) -> type[WheelBuilderConfig]: return WheelBuilderConfig