From c5bbb1626d22dc8ae80f3a949248e558a1ce4d7f Mon Sep 17 00:00:00 2001 From: Hyunsu Cho Date: Fri, 18 Jul 2025 18:24:12 -0700 Subject: [PATCH 01/18] [WIP] Add variant support --- backend/src/hatchling/build.py | 5 + .../hatchling/builders/variant_constants.py | 143 ++++++++++++++++++ backend/src/hatchling/builders/wheel.py | 101 +++++++++++++ backend/src/hatchling/cli/build/__init__.py | 34 +++++ backend/src/hatchling/metadata/core.py | 115 ++++++++++++++ ruff_defaults.toml | 2 - src/hatch/cli/build/__init__.py | 36 ++++- 7 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 backend/src/hatchling/builders/variant_constants.py diff --git a/backend/src/hatchling/build.py b/backend/src/hatchling/build.py index 5a1da4f89..c042508d3 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', @@ -116,6 +118,9 @@ 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)) + 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)) + return os.path.basename(directory) def prepare_metadata_for_build_editable( diff --git a/backend/src/hatchling/builders/variant_constants.py b/backend/src/hatchling/builders/variant_constants.py new file mode 100644 index 000000000..fd572e2d3 --- /dev/null +++ b/backend/src/hatchling/builders/variant_constants.py @@ -0,0 +1,143 @@ +# 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, TypedDict + +VARIANT_HASH_LEN = 8 +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_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/" +VARIANTS_JSON_VARIANT_DATA_KEY: Literal["variants"] = "variants" + +VALIDATION_VARIANT_HASH_REGEX = re.compile(rf"[0-9a-f]{{{VARIANT_HASH_LEN}}}") + +VALIDATION_NAMESPACE_REGEX = re.compile(r"[a-z0-9_]+") +VALIDATION_FEATURE_NAME_REGEX = re.compile(r"[a-z0-9_]+") + +# For `Property value` there is two regexes: +# 1. `VALIDATION_VALUE_VSPEC_REGEX` - if `packaging.specifiers.SpecifierSet` is used +# Note: for clarity - only "full version" are allowed +# i.e. so no "a|b|alpha|beta|rc|post|etc." versions +VALIDATION_VALUE_VSPEC_REGEX = re.compile(r"[0-9_.,!>~<=]+") +# 2. `VALIDATION_VALUE_STR_REGEX` - if string matching is used +VALIDATION_VALUE_STR_REGEX = re.compile(r"[a-z0-9_.]+") +VALIDATION_VALUE_REGEX = re.compile( + rf"{VALIDATION_VALUE_VSPEC_REGEX.pattern}|{VALIDATION_VALUE_STR_REGEX.pattern}" +) + +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" [0-9a-f]{{{VARIANT_HASH_LEN}}} " + 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, + }, + 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, +) \ No newline at end of file diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index e6bc55262..0d70321cc 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -2,15 +2,20 @@ 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.metadata.core import ProjectMetadata + from hatchling.__about__ import __version__ from hatchling.builders.config import BuilderConfig from hatchling.builders.constants import EDITABLES_REQUIREMENT @@ -26,6 +31,22 @@ 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, +) from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors if TYPE_CHECKING: @@ -188,6 +209,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 +304,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(metadata: ProjectMetadata) -> str: + if metadata.variant_hash is not None: + 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 := metadata.variant_default_priorities["namespace"]): + data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_NAMESPACE_KEY] = ns_prio + + if (feat_prio := metadata.variant_default_priorities["feature"]): + data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_FEATURE_KEY] = feat_prio + + if (prop_prio := metadata.variant_default_priorities["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, plugin_conf in metadata.variant_plugins.items(): + variant_providers[ns][VARIANT_INFO_PROVIDER_REQUIRES_KEY] = plugin_conf.get("requires", []) + + if (enable_if := plugin_conf.get("enable_if", None)) is not None: + variant_providers[ns][VARIANT_INFO_PROVIDER_ENABLE_IF_KEY] = enable_if + + if plugin_conf.get("optional", False): + variant_providers[ns][VARIANT_INFO_PROVIDER_OPTIONAL_KEY] = True + + if (plugin_api := plugin_conf.get("plugin_api", None)) is not None: + variant_providers[ns][VARIANT_INFO_PROVIDER_PLUGIN_API_KEY] = plugin_api + + data[VARIANT_INFO_PROVIDER_DATA_KEY] = variant_providers + + # ==================== VARIANTS_JSON_VARIANT_DATA_KEY ==================== # + + variant_data = defaultdict(lambda: defaultdict(set)) + for vprop_str in metadata.variant_properties: + match = VALIDATION_PROPERTY_REGEX.match(vprop_str) + if not match: + raise ValueError( + f"Invalid variant property '{vprop_str}' in variant {metadata.variant_hash}" + ) + namespace = match.group('namespace') + feature = match.group('feature') + value = match.group('value') + variant_data[namespace][feature].add(value) + data[VARIANTS_JSON_VARIANT_DATA_KEY][metadata.variant_hash] = variant_data + + def preprocess(data): + """Preprocess the data to ensure it is JSON serializable.""" + if isinstance(data, (defaultdict, 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 + ) + return '' + self.__variants_json_constructor = constructor + return self.__variants_json_constructor + @property def shared_data(self) -> dict[str, str]: if self.__shared_data is None: @@ -710,6 +806,11 @@ def write_project_metadata( 'METADATA', self.config.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies) ) records.write(record) + record = archive.write_metadata( + VARIANT_DIST_INFO_FILENAME, + self.config.variants_json_constructor(self.metadata), + ) + 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..b653d989e 100644 --- a/backend/src/hatchling/cli/build/__init__.py +++ b/backend/src/hatchling/cli/build/__init__.py @@ -15,7 +15,15 @@ def build_impl( clean_hooks_after: bool, clean_only: bool, show_dynamic_deps: bool, + variant_props: list[str], + variant_null: bool, + variant_label: str | None, ) -> None: + print(f"{__file__}::build_impl") + print(f'{variant_props=}') + print(f'{variant_null=}') + print(f'{variant_label=}') + import os from hatchling.bridge.app import Application @@ -72,7 +80,9 @@ def build_impl( if not (clean_only or show_dynamic_deps) and len(target_data) > 1: app.display_mini_header(target_name) + print(f"{metadata=}") builder = builder_class(root, plugin_manager=plugin_manager, metadata=metadata, app=app.get_safe_application()) + if show_dynamic_deps: for dependency in builder.config.dynamic_dependencies: dynamic_dependencies[dependency] = None @@ -116,4 +126,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='variant_null', + 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..d83a2ae34 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -1,9 +1,11 @@ from __future__ import annotations +import hashlib import os import sys from contextlib import suppress from copy import deepcopy +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, cast from hatchling.metadata.utils import ( @@ -31,11 +33,122 @@ import tomli as tomllib +VARIANT_HASH_LEN = 8 + + def load_toml(path: str) -> dict[str, Any]: with open(path, encoding='utf-8') as f: return tomllib.loads(f.read()) +@dataclass +class VariantProviderConfig: + requires: list[str] + plugin_api: str | 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: + vhash: str + properties: list[str] + 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 vprops is None: + data["vhash"] = None + data["properties"] = None + + + elif len(vprops) == 0: + data["vhash"] = "0" * VARIANT_HASH_LEN + data["properties"] = [] + + else: + # Normalizing + _vprops = [ + [el.strip() for el in vprop.split("::")] + for vprop in vprops + ] + for vprop in _vprops: + assert len(vprop) == 3, f"Invalid variant property: {vprop}" + + data["properties"] = [" :: ".join(vprop) for vprop in sorted(_vprops)] + + hash_object = hashlib.sha256() + for vprop in data["properties"]: + hash_object.update(f"{vprop}\n".encode()) + data["vhash"] = hash_object.hexdigest()[:VARIANT_HASH_LEN] + + if variant_label is not None: + data["vhash"] = 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["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() + + def to_variant_cfg_dict(self) -> dict[str, Any]: + """Converts the VariantConfig instance to a metadata dictionary.""" + return { + "variant_hash": self.vhash, + "variant_properties": self.properties, + "variant_plugins": { + namespace: { + "requires": provider_cfg.requires, + "plugin_api": provider_cfg.plugin_api, + "enable_if": provider_cfg.enable_if, + "optional": provider_cfg.optional, + } + for namespace, provider_cfg in self.providers.items() + }, + "variant_default_priorities": { + "namespace": self.default_priorities.get("namespace", []), + "feature": self.default_priorities.get("feature", {}), + "property": self.default_priorities.get("property", {}) + } + } + + class ProjectMetadata(Generic[PluginManagerBound]): def __init__( self, @@ -58,6 +171,8 @@ def __init__( self._version: str | None = None self._project_file: str | None = None + self.variant_hash: str | 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') 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..4886509e3 100644 --- a/src/hatch/cli/build/__init__.py +++ b/src/hatch/cli/build/__init__.py @@ -49,8 +49,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', + 'variant_null', + 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, variant_null, variant_label, +): """Build a project.""" app.ensure_environment_plugin_dependencies() @@ -85,6 +109,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 +127,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 = ['/workspace/.venv/bin/python', '-u', '-m', 'hatchling', 'build', '--target', target] + # Pass variant flags to Hatchling + for prop in variant_props: + command.extend(['-p', prop]) + if variant_null: + 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 From 17ac3085e414d0156b4dc4e22c79039579398911 Mon Sep 17 00:00:00 2001 From: Hyunsu Cho Date: Mon, 21 Jul 2025 18:48:43 -0700 Subject: [PATCH 02/18] Add variant support, wheel build functional --- backend/src/hatchling/build.py | 5 +- backend/src/hatchling/builders/wheel.py | 182 ++++++++++++-------- backend/src/hatchling/cli/build/__init__.py | 25 ++- backend/src/hatchling/metadata/core.py | 30 +--- src/hatch/cli/build/__init__.py | 2 +- 5 files changed, 136 insertions(+), 108 deletions(-) diff --git a/backend/src/hatchling/build.py b/backend/src/hatchling/build.py index c042508d3..eb1a65422 100644 --- a/backend/src/hatchling/build.py +++ b/backend/src/hatchling/build.py @@ -118,8 +118,9 @@ 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)) - 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)) + if builder.metadata.variant_hash 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) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index 0d70321cc..86e49e55b 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -14,7 +14,9 @@ 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 @@ -47,6 +49,7 @@ VARIANTS_JSON_SCHEMA_URL, VARIANTS_JSON_VARIANT_DATA_KEY, ) +from hatchling.metadata.core import VariantConfig from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors if TYPE_CHECKING: @@ -307,75 +310,73 @@ def core_metadata_constructor(self) -> Callable[..., str]: @property def variants_json_constructor(self) -> Callable[..., str]: if self.__variants_json_constructor is None: - def constructor(metadata: ProjectMetadata) -> str: - if metadata.variant_hash is not None: - 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 := metadata.variant_default_priorities["namespace"]): - data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_NAMESPACE_KEY] = ns_prio - - if (feat_prio := metadata.variant_default_priorities["feature"]): - data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_FEATURE_KEY] = feat_prio - - if (prop_prio := metadata.variant_default_priorities["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, plugin_conf in metadata.variant_plugins.items(): - variant_providers[ns][VARIANT_INFO_PROVIDER_REQUIRES_KEY] = plugin_conf.get("requires", []) - - if (enable_if := plugin_conf.get("enable_if", None)) is not None: - variant_providers[ns][VARIANT_INFO_PROVIDER_ENABLE_IF_KEY] = enable_if - - if plugin_conf.get("optional", False): - variant_providers[ns][VARIANT_INFO_PROVIDER_OPTIONAL_KEY] = True - - if (plugin_api := plugin_conf.get("plugin_api", None)) is not None: - variant_providers[ns][VARIANT_INFO_PROVIDER_PLUGIN_API_KEY] = plugin_api - - data[VARIANT_INFO_PROVIDER_DATA_KEY] = variant_providers - - # ==================== VARIANTS_JSON_VARIANT_DATA_KEY ==================== # - - variant_data = defaultdict(lambda: defaultdict(set)) - for vprop_str in metadata.variant_properties: - match = VALIDATION_PROPERTY_REGEX.match(vprop_str) - if not match: - raise ValueError( - f"Invalid variant property '{vprop_str}' in variant {metadata.variant_hash}" - ) - namespace = match.group('namespace') - feature = match.group('feature') - value = match.group('value') - variant_data[namespace][feature].add(value) - data[VARIANTS_JSON_VARIANT_DATA_KEY][metadata.variant_hash] = variant_data - - def preprocess(data): - """Preprocess the data to ensure it is JSON serializable.""" - if isinstance(data, (defaultdict, 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 - ) - return '' - self.__variants_json_constructor = constructor + 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["feature"]): + data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_FEATURE_KEY] = feat_prio + + if (prop_prio := variant_config.default_priorities["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 + + 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.variant_hash}" + ) + 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.vhash] = variant_data + + def preprocess(data): + """Preprocess the data to ensure it is JSON serializable.""" + if isinstance(data, (defaultdict, 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 @@ -545,6 +546,32 @@ 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, + ): + metadata.variant_config = VariantConfig.from_dict( + data=metadata.variant_config_data, + vprops=variant_props, + variant_label=variant_label, + ) + metadata.variant_config.validate() + metadata.variant_hash = metadata.variant_config.vhash + + 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} @@ -579,7 +606,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_hash is not None: + wheel_name = f"{self.artifact_project_id}-{build_data['tag']}-{self.metadata.variant_hash}.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) @@ -806,11 +837,12 @@ def write_project_metadata( 'METADATA', self.config.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies) ) records.write(record) - record = archive.write_metadata( - VARIANT_DIST_INFO_FILENAME, - self.config.variants_json_constructor(self.metadata), - ) - records.write(record) + if self.metadata.variant_hash 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 b653d989e..3eedd67f9 100644 --- a/backend/src/hatchling/cli/build/__init__.py +++ b/backend/src/hatchling/cli/build/__init__.py @@ -15,15 +15,10 @@ def build_impl( clean_hooks_after: bool, clean_only: bool, show_dynamic_deps: bool, - variant_props: list[str], - variant_null: bool, + variant_props: list[str] | None = None, + variant_null: bool = False, variant_label: str | None, ) -> None: - print(f"{__file__}::build_impl") - print(f'{variant_props=}') - print(f'{variant_null=}') - print(f'{variant_label=}') - import os from hatchling.bridge.app import Application @@ -80,8 +75,20 @@ def build_impl( if not (clean_only or show_dynamic_deps) and len(target_data) > 1: app.display_mini_header(target_name) - print(f"{metadata=}") - builder = builder_class(root, plugin_manager=plugin_manager, metadata=metadata, app=app.get_safe_application()) + variant_build_kwargs = {} + if f'{builder_class.__module__}.{builder_class.__name__}' == 'hatchling.builders.wheel.WheelBuilder': + variant_build_kwargs = { + "variant_props": variant_props if not variant_null else [], + "variant_label": variant_label, + } + + builder = builder_class( + root, + plugin_manager=plugin_manager, + metadata=metadata, + app=app.get_safe_application(), + **variant_build_kwargs, + ) if show_dynamic_deps: for dependency in builder.config.dynamic_dependencies: diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index d83a2ae34..c823ea63d 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -127,27 +127,6 @@ def validate(self): for provider_cfg in self.providers.values(): provider_cfg.validate() - def to_variant_cfg_dict(self) -> dict[str, Any]: - """Converts the VariantConfig instance to a metadata dictionary.""" - return { - "variant_hash": self.vhash, - "variant_properties": self.properties, - "variant_plugins": { - namespace: { - "requires": provider_cfg.requires, - "plugin_api": provider_cfg.plugin_api, - "enable_if": provider_cfg.enable_if, - "optional": provider_cfg.optional, - } - for namespace, provider_cfg in self.providers.items() - }, - "variant_default_priorities": { - "namespace": self.default_priorities.get("namespace", []), - "feature": self.default_priorities.get("feature", {}), - "property": self.default_priorities.get("property", {}) - } - } - class ProjectMetadata(Generic[PluginManagerBound]): def __init__( @@ -172,6 +151,8 @@ def __init__( self._project_file: str | None = None self.variant_hash: 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: @@ -241,6 +222,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/src/hatch/cli/build/__init__.py b/src/hatch/cli/build/__init__.py index 4886509e3..8e57fe4da 100644 --- a/src/hatch/cli/build/__init__.py +++ b/src/hatch/cli/build/__init__.py @@ -127,7 +127,7 @@ def build( else str(artifact_path) ) else: - command = ['/workspace/.venv/bin/python', '-u', '-m', 'hatchling', 'build', '--target', target] + command = ['python', '-u', '-m', 'hatchling', 'build', '--target', target] # Pass variant flags to Hatchling for prop in variant_props: command.extend(['-p', prop]) From 302daa1dc36c2d5d5e507895a4dc3eb9adb92cb8 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Tue, 29 Jul 2025 17:48:35 -0400 Subject: [PATCH 03/18] Python path --- backend/src/hatchling/builders/wheel.py | 16 +++++++++------- src/hatch/cli/build/__init__.py | 3 ++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index 86e49e55b..f67f03633 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -556,13 +556,15 @@ def __init__( variant_props: list[str] | None = None, variant_label: str | None = 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_hash = metadata.variant_config.vhash + + if metadata 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_hash = metadata.variant_config.vhash super().__init__( root=root, diff --git a/src/hatch/cli/build/__init__.py b/src/hatch/cli/build/__init__.py index 8e57fe4da..3840441ac 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: @@ -127,7 +128,7 @@ def build( 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]) From 944ad692334fa2f1e148bb35d6ce329fdd2ca138 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Thu, 31 Jul 2025 14:05:08 -0400 Subject: [PATCH 04/18] Incorrect logic fixed. Explicitly block forbidden scenario --- backend/src/hatchling/metadata/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index c823ea63d..4ae0136d5 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -102,6 +102,9 @@ def from_dict(cls, data: dict, vprops: list[str] | None, data["vhash"] = hash_object.hexdigest()[:VARIANT_HASH_LEN] 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["vhash"] = variant_label # Convert hyphenated keys to underscored keys From c2ae650dc2d573bc15252eead56b4fe054d6bc45 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Fri, 5 Sep 2025 14:01:03 -0400 Subject: [PATCH 05/18] [Null Variant] Made uniform --- backend/src/hatchling/cli/build/__init__.py | 6 +++--- src/hatch/cli/build/__init__.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/hatchling/cli/build/__init__.py b/backend/src/hatchling/cli/build/__init__.py index 3eedd67f9..12507e9de 100644 --- a/backend/src/hatchling/cli/build/__init__.py +++ b/backend/src/hatchling/cli/build/__init__.py @@ -16,7 +16,7 @@ def build_impl( clean_only: bool, show_dynamic_deps: bool, variant_props: list[str] | None = None, - variant_null: bool = False, + null_variant: bool = False, variant_label: str | None, ) -> None: import os @@ -78,7 +78,7 @@ def build_impl( variant_build_kwargs = {} if f'{builder_class.__module__}.{builder_class.__name__}' == 'hatchling.builders.wheel.WheelBuilder': variant_build_kwargs = { - "variant_props": variant_props if not variant_null else [], + "variant_props": variant_props if not null_variant else [], "variant_label": variant_label, } @@ -147,7 +147,7 @@ def build_command(subparsers: argparse._SubParsersAction, defaults: Any) -> None ) group.add_argument( '--null-variant', - dest='variant_null', + dest='null_variant', action='store_true', help='Make the variant a `null variant` - no variant property.', ) diff --git a/src/hatch/cli/build/__init__.py b/src/hatch/cli/build/__init__.py index 3840441ac..3dbc949f7 100644 --- a/src/hatch/cli/build/__init__.py +++ b/src/hatch/cli/build/__init__.py @@ -62,7 +62,7 @@ ) @click.option( '--null-variant', - 'variant_null', + 'null_variant', is_flag=True, help='Make the variant a `null variant` - no variant property.', ) @@ -74,7 +74,7 @@ @click.pass_obj def build( app: Application, location, targets, hooks_only, no_hooks, ext, clean, clean_hooks_after, clean_only, - variant_props, variant_null, variant_label, + variant_props, null_variant, variant_label, ): """Build a project.""" app.ensure_environment_plugin_dependencies() @@ -132,7 +132,7 @@ def build( # Pass variant flags to Hatchling for prop in variant_props: command.extend(['-p', prop]) - if variant_null: + if null_variant: command.append('--null-variant') if variant_label: command.extend(['--variant-label', variant_label]) From acfff5983cd4c4d5b3775abd6282ceb7b5521fb6 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Fri, 5 Sep 2025 14:01:57 -0400 Subject: [PATCH 06/18] [VariantLib] constant file updated --- .../hatchling/builders/variant_constants.py | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/backend/src/hatchling/builders/variant_constants.py b/backend/src/hatchling/builders/variant_constants.py index fd572e2d3..00832be60 100644 --- a/backend/src/hatchling/builders/variant_constants.py +++ b/backend/src/hatchling/builders/variant_constants.py @@ -1,12 +1,13 @@ # 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, TypedDict +from typing import Literal +from typing import TypedDict -VARIANT_HASH_LEN = 8 +VARIANT_LABEL_LENGTH = 16 +NULL_VARIANT_LABEL = "null" CONFIG_FILENAME = "variants.toml" VARIANT_DIST_INFO_FILENAME = "variant.json" @@ -19,6 +20,7 @@ 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" @@ -27,21 +29,11 @@ VARIANTS_JSON_SCHEMA_URL = "https://variants-schema.wheelnext.dev/" VARIANTS_JSON_VARIANT_DATA_KEY: Literal["variants"] = "variants" -VALIDATION_VARIANT_HASH_REGEX = re.compile(rf"[0-9a-f]{{{VARIANT_HASH_LEN}}}") +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_]+") - -# For `Property value` there is two regexes: -# 1. `VALIDATION_VALUE_VSPEC_REGEX` - if `packaging.specifiers.SpecifierSet` is used -# Note: for clarity - only "full version" are allowed -# i.e. so no "a|b|alpha|beta|rc|post|etc." versions -VALIDATION_VALUE_VSPEC_REGEX = re.compile(r"[0-9_.,!>~<=]+") -# 2. `VALIDATION_VALUE_STR_REGEX` - if string matching is used -VALIDATION_VALUE_STR_REGEX = re.compile(r"[a-z0-9_.]+") -VALIDATION_VALUE_REGEX = re.compile( - rf"{VALIDATION_VALUE_VSPEC_REGEX.pattern}|{VALIDATION_VALUE_STR_REGEX.pattern}" -) +VALIDATION_VALUE_REGEX = re.compile(r"[a-z0-9_.]+") VALIDATION_FEATURE_REGEX = re.compile( rf""" @@ -87,13 +79,13 @@ r" (?P[^\s-]+?) " # r" - (?P[^\s-]*?) " # "-" r" ) " # close "namever" group - r" ( - (?P\d[^-]*?) )? " # optional "-" + r" (?: - (?P\d[^-]*?) )? " # optional "-" r" - (?P[^\s-]+?) " # "-" tag r" - (?P[^\s-]+?) " # "-" tag r" - (?P[^\s-]+?) " # "-" tag r") " # end of group - r"( - (?P " # optional - rf" [0-9a-f]{{{VARIANT_HASH_LEN}}} " + r"(?: - (?P " # optional + rf" {VALIDATION_VARIANT_LABEL_REGEX.pattern}" r" ) " r")? " r"\.whl " # ".whl" suffix @@ -124,6 +116,8 @@ class PriorityJsonDict(TypedDict, total=False): "plugin-api": str, "requires": list[str], "enable-if": str, + "optional": bool, + "plugin-use": Literal["all", "build", "none"], }, total=False, ) @@ -140,4 +134,4 @@ class PriorityJsonDict(TypedDict, total=False): "variants": dict[str, VariantInfoJsonDict], }, total=False, -) \ No newline at end of file +) From e51c72b7dbb95dd83d741e9e45c60a098b2ef336 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Fri, 5 Sep 2025 14:14:43 -0400 Subject: [PATCH 07/18] Adjustment to new Specs --- backend/src/hatchling/build.py | 2 +- backend/src/hatchling/builders/wheel.py | 13 +++++++------ backend/src/hatchling/metadata/core.py | 12 ++++++------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/src/hatchling/build.py b/backend/src/hatchling/build.py index eb1a65422..5c47c6aaf 100644 --- a/backend/src/hatchling/build.py +++ b/backend/src/hatchling/build.py @@ -118,7 +118,7 @@ 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_hash is not None: + 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)) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index f67f03633..62e4f40b1 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -357,13 +357,14 @@ def constructor(variant_config: VariantConfig) -> str: match = VALIDATION_PROPERTY_REGEX.match(vprop_str) if not match: raise ValueError( - f"Invalid variant property '{vprop_str}' in variant {variant_config.variant_hash}" + f"Invalid variant property '{vprop_str}' in variant `{variant_config.variant_label}`" ) 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.vhash] = variant_data + + data[VARIANTS_JSON_VARIANT_DATA_KEY][variant_config.vlabel] = variant_data def preprocess(data): """Preprocess the data to ensure it is JSON serializable.""" @@ -564,7 +565,7 @@ def __init__( variant_label=variant_label, ) metadata.variant_config.validate() - metadata.variant_hash = metadata.variant_config.vhash + metadata.variant_label = metadata.variant_config.vlabel super().__init__( root=root, @@ -608,8 +609,8 @@ def build_standard(self, directory: str, **build_data: Any) -> str: records.write((f'{archive.metadata_directory}/RECORD', '', '')) archive.write_metadata('RECORD', records.construct()) - if self.metadata.variant_hash is not None: - wheel_name = f"{self.artifact_project_id}-{build_data['tag']}-{self.metadata.variant_hash}.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) @@ -839,7 +840,7 @@ def write_project_metadata( 'METADATA', self.config.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies) ) records.write(record) - if self.metadata.variant_hash is not None: + 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), diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 4ae0136d5..fc8817d91 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -65,7 +65,7 @@ def validate(self): @dataclass class VariantConfig: - vhash: str + vlabel: str properties: list[str] default_priorities: dict[str, list[str]] providers: dict[str, VariantProviderConfig] @@ -77,12 +77,12 @@ def from_dict(cls, data: dict, vprops: list[str] | None, data = data.copy() if vprops is None: - data["vhash"] = None + data["vlabel"] = None data["properties"] = None elif len(vprops) == 0: - data["vhash"] = "0" * VARIANT_HASH_LEN + data["vlabel"] = "null" data["properties"] = [] else: @@ -99,13 +99,13 @@ def from_dict(cls, data: dict, vprops: list[str] | None, hash_object = hashlib.sha256() for vprop in data["properties"]: hash_object.update(f"{vprop}\n".encode()) - data["vhash"] = hash_object.hexdigest()[:VARIANT_HASH_LEN] + data["vlabel"] = hash_object.hexdigest()[:VARIANT_HASH_LEN] 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["vhash"] = variant_label + data["vlabel"] = variant_label # Convert hyphenated keys to underscored keys data = {key.replace("-", "_"): value for key, value in data.items()} @@ -153,7 +153,7 @@ def __init__( self._version: str | None = None self._project_file: str | None = None - self.variant_hash: str | None = None + self.variant_label: str | None = None self.variant_config: VariantConfig | None = None self._variant_config_data: dict[str, Any] | None = None From e14634a65ccbf9219baf71a293dcdb2e890a32a1 Mon Sep 17 00:00:00 2001 From: Hyunsu Cho Date: Tue, 9 Sep 2025 19:55:47 +0000 Subject: [PATCH 08/18] Address review comment: Pass variant kwargs to all builders --- backend/src/hatchling/builders/plugin/interface.py | 2 ++ backend/src/hatchling/builders/wheel.py | 2 +- backend/src/hatchling/cli/build/__init__.py | 10 ++-------- 3 files changed, 5 insertions(+), 9 deletions(-) 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/wheel.py b/backend/src/hatchling/builders/wheel.py index 62e4f40b1..a61e2dd41 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -557,7 +557,7 @@ def __init__( variant_props: list[str] | None = None, variant_label: str | None = None, ): - + if metadata is not None: metadata.variant_config = VariantConfig.from_dict( data=metadata.variant_config_data, diff --git a/backend/src/hatchling/cli/build/__init__.py b/backend/src/hatchling/cli/build/__init__.py index 12507e9de..db612e32f 100644 --- a/backend/src/hatchling/cli/build/__init__.py +++ b/backend/src/hatchling/cli/build/__init__.py @@ -75,19 +75,13 @@ def build_impl( if not (clean_only or show_dynamic_deps) and len(target_data) > 1: app.display_mini_header(target_name) - variant_build_kwargs = {} - if f'{builder_class.__module__}.{builder_class.__name__}' == 'hatchling.builders.wheel.WheelBuilder': - variant_build_kwargs = { - "variant_props": variant_props if not null_variant else [], - "variant_label": variant_label, - } - builder = builder_class( root, plugin_manager=plugin_manager, metadata=metadata, app=app.get_safe_application(), - **variant_build_kwargs, + variant_props=variant_props if not null_variant else [], + variant_label=variant_label, ) if show_dynamic_deps: From adfd0f902a56bbc58c7c99ccbcaf4a9b6d9cb639 Mon Sep 17 00:00:00 2001 From: Hyunsu Cho Date: Tue, 9 Sep 2025 21:08:01 +0000 Subject: [PATCH 09/18] Address various review comments --- backend/src/hatchling/builders/wheel.py | 2 +- backend/src/hatchling/metadata/core.py | 21 ++++++++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index a61e2dd41..d52cb2e9c 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -368,7 +368,7 @@ def constructor(variant_config: VariantConfig) -> str: def preprocess(data): """Preprocess the data to ensure it is JSON serializable.""" - if isinstance(data, (defaultdict, dict)): + if isinstance(data, dict): return {k: preprocess(v) for k, v in data.items()} if isinstance(data, set): return list(data) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index fc8817d91..6b2feb977 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, cast +from hatchling.builders.variant_constants import VARIANT_LABEL_LENGTH from hatchling.metadata.utils import ( format_dependency, is_valid_project_name, @@ -33,9 +34,6 @@ import tomli as tomllib -VARIANT_HASH_LEN = 8 - - def load_toml(path: str) -> dict[str, Any]: with open(path, encoding='utf-8') as f: return tomllib.loads(f.read()) @@ -65,8 +63,8 @@ def validate(self): @dataclass class VariantConfig: - vlabel: str - properties: list[str] + vlabel: str | None + properties: list[str] | None default_priorities: dict[str, list[str]] providers: dict[str, VariantProviderConfig] @@ -92,19 +90,20 @@ def from_dict(cls, data: dict, vprops: list[str] | None, for vprop in vprops ] for vprop in _vprops: - assert len(vprop) == 3, f"Invalid variant property: {vprop}" + if len(vprop) != 3: + raise ValueError(f"Invalid variant property: {vprop}") data["properties"] = [" :: ".join(vprop) for vprop in sorted(_vprops)] - hash_object = hashlib.sha256() - for vprop in data["properties"]: - hash_object.update(f"{vprop}\n".encode()) - data["vlabel"] = hash_object.hexdigest()[:VARIANT_HASH_LEN] + 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 From 68b905f11ed5af4ee1500b373b5aaab9115138fa Mon Sep 17 00:00:00 2001 From: Hyunsu Cho Date: Mon, 15 Sep 2025 16:01:32 -0700 Subject: [PATCH 10/18] Add PEP517 config_settings interface --- backend/src/hatchling/build.py | 23 ++++++++++++++++++++--- backend/src/hatchling/builders/wheel.py | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/src/hatchling/build.py b/backend/src/hatchling/build.py index 5c47c6aaf..94ff24aca 100644 --- a/backend/src/hatchling/build.py +++ b/backend/src/hatchling/build.py @@ -48,15 +48,32 @@ 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) + + variant_props, variant_label = None, None + if "variant-property" in config_settings: + variant_props = config_settings["variant-property"] + if "variant-label" in config_settings: + variant_label = config_settings["variant-label"] + 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']))) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index d52cb2e9c..0e7a03590 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -357,7 +357,7 @@ def constructor(variant_config: VariantConfig) -> str: match = VALIDATION_PROPERTY_REGEX.match(vprop_str) if not match: raise ValueError( - f"Invalid variant property '{vprop_str}' in variant `{variant_config.variant_label}`" + f"Invalid variant property '{vprop_str}' in variant `{variant_config.vlabel}`" ) namespace = match.group('namespace') feature = match.group('feature') From 8c27bd4584f48ca6cc5865a3616bd0eff5baa493 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Thu, 18 Sep 2025 14:29:29 -0400 Subject: [PATCH 11/18] Variant Schema URL updated --- backend/src/hatchling/builders/variant_constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/hatchling/builders/variant_constants.py b/backend/src/hatchling/builders/variant_constants.py index 00832be60..69c5e5088 100644 --- a/backend/src/hatchling/builders/variant_constants.py +++ b/backend/src/hatchling/builders/variant_constants.py @@ -26,7 +26,7 @@ PYPROJECT_TOML_TOP_KEY = "variant" VARIANTS_JSON_SCHEMA_KEY: Literal["$schema"] = "$schema" -VARIANTS_JSON_SCHEMA_URL = "https://variants-schema.wheelnext.dev/" +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}}}") From 88bb42faa6a411899b5b480c1298545185c1dc8b Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 2 Oct 2025 16:12:20 +0200 Subject: [PATCH 12/18] Format variant support with ruff Hatch uses the ruff formatter as code style. --- backend/src/hatchling/build.py | 8 +- backend/src/hatchling/builders/config.py | 2 +- backend/src/hatchling/builders/sdist.py | 12 +- .../hatchling/builders/variant_constants.py | 104 +++++++++--------- backend/src/hatchling/builders/wheel.py | 31 +++--- backend/src/hatchling/cli/build/__init__.py | 2 +- backend/src/hatchling/metadata/core.py | 49 ++++----- backend/src/hatchling/metadata/spec.py | 50 ++++----- 8 files changed, 123 insertions(+), 135 deletions(-) diff --git a/backend/src/hatchling/build.py b/backend/src/hatchling/build.py index 94ff24aca..098c19c59 100644 --- a/backend/src/hatchling/build.py +++ b/backend/src/hatchling/build.py @@ -63,10 +63,10 @@ def build_wheel( metadata = ProjectMetadata(root_dir, plugin_manager) variant_props, variant_label = None, None - if "variant-property" in config_settings: - variant_props = config_settings["variant-property"] - if "variant-label" in config_settings: - variant_label = config_settings["variant-label"] + if 'variant-property' in config_settings: + variant_props = config_settings['variant-property'] + if 'variant-label' in config_settings: + variant_label = config_settings['variant-label'] builder = WheelBuilder( root_dir, plugin_manager=plugin_manager, 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/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 index 69c5e5088..50da892ad 100644 --- a/backend/src/hatchling/builders/variant_constants.py +++ b/backend/src/hatchling/builders/variant_constants.py @@ -7,33 +7,33 @@ from typing import TypedDict VARIANT_LABEL_LENGTH = 16 -NULL_VARIANT_LABEL = "null" -CONFIG_FILENAME = "variants.toml" -VARIANT_DIST_INFO_FILENAME = "variant.json" +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" +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" +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" +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_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_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""" @@ -55,7 +55,7 @@ re.VERBOSE, ) -VALIDATION_PROVIDER_ENABLE_IF_REGEX = re.compile(r"[\S ]+") +VALIDATION_PROVIDER_ENABLE_IF_REGEX = re.compile(r'[\S ]+') VALIDATION_PROVIDER_PLUGIN_API_REGEX = re.compile( r""" (?P [\w.]+) @@ -65,31 +65,29 @@ """, re.VERBOSE, ) -VALIDATION_PROVIDER_REQUIRES_REGEX = re.compile(r"[\S ]+") +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_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" ", + 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, ) @@ -111,13 +109,13 @@ class PriorityJsonDict(TypedDict, total=False): ProviderPluginJsonDict = TypedDict( - "ProviderPluginJsonDict", + 'ProviderPluginJsonDict', { - "plugin-api": str, - "requires": list[str], - "enable-if": str, - "optional": bool, - "plugin-use": Literal["all", "build", "none"], + 'plugin-api': str, + 'requires': list[str], + 'enable-if': str, + 'optional': bool, + 'plugin-use': Literal['all', 'build', 'none'], }, total=False, ) @@ -126,12 +124,12 @@ class PriorityJsonDict(TypedDict, total=False): VariantsJsonDict = TypedDict( - "VariantsJsonDict", + 'VariantsJsonDict', { - "$schema": str, - "default-priorities": PriorityJsonDict, - "providers": dict[str, ProviderPluginJsonDict], - "variants": dict[str, VariantInfoJsonDict], + '$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 0e7a03590..205f8453d 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -310,23 +310,24 @@ def core_metadata_constructor(self) -> Callable[..., str]: @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: {} + VARIANTS_JSON_VARIANT_DATA_KEY: {}, } # ==================== VARIANT_INFO_DEFAULT_PRIO_KEY ==================== # - if (ns_prio := variant_config.default_priorities["namespace"]): + 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["feature"]): + if feat_prio := variant_config.default_priorities['feature']: data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_FEATURE_KEY] = feat_prio - if (prop_prio := variant_config.default_priorities["property"]): + if prop_prio := variant_config.default_priorities['property']: data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_PROPERTY_KEY] = prop_prio if not data[VARIANT_INFO_DEFAULT_PRIO_KEY]: @@ -353,12 +354,10 @@ def constructor(variant_config: VariantConfig) -> str: # ==================== VARIANTS_JSON_VARIANT_DATA_KEY ==================== # variant_data = defaultdict(lambda: defaultdict(set)) - for vprop_str in (variant_config.properties or []): + 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}`" - ) + 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') @@ -374,9 +373,8 @@ def preprocess(data): return list(data) return data - return json.dumps( - preprocess(data), indent=4, sort_keys=True, ensure_ascii=False - ) + return json.dumps(preprocess(data), indent=4, sort_keys=True, ensure_ascii=False) + self.__variants_json_constructor = constructor return self.__variants_json_constructor @@ -557,7 +555,6 @@ def __init__( variant_props: list[str] | None = None, variant_label: str | None = None, ): - if metadata is not None: metadata.variant_config = VariantConfig.from_dict( data=metadata.variant_config_data, @@ -610,9 +607,9 @@ def build_standard(self, directory: str, **build_data: Any) -> str: archive.write_metadata('RECORD', records.construct()) if self.metadata.variant_label is not None: - wheel_name = f"{self.artifact_project_id}-{build_data['tag']}-{self.metadata.variant_label}.whl" + 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" + wheel_name = f'{self.artifact_project_id}-{build_data["tag"]}.whl' target = os.path.join(directory, wheel_name) replace_file(archive.path, target) @@ -702,7 +699,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) @@ -719,7 +716,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)): @@ -731,7 +728,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) diff --git a/backend/src/hatchling/cli/build/__init__.py b/backend/src/hatchling/cli/build/__init__.py index db612e32f..cb6cc5b6b 100644 --- a/backend/src/hatchling/cli/build/__init__.py +++ b/backend/src/hatchling/cli/build/__init__.py @@ -55,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() diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 6b2feb977..22c937488 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -50,7 +50,7 @@ class VariantProviderConfig: 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()} + data = {key.replace('-', '_'): value for key, value in data.items()} # Create an instance of VariantProviderConfig return cls(**data) @@ -58,7 +58,7 @@ def from_dict(cls, data: dict): def validate(self): """Validates the VariantProviderConfig instance.""" if not self.requires: - raise ValueError("Requires list cannot be empty") + raise ValueError('Requires list cannot be empty') @dataclass @@ -69,50 +69,45 @@ class VariantConfig: providers: dict[str, VariantProviderConfig] @classmethod - def from_dict(cls, data: dict, vprops: list[str] | None, - variant_label: str | None): + 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 vprops is None: - data["vlabel"] = None - data["properties"] = None - + data['vlabel'] = None + data['properties'] = None elif len(vprops) == 0: - data["vlabel"] = "null" - data["properties"] = [] + data['vlabel'] = 'null' + data['properties'] = [] else: # Normalizing - _vprops = [ - [el.strip() for el in vprop.split("::")] - for vprop in vprops - ] + _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}") + raise ValueError(f'Invalid variant property: {vprop}') - data["properties"] = [" :: ".join(vprop) for vprop in sorted(_vprops)] + 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] + 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 + 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()} + data = {key.replace('-', '_'): value for key, value in data.items()} # Convert providers to VariantProviderConfig instances - data["providers"] = { + data['providers'] = { provider: VariantProviderConfig.from_dict(provider_data) - for provider, provider_data in data["providers"].items() + for provider, provider_data in data['providers'].items() } # Create an instance of VariantConfig @@ -120,11 +115,9 @@ def from_dict(cls, data: dict, vprops: list[str] | None, def validate(self): """Validates the VariantConfig instance.""" - for namespace in self.default_priorities["namespace"]: + 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" - ) + raise ValueError(f"Namespace '{namespace}' is not defined in the variant providers") for provider_cfg in self.providers.values(): provider_cfg.validate() 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: From c93cd7e3835c6b3a430e6a5b7559e3ea2b8bd182 Mon Sep 17 00:00:00 2001 From: konstin Date: Thu, 2 Oct 2025 16:37:23 +0200 Subject: [PATCH 13/18] Allow non-variant wheels to be built --- backend/src/hatchling/builders/wheel.py | 15 ++++++++------- backend/src/hatchling/metadata/core.py | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index 205f8453d..d012d029a 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -556,13 +556,14 @@ def __init__( variant_label: str | None = None, ): if metadata 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 + 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, diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 22c937488..9c7b7358a 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -107,7 +107,7 @@ def from_dict(cls, data: dict, vprops: list[str] | None, variant_label: str | No # Convert providers to VariantProviderConfig instances data['providers'] = { provider: VariantProviderConfig.from_dict(provider_data) - for provider, provider_data in data['providers'].items() + for provider, provider_data in data.get('providers', {}).items() } # Create an instance of VariantConfig From 99488c9e0dbae42615bc62e6288f2acbf232d18a Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 7 Oct 2025 14:38:26 +0200 Subject: [PATCH 14/18] Only namespace in a mandatory default priority --- backend/src/hatchling/builders/wheel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index d012d029a..9f5e2ef53 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -324,10 +324,10 @@ def constructor(variant_config: VariantConfig) -> str: 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['feature']: + 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['property']: + 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]: From ad9d26449df189a1ed1604ae710dbae359bef14e Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 7 Oct 2025 15:10:03 +0200 Subject: [PATCH 15/18] Better error message for missing variant information --- backend/src/hatchling/metadata/core.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 9c7b7358a..406259754 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -73,6 +73,9 @@ def from_dict(cls, data: dict, vprops: list[str] | None, variant_label: str | No """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 From c7f4340f3ba156caebcc16ded0c69e0850f0def0 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 8 Oct 2025 15:14:20 +0200 Subject: [PATCH 16/18] Support `plugin-use` --- backend/src/hatchling/builders/wheel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/hatchling/builders/wheel.py b/backend/src/hatchling/builders/wheel.py index 9f5e2ef53..6e675f7bd 100644 --- a/backend/src/hatchling/builders/wheel.py +++ b/backend/src/hatchling/builders/wheel.py @@ -48,6 +48,7 @@ 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 @@ -349,6 +350,9 @@ def constructor(variant_config: VariantConfig) -> str: 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 ==================== # From 61469e5da5500fcf84e0d2a21feb25765073065f Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 8 Oct 2025 15:22:44 +0200 Subject: [PATCH 17/18] Support `plugin-use` in VariantProviderConfig --- backend/src/hatchling/metadata/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/hatchling/metadata/core.py b/backend/src/hatchling/metadata/core.py index 406259754..3b53009c5 100644 --- a/backend/src/hatchling/metadata/core.py +++ b/backend/src/hatchling/metadata/core.py @@ -6,7 +6,7 @@ from contextlib import suppress from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import TYPE_CHECKING, Any, Generic, cast, Literal from hatchling.builders.variant_constants import VARIANT_LABEL_LENGTH from hatchling.metadata.utils import ( @@ -43,6 +43,7 @@ def load_toml(path: str) -> dict[str, Any]: 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 From e349fd485d8aec4d380c2e37a2ae68aa7bbe5e21 Mon Sep 17 00:00:00 2001 From: konstin Date: Fri, 10 Oct 2025 13:45:47 +0200 Subject: [PATCH 18/18] Support `config_settings` being `None` --- backend/src/hatchling/build.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/src/hatchling/build.py b/backend/src/hatchling/build.py index 098c19c59..3813f5ec5 100644 --- a/backend/src/hatchling/build.py +++ b/backend/src/hatchling/build.py @@ -62,11 +62,14 @@ def build_wheel( plugin_manager = PluginManager() metadata = ProjectMetadata(root_dir, plugin_manager) - variant_props, variant_label = None, None - if 'variant-property' in config_settings: + if config_settings and 'variant-property' in config_settings: variant_props = config_settings['variant-property'] - if 'variant-label' in config_settings: + 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,