From a13bf861218b4f77ed03b003fb66d8c60aa9e969 Mon Sep 17 00:00:00 2001 From: Sandu Turcan Date: Fri, 31 May 2024 19:10:32 -0400 Subject: [PATCH] Hook for making local requirements publishable when creating a wheel or sdist --- .../hatchling/builders/hooks/plugin/hooks.py | 3 +- .../builders/hooks/plugin/interface.py | 10 ++++ .../builders/hooks/publishable_locals.py | 34 +++++++++++++ .../hatchling/builders/plugin/interface.py | 20 +++++++- backend/src/hatchling/builders/sdist.py | 4 +- backend/src/hatchling/builders/wheel.py | 18 ++++--- backend/src/hatchling/metadata/spec.py | 48 ++++++++++++++----- 7 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 backend/src/hatchling/builders/hooks/publishable_locals.py diff --git a/backend/src/hatchling/builders/hooks/plugin/hooks.py b/backend/src/hatchling/builders/hooks/plugin/hooks.py index 53fb28897..4519c42ca 100644 --- a/backend/src/hatchling/builders/hooks/plugin/hooks.py +++ b/backend/src/hatchling/builders/hooks/plugin/hooks.py @@ -3,6 +3,7 @@ import typing from hatchling.builders.hooks.custom import CustomBuildHook +from hatchling.builders.hooks.publishable_locals import PublishableLocalsHook from hatchling.builders.hooks.version import VersionBuildHook from hatchling.plugin import hookimpl @@ -12,4 +13,4 @@ @hookimpl def hatch_register_build_hook() -> list[type[BuildHookInterface]]: - return [CustomBuildHook, VersionBuildHook] # type: ignore + return [CustomBuildHook, VersionBuildHook, PublishableLocalsHook] # type: ignore diff --git a/backend/src/hatchling/builders/hooks/plugin/interface.py b/backend/src/hatchling/builders/hooks/plugin/interface.py index f13cd4379..20d3639b9 100644 --- a/backend/src/hatchling/builders/hooks/plugin/interface.py +++ b/backend/src/hatchling/builders/hooks/plugin/interface.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any, Generic, cast +from packaging.requirements import Requirement + from hatchling.builders.config import BuilderConfigBound if TYPE_CHECKING: @@ -112,6 +114,14 @@ def target_name(self) -> str: """ return self.__target_name + def publishable_local(self, req: Requirement) -> Requirement: + """ + Converts a local requirement to publishable. + @param req: requirement + @return: publishable requirement or original + """ + return req + def dependencies(self) -> list[str]: # noqa: PLR6301 """ A list of extra [dependencies](../../config/dependency.md) that must be installed diff --git a/backend/src/hatchling/builders/hooks/publishable_locals.py b/backend/src/hatchling/builders/hooks/publishable_locals.py new file mode 100644 index 000000000..476233b9d --- /dev/null +++ b/backend/src/hatchling/builders/hooks/publishable_locals.py @@ -0,0 +1,34 @@ +import os +from typing import Optional + +from packaging.requirements import Requirement + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from hatchling.metadata.core import ProjectMetadata + + +class PublishableLocalsHook(BuildHookInterface): + PLUGIN_NAME = 'publishable_locals' + + def local_dependency_path(self, req: Requirement) -> Optional[str]: + if not req.url: + return None + req_url = req.url + if req_url.startswith("file://"): + req_url = req.url[len("file://"):] + elif ":" in req_url: + return None + if req_url.startswith("/"): + p_path = req_url + else: + p_path = os.path.normpath(os.path.join(self.root, req_url)) + return p_path if os.path.isdir(p_path) else None + + def publishable_local(self, req: Requirement) -> Requirement: + p_path = self.local_dependency_path(req) + if p_path: + p_meta = ProjectMetadata(p_path, plugin_manager=self.metadata.plugin_manager) + p_ver = p_meta.version + return Requirement(f"{req.name}=={p_ver}") + else: + return req diff --git a/backend/src/hatchling/builders/plugin/interface.py b/backend/src/hatchling/builders/plugin/interface.py index 95e1c15f7..0a834c5a9 100644 --- a/backend/src/hatchling/builders/plugin/interface.py +++ b/backend/src/hatchling/builders/plugin/interface.py @@ -3,16 +3,18 @@ import os import re from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Callable, Generator, Generic, Iterable, cast +from typing import TYPE_CHECKING, Any, Callable, Generator, Generic, Iterable, cast, Optional + +from packaging.requirements import Requirement from hatchling.builders.config import BuilderConfig, BuilderConfigBound, env_var_enabled from hatchling.builders.constants import EXCLUDED_DIRECTORIES, EXCLUDED_FILES, BuildEnvVars +from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.builders.utils import get_relative_path, safe_walk from hatchling.plugin.manager import PluginManagerBound if TYPE_CHECKING: from hatchling.bridge.app import Application - from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.metadata.core import ProjectMetadata @@ -142,6 +144,16 @@ def build( # Allow inspection of configured build hooks and the order in which they run build_data['build_hooks'] = tuple(configured_build_hooks) + pub_local_hook = None + for build_hook in build_hooks: + if type(build_hook).publishable_local is not BuildHookInterface.publishable_local: + if pub_local_hook is None: + pub_local_hook = build_hook + else: + raise ValueError(f'Hooks {pub_local_hook.PLUGIN_NAME} and {build_hook.PLUGIN_NAME} provide conflicting `publishable_local`') + if pub_local_hook: + build_data['publishable_local_function'] = pub_local_hook.publishable_local + # Execute all `initialize` build hooks for build_hook in build_hooks: build_hook.initialize(version, build_data) @@ -439,3 +451,7 @@ def normalize_file_name_component(file_name: str) -> str: https://peps.python.org/pep-0427/#escaping-and-unicode """ return re.sub(r'[^\w\d.]+', '_', file_name, flags=re.UNICODE) + + @staticmethod + def publishable_local_function(build_data: dict[str, Any]) -> Optional[Callable[[Requirement], Requirement]]: + return build_data.get('publishable_local_function', None) diff --git a/backend/src/hatchling/builders/sdist.py b/backend/src/hatchling/builders/sdist.py index bd29ad649..c04114119 100644 --- a/backend/src/hatchling/builders/sdist.py +++ b/backend/src/hatchling/builders/sdist.py @@ -189,7 +189,9 @@ def build_standard(self, directory: str, **build_data: Any) -> str: archive.addfile(tar_info) archive.create_file( - self.config.core_metadata_constructor(self.metadata, extra_dependencies=build_data['dependencies']), + self.config.core_metadata_constructor(self.metadata, + extra_dependencies=build_data['dependencies'], + normalize_requirement=self.publishable_local_function(build_data)), 'PKG-INFO', ) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index 79a4940b0..c1f16124f 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -475,7 +475,7 @@ def build_standard(self, directory: str, **build_data: Any) -> str: record = archive.add_file(included_file) records.write(record) - self.write_data(archive, records, build_data, build_data['dependencies']) + self.write_data(archive, records, build_data, build_data['dependencies'], enable_publishable_locals=True) records.write((f'{archive.metadata_directory}/RECORD', '', '')) archive.write_metadata('RECORD', records.construct()) @@ -602,13 +602,14 @@ def build_editable_explicit(self, directory: str, **build_data: Any) -> str: return target def write_data( - self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any], extra_dependencies: Sequence[str] + self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any], extra_dependencies: Sequence[str], enable_publishable_locals=False ) -> 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) + self.write_metadata(archive, records, build_data, extra_dependencies=extra_dependencies, + enable_publishable_locals=enable_publishable_locals) def add_shared_data(self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any]) -> None: shared_data = dict(self.config.shared_data) @@ -656,12 +657,14 @@ def write_metadata( records: RecordFile, build_data: dict[str, Any], extra_dependencies: Sequence[str] = (), + enable_publishable_locals=False, ) -> 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) + self.write_project_metadata(archive, records, build_data, extra_dependencies=extra_dependencies, + enable_publishable_locals=enable_publishable_locals) # WHEEL self.write_archive_metadata(archive, records, build_data) @@ -698,10 +701,13 @@ def write_entry_points_file(self, archive: WheelArchive, records: RecordFile) -> records.write(record) def write_project_metadata( - self, archive: WheelArchive, records: RecordFile, extra_dependencies: Sequence[str] = () + self, archive: WheelArchive, records: RecordFile, build_data: dict[str, Any], extra_dependencies: Sequence[str] = (), enable_publishable_locals=False ) -> None: + normalize_requirement_f = self.publishable_local_function(build_data) if enable_publishable_locals else None record = archive.write_metadata( - 'METADATA', self.config.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies) + 'METADATA', self.config.core_metadata_constructor(self.metadata, + extra_dependencies=extra_dependencies, + normalize_requirement=normalize_requirement_f) ) records.write(record) diff --git a/backend/src/hatchling/metadata/spec.py b/backend/src/hatchling/metadata/spec.py index 501a88260..6c087fbb2 100644 --- a/backend/src/hatchling/metadata/spec.py +++ b/backend/src/hatchling/metadata/spec.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Optional + +from packaging.requirements import Requirement if TYPE_CHECKING: from hatchling.metadata.core import ProjectMetadata @@ -196,7 +198,8 @@ def project_metadata_from_core_metadata(core_metadata: str) -> dict[str, Any]: return metadata -def construct_metadata_file_1_2(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: +def construct_metadata_file_1_2(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None, + normalize_requirement: Optional[Callable[[Requirement], Requirement]] = None) -> str: """ https://peps.python.org/pep-0345/ """ @@ -244,8 +247,12 @@ def construct_metadata_file_1_2(metadata: ProjectMetadata, extra_dependencies: t if metadata.core.requires_python: metadata_file += f'Requires-Python: {metadata.core.requires_python}\n' - if metadata.core.dependencies: - for dependency in metadata.core.dependencies: + if metadata.core.dependencies_complex: + for dependency, req in metadata.core.dependencies_complex.items(): + if normalize_requirement: + new_req = normalize_requirement(req) + if new_req is not req: + dependency = str(new_req) metadata_file += f'Requires-Dist: {dependency}\n' if extra_dependencies: @@ -255,7 +262,8 @@ def construct_metadata_file_1_2(metadata: ProjectMetadata, extra_dependencies: t return metadata_file -def construct_metadata_file_2_1(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: +def construct_metadata_file_2_1(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None, + normalize_requirement: Optional[Callable[[Requirement], Requirement]] = None) -> str: """ https://peps.python.org/pep-0566/ """ @@ -310,8 +318,12 @@ def construct_metadata_file_2_1(metadata: ProjectMetadata, extra_dependencies: t if metadata.core.requires_python: metadata_file += f'Requires-Python: {metadata.core.requires_python}\n' - if metadata.core.dependencies: - for dependency in metadata.core.dependencies: + if metadata.core.dependencies_complex: + for dependency, req in metadata.core.dependencies_complex.items(): + if normalize_requirement: + new_req = normalize_requirement(req) + if new_req is not req: + dependency = str(new_req) metadata_file += f'Requires-Dist: {dependency}\n' if extra_dependencies: @@ -337,7 +349,8 @@ def construct_metadata_file_2_1(metadata: ProjectMetadata, extra_dependencies: t return metadata_file -def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: +def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None, + normalize_requirement: Optional[Callable[[Requirement], Requirement]] = None) -> str: """ https://peps.python.org/pep-0643/ """ @@ -401,8 +414,12 @@ def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: t if metadata.core.requires_python: metadata_file += f'Requires-Python: {metadata.core.requires_python}\n' - if metadata.core.dependencies: - for dependency in metadata.core.dependencies: + if metadata.core.dependencies_complex: + for dependency, req in metadata.core.dependencies_complex.items(): + if normalize_requirement: + new_req = normalize_requirement(req) + if new_req is not req: + dependency = str(new_req) metadata_file += f'Requires-Dist: {dependency}\n' if extra_dependencies: @@ -428,7 +445,8 @@ def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: t return metadata_file -def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None) -> str: +def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: tuple[str] | None = None, + normalize_requirement: Optional[Callable[[Requirement], Requirement]] = None) -> str: """ https://peps.python.org/pep-0639/ """ @@ -492,8 +510,12 @@ def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: t if metadata.core.requires_python: metadata_file += f'Requires-Python: {metadata.core.requires_python}\n' - if metadata.core.dependencies: - for dependency in metadata.core.dependencies: + if metadata.core.dependencies_complex: + for dependency, req in metadata.core.dependencies_complex.items(): + if normalize_requirement: + new_req = normalize_requirement(req) + if new_req is not req: + dependency = str(new_req) metadata_file += f'Requires-Dist: {dependency}\n' if extra_dependencies: