Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/hatchling/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -116,6 +118,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(
Expand Down
2 changes: 2 additions & 0 deletions backend/src/hatchling/builders/plugin/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
137 changes: 137 additions & 0 deletions backend/src/hatchling/builders/variant_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# 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/"
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<namespace>{VALIDATION_NAMESPACE_REGEX.pattern})
\s* :: \s*
(?P<feature>{VALIDATION_FEATURE_NAME_REGEX.pattern})
""",
re.VERBOSE,
)

VALIDATION_PROPERTY_REGEX = re.compile(
rf"""
(?P<namespace>{VALIDATION_NAMESPACE_REGEX.pattern})
\s* :: \s*
(?P<feature>{VALIDATION_FEATURE_NAME_REGEX.pattern})
\s* :: \s*
(?P<value>{VALIDATION_VALUE_REGEX.pattern})
""",
re.VERBOSE,
)

VALIDATION_PROVIDER_ENABLE_IF_REGEX = re.compile(r"[\S ]+")
VALIDATION_PROVIDER_PLUGIN_API_REGEX = re.compile(
r"""
(?P<module> [\w.]+)
(?: \s* : \s*
(?P<attr> [\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<base_wheel_name> " # <base_wheel_name> group (without variant)
r" (?P<namever> " # "namever" group contains <name>-<ver>
r" (?P<name>[^\s-]+?) " # <name>
r" - (?P<ver>[^\s-]*?) " # "-" <ver>
r" ) " # close "namever" group
r" (?: - (?P<build>\d[^-]*?) )? " # optional "-" <build>
r" - (?P<pyver>[^\s-]+?) " # "-" <pyver> tag
r" - (?P<abi>[^\s-]+?) " # "-" <abi> tag
r" - (?P<plat>[^\s-]+?) " # "-" <plat> tag
r") " # end of <base_wheel_name> group
r"(?: - (?P<variant_label> " # optional <variant_label>
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,
)
138 changes: 137 additions & 1 deletion backend/src/hatchling/builders/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +33,23 @@
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.core import VariantConfig
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors

if TYPE_CHECKING:
Expand Down Expand Up @@ -188,6 +212,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
Expand Down Expand Up @@ -282,6 +307,79 @@ 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["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_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.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:
Expand Down Expand Up @@ -449,6 +547,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:
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}

Expand Down Expand Up @@ -483,7 +609,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)
Expand Down Expand Up @@ -710,6 +840,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:
Expand Down
Loading