diff --git a/backend/src/hatchling/build.py b/backend/src/hatchling/build.py index 5a1da4f89..3813f5ec5 100644 --- a/backend/src/hatchling/build.py +++ b/backend/src/hatchling/build.py @@ -3,6 +3,8 @@ import os from typing import Any +from hatchling.builders.variant_constants import VARIANT_DIST_INFO_FILENAME + __all__ = [ 'build_editable', 'build_sdist', @@ -46,15 +48,35 @@ def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) def build_wheel( wheel_directory: str, - config_settings: dict[str, Any] | None = None, # noqa: ARG001 + config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None, # noqa: ARG001 ) -> str: """ https://peps.python.org/pep-0517/#build-wheel """ from hatchling.builders.wheel import WheelBuilder - - builder = WheelBuilder(os.getcwd()) + from hatchling.metadata.core import ProjectMetadata + from hatchling.plugin.manager import PluginManager + + root_dir = os.getcwd() + plugin_manager = PluginManager() + metadata = ProjectMetadata(root_dir, plugin_manager) + + if config_settings and 'variant-property' in config_settings: + variant_props = config_settings['variant-property'] + else: + variant_props = None + if config_settings and 'variant-label' in config_settings: + variant_label = config_settings['variant-label'] + else: + variant_label = None + builder = WheelBuilder( + root_dir, + plugin_manager=plugin_manager, + metadata=metadata, + variant_props=variant_props, + variant_label=variant_label, + ) return os.path.basename(next(builder.build(directory=wheel_directory, versions=['standard']))) @@ -116,6 +138,10 @@ def prepare_metadata_for_build_wheel( with open(os.path.join(directory, 'METADATA'), 'w', encoding='utf-8') as f: f.write(builder.config.core_metadata_constructor(builder.metadata)) + if builder.metadata.variant_label is not None: + with open(os.path.join(directory, VARIANT_DIST_INFO_FILENAME), 'w', encoding='utf-8') as f: + f.write(builder.config.variants_json_constructor(builder.metadata.variant_config)) + return os.path.basename(directory) def prepare_metadata_for_build_editable( diff --git a/backend/src/hatchling/builders/config.py b/backend/src/hatchling/builders/config.py index ff0dc58fa..9ffc079ac 100644 --- a/backend/src/hatchling/builders/config.py +++ b/backend/src/hatchling/builders/config.py @@ -141,7 +141,7 @@ def include_spec(self) -> pathspec.GitIgnoreSpec | None: # Matching only at the root requires a forward slash, back slashes do not work. As such, # normalize to forward slashes for consistency. - all_include_patterns.extend(f"/{relative_path.replace(os.sep, '/')}/" for relative_path in self.packages) + all_include_patterns.extend(f'/{relative_path.replace(os.sep, "/")}/' for relative_path in self.packages) if all_include_patterns: return pathspec.GitIgnoreSpec.from_lines(all_include_patterns) diff --git a/backend/src/hatchling/builders/plugin/interface.py b/backend/src/hatchling/builders/plugin/interface.py index 95e1c15f7..ff9cddacf 100644 --- a/backend/src/hatchling/builders/plugin/interface.py +++ b/backend/src/hatchling/builders/plugin/interface.py @@ -60,6 +60,8 @@ def __init__( config: dict[str, Any] | None = None, metadata: ProjectMetadata | None = None, app: Application | None = None, + variant_props: list[str] | None = None, # noqa: ARG002 + variant_label: str | None = None, # noqa: ARG002 ) -> None: self.__root = root self.__plugin_manager = cast(PluginManagerBound, plugin_manager) diff --git a/backend/src/hatchling/builders/sdist.py b/backend/src/hatchling/builders/sdist.py index cca5a46f8..dc51c860d 100644 --- a/backend/src/hatchling/builders/sdist.py +++ b/backend/src/hatchling/builders/sdist.py @@ -230,15 +230,15 @@ def construct_setup_py_file(self, packages: list[str], extra_dependencies: tuple authors_data = self.metadata.core.authors_data if authors_data['name']: - contents += f" author={', '.join(authors_data['name'])!r},\n" + contents += f' author={", ".join(authors_data["name"])!r},\n' if authors_data['email']: - contents += f" author_email={', '.join(authors_data['email'])!r},\n" + contents += f' author_email={", ".join(authors_data["email"])!r},\n' maintainers_data = self.metadata.core.maintainers_data if maintainers_data['name']: - contents += f" maintainer={', '.join(maintainers_data['name'])!r},\n" + contents += f' maintainer={", ".join(maintainers_data["name"])!r},\n' if maintainers_data['email']: - contents += f" maintainer_email={', '.join(maintainers_data['email'])!r},\n" + contents += f' maintainer_email={", ".join(maintainers_data["email"])!r},\n' if self.metadata.core.classifiers: contents += ' classifiers=[\n' @@ -313,9 +313,9 @@ def construct_setup_py_file(self, packages: list[str], extra_dependencies: tuple for package in packages: if package.startswith(f'src{os.sep}'): src_layout = True - contents += f" {package.replace(os.sep, '.')[4:]!r},\n" + contents += f' {package.replace(os.sep, ".")[4:]!r},\n' else: - contents += f" {package.replace(os.sep, '.')!r},\n" + contents += f' {package.replace(os.sep, ".")!r},\n' contents += ' ],\n' diff --git a/backend/src/hatchling/builders/variant_constants.py b/backend/src/hatchling/builders/variant_constants.py new file mode 100644 index 000000000..50da892ad --- /dev/null +++ b/backend/src/hatchling/builders/variant_constants.py @@ -0,0 +1,135 @@ +# This file is copied from variantlib/variantlib/constants.py +# Do not edit this file directly, instead edit variantlib/variantlib/constants.py +from __future__ import annotations + +import re +from typing import Literal +from typing import TypedDict + +VARIANT_LABEL_LENGTH = 16 +NULL_VARIANT_LABEL = 'null' +CONFIG_FILENAME = 'variants.toml' +VARIANT_DIST_INFO_FILENAME = 'variant.json' + +# Common variant info keys (used in pyproject.toml and variants.json) +VARIANT_INFO_DEFAULT_PRIO_KEY: Literal['default-priorities'] = 'default-priorities' +VARIANT_INFO_FEATURE_KEY: Literal['feature'] = 'feature' +VARIANT_INFO_NAMESPACE_KEY: Literal['namespace'] = 'namespace' +VARIANT_INFO_PROPERTY_KEY: Literal['property'] = 'property' +VARIANT_INFO_PROVIDER_DATA_KEY: Literal['providers'] = 'providers' +VARIANT_INFO_PROVIDER_ENABLE_IF_KEY: Literal['enable-if'] = 'enable-if' +VARIANT_INFO_PROVIDER_OPTIONAL_KEY: Literal['optional'] = 'optional' +VARIANT_INFO_PROVIDER_PLUGIN_API_KEY: Literal['plugin-api'] = 'plugin-api' +VARIANT_INFO_PROVIDER_PLUGIN_USE_KEY: Literal['plugin-use'] = 'plugin-use' +VARIANT_INFO_PROVIDER_REQUIRES_KEY: Literal['requires'] = 'requires' + +PYPROJECT_TOML_TOP_KEY = 'variant' + +VARIANTS_JSON_SCHEMA_KEY: Literal['$schema'] = '$schema' +VARIANTS_JSON_SCHEMA_URL = 'https://variants-schema.wheelnext.dev/v0.0.2.json' +VARIANTS_JSON_VARIANT_DATA_KEY: Literal['variants'] = 'variants' + +VALIDATION_VARIANT_LABEL_REGEX = re.compile(rf'[0-9a-z._]{{1,{VARIANT_LABEL_LENGTH}}}') + +VALIDATION_NAMESPACE_REGEX = re.compile(r'[a-z0-9_]+') +VALIDATION_FEATURE_NAME_REGEX = re.compile(r'[a-z0-9_]+') +VALIDATION_VALUE_REGEX = re.compile(r'[a-z0-9_.]+') + +VALIDATION_FEATURE_REGEX = re.compile( + rf""" + (?P{VALIDATION_NAMESPACE_REGEX.pattern}) + \s* :: \s* + (?P{VALIDATION_FEATURE_NAME_REGEX.pattern}) +""", + re.VERBOSE, +) + +VALIDATION_PROPERTY_REGEX = re.compile( + rf""" + (?P{VALIDATION_NAMESPACE_REGEX.pattern}) + \s* :: \s* + (?P{VALIDATION_FEATURE_NAME_REGEX.pattern}) + \s* :: \s* + (?P{VALIDATION_VALUE_REGEX.pattern}) +""", + re.VERBOSE, +) + +VALIDATION_PROVIDER_ENABLE_IF_REGEX = re.compile(r'[\S ]+') +VALIDATION_PROVIDER_PLUGIN_API_REGEX = re.compile( + r""" + (?P [\w.]+) + (?: \s* : \s* + (?P [\w.]+) + )? + """, + re.VERBOSE, +) +VALIDATION_PROVIDER_REQUIRES_REGEX = re.compile(r'[\S ]+') + + +# VALIDATION_PYTHON_PACKAGE_NAME_REGEX = re.compile(r"[^\s-]+?") +# Per PEP 508: https://peps.python.org/pep-0508/#names +VALIDATION_PYTHON_PACKAGE_NAME_REGEX = re.compile(r'[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]', re.IGNORECASE) +VALIDATION_WHEEL_NAME_REGEX = re.compile( + r'(?P ' # group (without variant) + r' (?P ' # "namever" group contains - + r' (?P[^\s-]+?) ' # + r' - (?P[^\s-]*?) ' # "-" + r' ) ' # close "namever" group + r' (?: - (?P\d[^-]*?) )? ' # optional "-" + r' - (?P[^\s-]+?) ' # "-" tag + r' - (?P[^\s-]+?) ' # "-" tag + r' - (?P[^\s-]+?) ' # "-" tag + r') ' # end of group + r'(?: - (?P ' # optional + rf' {VALIDATION_VARIANT_LABEL_REGEX.pattern}' + r' ) ' + r')? ' + r'\.whl ' # ".whl" suffix + r' ', + re.VERBOSE, +) + + +# ======================== Json TypedDict for the JSON format ======================== # + +# NOTE: Unfortunately, it is not possible as of today to use variables in the definition +# of TypedDict. Similarly also impossible to use the normal "class format" if a +# key uses the characted `-`. +# +# For all these reasons and easier future maintenance - these classes have been +# added to this file instead of a more "format definition" file. + + +class PriorityJsonDict(TypedDict, total=False): + namespace: list[str] + feature: dict[str, list[str]] + property: dict[str, dict[str, list[str]]] + + +ProviderPluginJsonDict = TypedDict( + 'ProviderPluginJsonDict', + { + 'plugin-api': str, + 'requires': list[str], + 'enable-if': str, + 'optional': bool, + 'plugin-use': Literal['all', 'build', 'none'], + }, + total=False, +) + +VariantInfoJsonDict = dict[str, dict[str, list[str]]] + + +VariantsJsonDict = TypedDict( + 'VariantsJsonDict', + { + '$schema': str, + 'default-priorities': PriorityJsonDict, + 'providers': dict[str, ProviderPluginJsonDict], + 'variants': dict[str, VariantInfoJsonDict], + }, + total=False, +) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index e6bc55262..6e675f7bd 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -2,15 +2,22 @@ import csv import hashlib +import json import os import stat import sys import tempfile import zipfile +from collections import defaultdict from functools import cached_property from io import StringIO from typing import TYPE_CHECKING, Any, Callable, Iterable, NamedTuple, Sequence, Tuple, cast +if TYPE_CHECKING: + from hatchling.bridge.app import Application + from hatchling.metadata.core import ProjectMetadata + from hatchling.plugin.manager import PluginManagerBound + from hatchling.__about__ import __version__ from hatchling.builders.config import BuilderConfig from hatchling.builders.constants import EDITABLES_REQUIREMENT @@ -26,6 +33,24 @@ replace_file, set_zip_info_mode, ) +from hatchling.builders.variant_constants import ( + VALIDATION_PROPERTY_REGEX, + VARIANT_DIST_INFO_FILENAME, + VARIANT_INFO_DEFAULT_PRIO_KEY, + VARIANT_INFO_FEATURE_KEY, + VARIANT_INFO_NAMESPACE_KEY, + VARIANT_INFO_PROPERTY_KEY, + VARIANT_INFO_PROVIDER_DATA_KEY, + VARIANT_INFO_PROVIDER_ENABLE_IF_KEY, + VARIANT_INFO_PROVIDER_OPTIONAL_KEY, + VARIANT_INFO_PROVIDER_PLUGIN_API_KEY, + VARIANT_INFO_PROVIDER_REQUIRES_KEY, + VARIANTS_JSON_SCHEMA_KEY, + VARIANTS_JSON_SCHEMA_URL, + VARIANTS_JSON_VARIANT_DATA_KEY, + VARIANT_INFO_PROVIDER_PLUGIN_USE_KEY, +) +from hatchling.metadata.core import VariantConfig from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors if TYPE_CHECKING: @@ -188,6 +213,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.__core_metadata_constructor: Callable[..., str] | None = None + self.__variants_json_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 @@ -282,6 +308,80 @@ def core_metadata_constructor(self) -> Callable[..., str]: return self.__core_metadata_constructor + @property + def variants_json_constructor(self) -> Callable[..., str]: + if self.__variants_json_constructor is None: + + def constructor(variant_config: VariantConfig) -> str: + data = { + VARIANTS_JSON_SCHEMA_KEY: VARIANTS_JSON_SCHEMA_URL, + VARIANT_INFO_DEFAULT_PRIO_KEY: {}, + VARIANT_INFO_PROVIDER_DATA_KEY: {}, + VARIANTS_JSON_VARIANT_DATA_KEY: {}, + } + + # ==================== VARIANT_INFO_DEFAULT_PRIO_KEY ==================== # + + if ns_prio := variant_config.default_priorities['namespace']: + data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_NAMESPACE_KEY] = ns_prio + + if feat_prio := variant_config.default_priorities.get('feature'): + data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_FEATURE_KEY] = feat_prio + + if prop_prio := variant_config.default_priorities.get('property'): + data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_PROPERTY_KEY] = prop_prio + + if not data[VARIANT_INFO_DEFAULT_PRIO_KEY]: + # If no default priorities are set, remove the key + del data[VARIANT_INFO_DEFAULT_PRIO_KEY] + + # ==================== VARIANT_INFO_PROVIDER_DATA_KEY ==================== # + + variant_providers = defaultdict(dict) + for ns, provider_cfg in variant_config.providers.items(): + variant_providers[ns][VARIANT_INFO_PROVIDER_REQUIRES_KEY] = provider_cfg.requires + + if provider_cfg.enable_if is not None: + variant_providers[ns][VARIANT_INFO_PROVIDER_ENABLE_IF_KEY] = provider_cfg.enable_if + + if provider_cfg.optional: + variant_providers[ns][VARIANT_INFO_PROVIDER_OPTIONAL_KEY] = True + + if provider_cfg.plugin_api is not None: + variant_providers[ns][VARIANT_INFO_PROVIDER_PLUGIN_API_KEY] = provider_cfg.plugin_api + + if provider_cfg.plugin_use is not None: + variant_providers[ns][VARIANT_INFO_PROVIDER_PLUGIN_USE_KEY] = provider_cfg.plugin_use + + data[VARIANT_INFO_PROVIDER_DATA_KEY] = variant_providers + + # ==================== VARIANTS_JSON_VARIANT_DATA_KEY ==================== # + + variant_data = defaultdict(lambda: defaultdict(set)) + for vprop_str in variant_config.properties or []: + match = VALIDATION_PROPERTY_REGEX.match(vprop_str) + if not match: + raise ValueError(f"Invalid variant property '{vprop_str}' in variant `{variant_config.vlabel}`") + namespace = match.group('namespace') + feature = match.group('feature') + value = match.group('value') + variant_data[namespace][feature].add(value) + + data[VARIANTS_JSON_VARIANT_DATA_KEY][variant_config.vlabel] = variant_data + + def preprocess(data): + """Preprocess the data to ensure it is JSON serializable.""" + if isinstance(data, dict): + return {k: preprocess(v) for k, v in data.items()} + if isinstance(data, set): + return list(data) + return data + + return json.dumps(preprocess(data), indent=4, sort_keys=True, ensure_ascii=False) + + self.__variants_json_constructor = constructor + return self.__variants_json_constructor + @property def shared_data(self) -> dict[str, str]: if self.__shared_data is None: @@ -449,6 +549,34 @@ class WheelBuilder(BuilderInterface): PLUGIN_NAME = 'wheel' + def __init__( + self, + root: str, + plugin_manager: PluginManagerBound | None = None, + config: dict[str, Any] | None = None, + metadata: ProjectMetadata | None = None, + app: Application | None = None, + variant_props: list[str] | None = None, + variant_label: str | None = None, + ): + if metadata is not None: + if variant_props is not None and variant_label is not None: + metadata.variant_config = VariantConfig.from_dict( + data=metadata.variant_config_data, + vprops=variant_props, + variant_label=variant_label, + ) + metadata.variant_config.validate() + metadata.variant_label = metadata.variant_config.vlabel + + super().__init__( + root=root, + plugin_manager=plugin_manager, + config=config, + metadata=metadata, + app=app, + ) + def get_version_api(self) -> dict[str, Callable]: return {'standard': self.build_standard, 'editable': self.build_editable} @@ -483,7 +611,11 @@ def build_standard(self, directory: str, **build_data: Any) -> str: 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") + if self.metadata.variant_label is not None: + wheel_name = f'{self.artifact_project_id}-{build_data["tag"]}-{self.metadata.variant_label}.whl' + else: + wheel_name = f'{self.artifact_project_id}-{build_data["tag"]}.whl' + target = os.path.join(directory, wheel_name) replace_file(archive.path, target) normalize_artifact_permissions(target) @@ -572,7 +704,7 @@ def build_editable_detection(self, directory: str, **build_data: Any) -> str: 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") + target = os.path.join(directory, f'{self.artifact_project_id}-{build_data["tag"]}.whl') replace_file(archive.path, target) normalize_artifact_permissions(target) @@ -589,7 +721,7 @@ def build_editable_explicit(self, directory: str, **build_data: Any) -> str: for relative_directory in self.config.dev_mode_dirs ) - record = archive.write_file(f"_{self.metadata.core.name.replace('-', '_')}.pth", '\n'.join(directories)) + 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)): @@ -601,7 +733,7 @@ def build_editable_explicit(self, directory: str, **build_data: Any) -> str: 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") + target = os.path.join(directory, f'{self.artifact_project_id}-{build_data["tag"]}.whl') replace_file(archive.path, target) normalize_artifact_permissions(target) @@ -710,6 +842,12 @@ def write_project_metadata( 'METADATA', self.config.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies) ) records.write(record) + if self.metadata.variant_label is not None: + record = archive.write_metadata( + VARIANT_DIST_INFO_FILENAME, + self.config.variants_json_constructor(self.metadata.variant_config), + ) + records.write(record) def add_licenses(self, archive: WheelArchive, records: RecordFile) -> None: for relative_path in self.metadata.core.license_files: diff --git a/backend/src/hatchling/cli/build/__init__.py b/backend/src/hatchling/cli/build/__init__.py index 1e4f1d35d..cb6cc5b6b 100644 --- a/backend/src/hatchling/cli/build/__init__.py +++ b/backend/src/hatchling/cli/build/__init__.py @@ -15,6 +15,9 @@ def build_impl( clean_hooks_after: bool, clean_only: bool, show_dynamic_deps: bool, + variant_props: list[str] | None = None, + null_variant: bool = False, + variant_label: str | None, ) -> None: import os @@ -52,7 +55,7 @@ def build_impl( builders[target_name] = builder_class if unknown_targets: - app.abort(f"Unknown build targets: {', '.join(sorted(unknown_targets))}") + app.abort(f'Unknown build targets: {", ".join(sorted(unknown_targets))}') # We guarantee that builds occur within the project directory root = os.getcwd() @@ -72,7 +75,15 @@ def build_impl( if not (clean_only or show_dynamic_deps) and len(target_data) > 1: app.display_mini_header(target_name) - builder = builder_class(root, plugin_manager=plugin_manager, metadata=metadata, app=app.get_safe_application()) + builder = builder_class( + root, + plugin_manager=plugin_manager, + metadata=metadata, + app=app.get_safe_application(), + variant_props=variant_props if not null_variant else [], + variant_label=variant_label, + ) + if show_dynamic_deps: for dependency in builder.config.dynamic_dependencies: dynamic_dependencies[dependency] = None @@ -116,4 +127,28 @@ def build_command(subparsers: argparse._SubParsersAction, defaults: Any) -> None parser.add_argument('--clean-only', dest='clean_only', action='store_true') parser.add_argument('--show-dynamic-deps', dest='show_dynamic_deps', action='store_true') parser.add_argument('--app', dest='called_by_app', action='store_true', help=argparse.SUPPRESS) + + group = parser.add_mutually_exclusive_group(required=False) + group.add_argument( + '-p', + '--variant-property', + dest='variant_props', + type=str, + action='extend', + nargs='+', + help=('Variant Properties to add to the Wheel Variant, can be repeated as many times as needed'), + default=None, + ) + group.add_argument( + '--null-variant', + dest='null_variant', + action='store_true', + help='Make the variant a `null variant` - no variant property.', + ) + parser.add_argument( + '--variant-label', + dest='variant_label', + help='Use a custom variant label (the default is variant hash)', + ) + parser.set_defaults(func=build_impl) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 88ed55775..3b53009c5 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -1,11 +1,14 @@ from __future__ import annotations +import hashlib import os import sys from contextlib import suppress from copy import deepcopy -from typing import TYPE_CHECKING, Any, Generic, cast +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Generic, cast, Literal +from hatchling.builders.variant_constants import VARIANT_LABEL_LENGTH from hatchling.metadata.utils import ( format_dependency, is_valid_project_name, @@ -36,6 +39,94 @@ def load_toml(path: str) -> dict[str, Any]: return tomllib.loads(f.read()) +@dataclass +class VariantProviderConfig: + requires: list[str] + plugin_api: str | None = None + plugin_use: Literal['all'] | Literal['build'] | Literal['none'] | None = None + enable_if: str | None = None + optional: bool = False + + @classmethod + def from_dict(cls, data: dict): + """Creates an instance of VariantProviderConfig from a dictionary.""" + # Convert hyphenated keys to underscored keys + data = {key.replace('-', '_'): value for key, value in data.items()} + + # Create an instance of VariantProviderConfig + return cls(**data) + + def validate(self): + """Validates the VariantProviderConfig instance.""" + if not self.requires: + raise ValueError('Requires list cannot be empty') + + +@dataclass +class VariantConfig: + vlabel: str | None + properties: list[str] | None + default_priorities: dict[str, list[str]] + providers: dict[str, VariantProviderConfig] + + @classmethod + def from_dict(cls, data: dict, vprops: list[str] | None, variant_label: str | None): + """Creates an instance of VariantConfig from a dictionary.""" + data = data.copy() + + if not 'default-priorities' in data: + raise ValueError('Missing `default-priorities` in variant configuration') + + if vprops is None: + data['vlabel'] = None + data['properties'] = None + + elif len(vprops) == 0: + data['vlabel'] = 'null' + data['properties'] = [] + + else: + # Normalizing + _vprops = [[el.strip() for el in vprop.split('::')] for vprop in vprops] + for vprop in _vprops: + if len(vprop) != 3: + raise ValueError(f'Invalid variant property: {vprop}') + + data['properties'] = [' :: '.join(vprop) for vprop in sorted(_vprops)] + + if variant_label is None: + hash_object = hashlib.sha256() + for vprop in data['properties']: + hash_object.update(f'{vprop}\n'.encode()) + data['vlabel'] = hash_object.hexdigest()[:VARIANT_LABEL_LENGTH] + + if variant_label is not None: + if data['properties'] is None or len(data['properties']) == 0: + raise ValueError('Variant Properties cannot be empty when a variant label is provided') + data['vlabel'] = variant_label + + # Convert hyphenated keys to underscored keys + data = {key.replace('-', '_'): value for key, value in data.items()} + + # Convert providers to VariantProviderConfig instances + data['providers'] = { + provider: VariantProviderConfig.from_dict(provider_data) + for provider, provider_data in data.get('providers', {}).items() + } + + # Create an instance of VariantConfig + return cls(**data) + + def validate(self): + """Validates the VariantConfig instance.""" + for namespace in self.default_priorities['namespace']: + if namespace not in self.providers: + raise ValueError(f"Namespace '{namespace}' is not defined in the variant providers") + + for provider_cfg in self.providers.values(): + provider_cfg.validate() + + class ProjectMetadata(Generic[PluginManagerBound]): def __init__( self, @@ -58,6 +149,10 @@ def __init__( self._version: str | None = None self._project_file: str | None = None + self.variant_label: str | None = None + self.variant_config: VariantConfig | None = None + self._variant_config_data: dict[str, Any] | None = None + # App already loaded config if config is not None and root is not None: self._project_file = os.path.join(root, 'pyproject.toml') @@ -126,6 +221,13 @@ def dynamic(self) -> list[str]: return self._dynamic + @property + def variant_config_data(self) -> dict[str, Any]: + """Variant configuration data fetched from pyproject.toml""" + if self._variant_config_data is None: + self._variant_config_data = self.config.get('variant', {}) + return self._variant_config_data + @property def name(self) -> str: # Duplicate the name parsing here for situations where it's diff --git a/backend/src/hatchling/metadata/spec.py b/backend/src/hatchling/metadata/spec.py index b295b7014..8ecd10ddd 100644 --- a/backend/src/hatchling/metadata/spec.py +++ b/backend/src/hatchling/metadata/spec.py @@ -214,15 +214,15 @@ def construct_metadata_file_1_2(metadata: ProjectMetadata, extra_dependencies: t authors_data = metadata.core.authors_data if authors_data['name']: - metadata_file += f"Author: {', '.join(authors_data['name'])}\n" + metadata_file += f'Author: {", ".join(authors_data["name"])}\n' if authors_data['email']: - metadata_file += f"Author-email: {', '.join(authors_data['email'])}\n" + metadata_file += f'Author-email: {", ".join(authors_data["email"])}\n' maintainers_data = metadata.core.maintainers_data if maintainers_data['name']: - metadata_file += f"Maintainer: {', '.join(maintainers_data['name'])}\n" + metadata_file += f'Maintainer: {", ".join(maintainers_data["name"])}\n' if maintainers_data['email']: - metadata_file += f"Maintainer-email: {', '.join(maintainers_data['email'])}\n" + metadata_file += f'Maintainer-email: {", ".join(maintainers_data["email"])}\n' if metadata.core.license: license_start = 'License: ' @@ -238,7 +238,7 @@ def construct_metadata_file_1_2(metadata: ProjectMetadata, extra_dependencies: t metadata_file += f'License: {metadata.core.license_expression}\n' if metadata.core.keywords: - metadata_file += f"Keywords: {','.join(metadata.core.keywords)}\n" + metadata_file += f'Keywords: {",".join(metadata.core.keywords)}\n' if metadata.core.classifiers: for classifier in metadata.core.classifiers: @@ -275,15 +275,15 @@ def construct_metadata_file_2_1(metadata: ProjectMetadata, extra_dependencies: t authors_data = metadata.core.authors_data if authors_data['name']: - metadata_file += f"Author: {', '.join(authors_data['name'])}\n" + metadata_file += f'Author: {", ".join(authors_data["name"])}\n' if authors_data['email']: - metadata_file += f"Author-email: {', '.join(authors_data['email'])}\n" + metadata_file += f'Author-email: {", ".join(authors_data["email"])}\n' maintainers_data = metadata.core.maintainers_data if maintainers_data['name']: - metadata_file += f"Maintainer: {', '.join(maintainers_data['name'])}\n" + metadata_file += f'Maintainer: {", ".join(maintainers_data["name"])}\n' if maintainers_data['email']: - metadata_file += f"Maintainer-email: {', '.join(maintainers_data['email'])}\n" + metadata_file += f'Maintainer-email: {", ".join(maintainers_data["email"])}\n' if metadata.core.license: license_start = 'License: ' @@ -299,7 +299,7 @@ def construct_metadata_file_2_1(metadata: ProjectMetadata, extra_dependencies: t metadata_file += f'License: {metadata.core.license_expression}\n' if metadata.core.keywords: - metadata_file += f"Keywords: {','.join(metadata.core.keywords)}\n" + metadata_file += f'Keywords: {",".join(metadata.core.keywords)}\n' if metadata.core.classifiers: for classifier in metadata.core.classifiers: @@ -361,15 +361,15 @@ def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: t authors_data = metadata.core.authors_data if authors_data['name']: - metadata_file += f"Author: {', '.join(authors_data['name'])}\n" + metadata_file += f'Author: {", ".join(authors_data["name"])}\n' if authors_data['email']: - metadata_file += f"Author-email: {', '.join(authors_data['email'])}\n" + metadata_file += f'Author-email: {", ".join(authors_data["email"])}\n' maintainers_data = metadata.core.maintainers_data if maintainers_data['name']: - metadata_file += f"Maintainer: {', '.join(maintainers_data['name'])}\n" + metadata_file += f'Maintainer: {", ".join(maintainers_data["name"])}\n' if maintainers_data['email']: - metadata_file += f"Maintainer-email: {', '.join(maintainers_data['email'])}\n" + metadata_file += f'Maintainer-email: {", ".join(maintainers_data["email"])}\n' if metadata.core.license: license_start = 'License: ' @@ -385,7 +385,7 @@ def construct_metadata_file_2_2(metadata: ProjectMetadata, extra_dependencies: t metadata_file += f'License: {metadata.core.license_expression}\n' if metadata.core.keywords: - metadata_file += f"Keywords: {','.join(metadata.core.keywords)}\n" + metadata_file += f'Keywords: {",".join(metadata.core.keywords)}\n' if metadata.core.classifiers: for classifier in metadata.core.classifiers: @@ -447,15 +447,15 @@ def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: t authors_data = metadata.core.authors_data if authors_data['name']: - metadata_file += f"Author: {', '.join(authors_data['name'])}\n" + metadata_file += f'Author: {", ".join(authors_data["name"])}\n' if authors_data['email']: - metadata_file += f"Author-email: {', '.join(authors_data['email'])}\n" + metadata_file += f'Author-email: {", ".join(authors_data["email"])}\n' maintainers_data = metadata.core.maintainers_data if maintainers_data['name']: - metadata_file += f"Maintainer: {', '.join(maintainers_data['name'])}\n" + metadata_file += f'Maintainer: {", ".join(maintainers_data["name"])}\n' if maintainers_data['email']: - metadata_file += f"Maintainer-email: {', '.join(maintainers_data['email'])}\n" + metadata_file += f'Maintainer-email: {", ".join(maintainers_data["email"])}\n' if metadata.core.license: license_start = 'License: ' @@ -471,7 +471,7 @@ def construct_metadata_file_2_3(metadata: ProjectMetadata, extra_dependencies: t metadata_file += f'License: {metadata.core.license_expression}\n' if metadata.core.keywords: - metadata_file += f"Keywords: {','.join(metadata.core.keywords)}\n" + metadata_file += f'Keywords: {",".join(metadata.core.keywords)}\n' if metadata.core.classifiers: for classifier in metadata.core.classifiers: @@ -533,15 +533,15 @@ def construct_metadata_file_2_4(metadata: ProjectMetadata, extra_dependencies: t authors_data = metadata.core.authors_data if authors_data['name']: - metadata_file += f"Author: {', '.join(authors_data['name'])}\n" + metadata_file += f'Author: {", ".join(authors_data["name"])}\n' if authors_data['email']: - metadata_file += f"Author-email: {', '.join(authors_data['email'])}\n" + metadata_file += f'Author-email: {", ".join(authors_data["email"])}\n' maintainers_data = metadata.core.maintainers_data if maintainers_data['name']: - metadata_file += f"Maintainer: {', '.join(maintainers_data['name'])}\n" + metadata_file += f'Maintainer: {", ".join(maintainers_data["name"])}\n' if maintainers_data['email']: - metadata_file += f"Maintainer-email: {', '.join(maintainers_data['email'])}\n" + metadata_file += f'Maintainer-email: {", ".join(maintainers_data["email"])}\n' if metadata.core.license: license_start = 'License: ' @@ -562,7 +562,7 @@ def construct_metadata_file_2_4(metadata: ProjectMetadata, extra_dependencies: t metadata_file += f'License-File: {license_file}\n' if metadata.core.keywords: - metadata_file += f"Keywords: {','.join(metadata.core.keywords)}\n" + metadata_file += f'Keywords: {",".join(metadata.core.keywords)}\n' if metadata.core.classifiers: for classifier in metadata.core.classifiers: diff --git a/ruff_defaults.toml b/ruff_defaults.toml index 9acc585a3..dacc7bb35 100644 --- a/ruff_defaults.toml +++ b/ruff_defaults.toml @@ -500,7 +500,6 @@ select = [ "S317", "S318", "S319", - "S320", "S321", "S323", "S324", @@ -629,7 +628,6 @@ select = [ "UP035", "UP036", "UP037", - "UP038", "UP039", "UP040", "UP041", diff --git a/src/hatch/cli/build/__init__.py b/src/hatch/cli/build/__init__.py index 7b5c63f69..3dbc949f7 100644 --- a/src/hatch/cli/build/__init__.py +++ b/src/hatch/cli/build/__init__.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +import sys import click if TYPE_CHECKING: @@ -49,8 +50,32 @@ ), ) @click.option('--clean-only', is_flag=True, hidden=True) +@click.option( + '-p', + '--variant-property', + 'variant_props', + multiple=True, + help=( + "Variant Properties to add to the Wheel Variant, can be repeated as many " + "times as needed" + ), +) +@click.option( + '--null-variant', + 'null_variant', + is_flag=True, + help='Make the variant a `null variant` - no variant property.', +) +@click.option( + '--variant-label', + 'variant_label', + help='Use a custom variant label (the default is variant hash)', +) @click.pass_obj -def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, clean_hooks_after, clean_only): +def build( + app: Application, location, targets, hooks_only, no_hooks, ext, clean, clean_hooks_after, clean_only, + variant_props, null_variant, variant_label, +): """Build a project.""" app.ensure_environment_plugin_dependencies() @@ -85,6 +110,7 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, app.display_header(target_name) if build_backend != BUILD_BACKEND: + # TODO(hcho3): Should we pass variant flags to non-Hatchling backend?? if target_name == 'sdist': directory = build_dir or app.project.location / DEFAULT_BUILD_DIRECTORY directory.ensure_dir_exists() @@ -102,7 +128,14 @@ def build(app: Application, location, targets, hooks_only, no_hooks, ext, clean, else str(artifact_path) ) else: - command = ['python', '-u', '-m', 'hatchling', 'build', '--target', target] + command = [sys.executable, '-u', '-m', 'hatchling', 'build', '--target', target] + # Pass variant flags to Hatchling + for prop in variant_props: + command.extend(['-p', prop]) + if null_variant: + command.append('--null-variant') + if variant_label: + command.extend(['--variant-label', variant_label]) # We deliberately pass the location unchanged so that absolute paths may be non-local # and reflect wherever builds actually take place