Skip to content

Commit c5bbb16

Browse files
committed
[WIP] Add variant support
1 parent 4ebce0e commit c5bbb16

File tree

7 files changed

+432
-4
lines changed

7 files changed

+432
-4
lines changed

backend/src/hatchling/build.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import os
44
from typing import Any
55

6+
from hatchling.builders.variant_constants import VARIANT_DIST_INFO_FILENAME
7+
68
__all__ = [
79
'build_editable',
810
'build_sdist',
@@ -116,6 +118,9 @@ def prepare_metadata_for_build_wheel(
116118
with open(os.path.join(directory, 'METADATA'), 'w', encoding='utf-8') as f:
117119
f.write(builder.config.core_metadata_constructor(builder.metadata))
118120

121+
with open(os.path.join(directory, VARIANT_DIST_INFO_FILENAME), 'w', encoding='utf-8') as f:
122+
f.write(builder.config.variants_json_constructor(builder.metadata))
123+
119124
return os.path.basename(directory)
120125

121126
def prepare_metadata_for_build_editable(
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# This file is copied from variantlib/variantlib/constants.py
2+
# Do not edit this file directly, instead edit variantlib/variantlib/constants.py
3+
4+
from __future__ import annotations
5+
6+
import re
7+
from typing import Literal, TypedDict
8+
9+
VARIANT_HASH_LEN = 8
10+
CONFIG_FILENAME = "variants.toml"
11+
VARIANT_DIST_INFO_FILENAME = "variant.json"
12+
13+
# Common variant info keys (used in pyproject.toml and variants.json)
14+
VARIANT_INFO_DEFAULT_PRIO_KEY: Literal["default-priorities"] = "default-priorities"
15+
VARIANT_INFO_FEATURE_KEY: Literal["feature"] = "feature"
16+
VARIANT_INFO_NAMESPACE_KEY: Literal["namespace"] = "namespace"
17+
VARIANT_INFO_PROPERTY_KEY: Literal["property"] = "property"
18+
VARIANT_INFO_PROVIDER_DATA_KEY: Literal["providers"] = "providers"
19+
VARIANT_INFO_PROVIDER_ENABLE_IF_KEY: Literal["enable-if"] = "enable-if"
20+
VARIANT_INFO_PROVIDER_OPTIONAL_KEY: Literal["optional"] = "optional"
21+
VARIANT_INFO_PROVIDER_PLUGIN_API_KEY: Literal["plugin-api"] = "plugin-api"
22+
VARIANT_INFO_PROVIDER_REQUIRES_KEY: Literal["requires"] = "requires"
23+
24+
PYPROJECT_TOML_TOP_KEY = "variant"
25+
26+
VARIANTS_JSON_SCHEMA_KEY: Literal["$schema"] = "$schema"
27+
VARIANTS_JSON_SCHEMA_URL = "https://variants-schema.wheelnext.dev/"
28+
VARIANTS_JSON_VARIANT_DATA_KEY: Literal["variants"] = "variants"
29+
30+
VALIDATION_VARIANT_HASH_REGEX = re.compile(rf"[0-9a-f]{{{VARIANT_HASH_LEN}}}")
31+
32+
VALIDATION_NAMESPACE_REGEX = re.compile(r"[a-z0-9_]+")
33+
VALIDATION_FEATURE_NAME_REGEX = re.compile(r"[a-z0-9_]+")
34+
35+
# For `Property value` there is two regexes:
36+
# 1. `VALIDATION_VALUE_VSPEC_REGEX` - if `packaging.specifiers.SpecifierSet` is used
37+
# Note: for clarity - only "full version" are allowed
38+
# i.e. so no "a|b|alpha|beta|rc|post|etc." versions
39+
VALIDATION_VALUE_VSPEC_REGEX = re.compile(r"[0-9_.,!>~<=]+")
40+
# 2. `VALIDATION_VALUE_STR_REGEX` - if string matching is used
41+
VALIDATION_VALUE_STR_REGEX = re.compile(r"[a-z0-9_.]+")
42+
VALIDATION_VALUE_REGEX = re.compile(
43+
rf"{VALIDATION_VALUE_VSPEC_REGEX.pattern}|{VALIDATION_VALUE_STR_REGEX.pattern}"
44+
)
45+
46+
VALIDATION_FEATURE_REGEX = re.compile(
47+
rf"""
48+
(?P<namespace>{VALIDATION_NAMESPACE_REGEX.pattern})
49+
\s* :: \s*
50+
(?P<feature>{VALIDATION_FEATURE_NAME_REGEX.pattern})
51+
""",
52+
re.VERBOSE,
53+
)
54+
55+
VALIDATION_PROPERTY_REGEX = re.compile(
56+
rf"""
57+
(?P<namespace>{VALIDATION_NAMESPACE_REGEX.pattern})
58+
\s* :: \s*
59+
(?P<feature>{VALIDATION_FEATURE_NAME_REGEX.pattern})
60+
\s* :: \s*
61+
(?P<value>{VALIDATION_VALUE_REGEX.pattern})
62+
""",
63+
re.VERBOSE,
64+
)
65+
66+
VALIDATION_PROVIDER_ENABLE_IF_REGEX = re.compile(r"[\S ]+")
67+
VALIDATION_PROVIDER_PLUGIN_API_REGEX = re.compile(
68+
r"""
69+
(?P<module> [\w.]+)
70+
(?: \s* : \s*
71+
(?P<attr> [\w.]+)
72+
)?
73+
""",
74+
re.VERBOSE,
75+
)
76+
VALIDATION_PROVIDER_REQUIRES_REGEX = re.compile(r"[\S ]+")
77+
78+
79+
# VALIDATION_PYTHON_PACKAGE_NAME_REGEX = re.compile(r"[^\s-]+?")
80+
# Per PEP 508: https://peps.python.org/pep-0508/#names
81+
VALIDATION_PYTHON_PACKAGE_NAME_REGEX = re.compile(
82+
r"[A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9]", re.IGNORECASE
83+
)
84+
VALIDATION_WHEEL_NAME_REGEX = re.compile(
85+
r"(?P<base_wheel_name> " # <base_wheel_name> group (without variant)
86+
r" (?P<namever> " # "namever" group contains <name>-<ver>
87+
r" (?P<name>[^\s-]+?) " # <name>
88+
r" - (?P<ver>[^\s-]*?) " # "-" <ver>
89+
r" ) " # close "namever" group
90+
r" ( - (?P<build>\d[^-]*?) )? " # optional "-" <build>
91+
r" - (?P<pyver>[^\s-]+?) " # "-" <pyver> tag
92+
r" - (?P<abi>[^\s-]+?) " # "-" <abi> tag
93+
r" - (?P<plat>[^\s-]+?) " # "-" <plat> tag
94+
r") " # end of <base_wheel_name> group
95+
r"( - (?P<variant_hash> " # optional <variant_hash>
96+
rf" [0-9a-f]{{{VARIANT_HASH_LEN}}} "
97+
r" ) "
98+
r")? "
99+
r"\.whl " # ".whl" suffix
100+
r" ",
101+
re.VERBOSE,
102+
)
103+
104+
105+
# ======================== Json TypedDict for the JSON format ======================== #
106+
107+
# NOTE: Unfortunately, it is not possible as of today to use variables in the definition
108+
# of TypedDict. Similarly also impossible to use the normal "class format" if a
109+
# key uses the characted `-`.
110+
#
111+
# For all these reasons and easier future maintenance - these classes have been
112+
# added to this file instead of a more "format definition" file.
113+
114+
115+
class PriorityJsonDict(TypedDict, total=False):
116+
namespace: list[str]
117+
feature: dict[str, list[str]]
118+
property: dict[str, dict[str, list[str]]]
119+
120+
121+
ProviderPluginJsonDict = TypedDict(
122+
"ProviderPluginJsonDict",
123+
{
124+
"plugin-api": str,
125+
"requires": list[str],
126+
"enable-if": str,
127+
},
128+
total=False,
129+
)
130+
131+
VariantInfoJsonDict = dict[str, dict[str, list[str]]]
132+
133+
134+
VariantsJsonDict = TypedDict(
135+
"VariantsJsonDict",
136+
{
137+
"$schema": str,
138+
"default-priorities": PriorityJsonDict,
139+
"providers": dict[str, ProviderPluginJsonDict],
140+
"variants": dict[str, VariantInfoJsonDict],
141+
},
142+
total=False,
143+
)

backend/src/hatchling/builders/wheel.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22

33
import csv
44
import hashlib
5+
import json
56
import os
67
import stat
78
import sys
89
import tempfile
910
import zipfile
11+
from collections import defaultdict
1012
from functools import cached_property
1113
from io import StringIO
1214
from typing import TYPE_CHECKING, Any, Callable, Iterable, NamedTuple, Sequence, Tuple, cast
1315

16+
if TYPE_CHECKING:
17+
from hatchling.metadata.core import ProjectMetadata
18+
1419
from hatchling.__about__ import __version__
1520
from hatchling.builders.config import BuilderConfig
1621
from hatchling.builders.constants import EDITABLES_REQUIREMENT
@@ -26,6 +31,22 @@
2631
replace_file,
2732
set_zip_info_mode,
2833
)
34+
from hatchling.builders.variant_constants import (
35+
VALIDATION_PROPERTY_REGEX,
36+
VARIANT_DIST_INFO_FILENAME,
37+
VARIANT_INFO_DEFAULT_PRIO_KEY,
38+
VARIANT_INFO_FEATURE_KEY,
39+
VARIANT_INFO_NAMESPACE_KEY,
40+
VARIANT_INFO_PROPERTY_KEY,
41+
VARIANT_INFO_PROVIDER_DATA_KEY,
42+
VARIANT_INFO_PROVIDER_ENABLE_IF_KEY,
43+
VARIANT_INFO_PROVIDER_OPTIONAL_KEY,
44+
VARIANT_INFO_PROVIDER_PLUGIN_API_KEY,
45+
VARIANT_INFO_PROVIDER_REQUIRES_KEY,
46+
VARIANTS_JSON_SCHEMA_KEY,
47+
VARIANTS_JSON_SCHEMA_URL,
48+
VARIANTS_JSON_VARIANT_DATA_KEY,
49+
)
2950
from hatchling.metadata.spec import DEFAULT_METADATA_VERSION, get_core_metadata_constructors
3051

3152
if TYPE_CHECKING:
@@ -188,6 +209,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
188209
super().__init__(*args, **kwargs)
189210

190211
self.__core_metadata_constructor: Callable[..., str] | None = None
212+
self.__variants_json_constructor: Callable[..., str] | None = None
191213
self.__shared_data: dict[str, str] | None = None
192214
self.__shared_scripts: dict[str, str] | None = None
193215
self.__extra_metadata: dict[str, str] | None = None
@@ -282,6 +304,80 @@ def core_metadata_constructor(self) -> Callable[..., str]:
282304

283305
return self.__core_metadata_constructor
284306

307+
@property
308+
def variants_json_constructor(self) -> Callable[..., str]:
309+
if self.__variants_json_constructor is None:
310+
def constructor(metadata: ProjectMetadata) -> str:
311+
if metadata.variant_hash is not None:
312+
data = {
313+
VARIANTS_JSON_SCHEMA_KEY: VARIANTS_JSON_SCHEMA_URL,
314+
VARIANT_INFO_DEFAULT_PRIO_KEY: {},
315+
VARIANT_INFO_PROVIDER_DATA_KEY: {},
316+
VARIANTS_JSON_VARIANT_DATA_KEY: {}
317+
}
318+
319+
# ==================== VARIANT_INFO_DEFAULT_PRIO_KEY ==================== #
320+
321+
if (ns_prio := metadata.variant_default_priorities["namespace"]):
322+
data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_NAMESPACE_KEY] = ns_prio
323+
324+
if (feat_prio := metadata.variant_default_priorities["feature"]):
325+
data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_FEATURE_KEY] = feat_prio
326+
327+
if (prop_prio := metadata.variant_default_priorities["property"]):
328+
data[VARIANT_INFO_DEFAULT_PRIO_KEY][VARIANT_INFO_PROPERTY_KEY] = prop_prio
329+
330+
if not data[VARIANT_INFO_DEFAULT_PRIO_KEY]:
331+
# If no default priorities are set, remove the key
332+
del data[VARIANT_INFO_DEFAULT_PRIO_KEY]
333+
334+
# ==================== VARIANT_INFO_PROVIDER_DATA_KEY ==================== #
335+
336+
variant_providers = defaultdict(dict)
337+
for ns, plugin_conf in metadata.variant_plugins.items():
338+
variant_providers[ns][VARIANT_INFO_PROVIDER_REQUIRES_KEY] = plugin_conf.get("requires", [])
339+
340+
if (enable_if := plugin_conf.get("enable_if", None)) is not None:
341+
variant_providers[ns][VARIANT_INFO_PROVIDER_ENABLE_IF_KEY] = enable_if
342+
343+
if plugin_conf.get("optional", False):
344+
variant_providers[ns][VARIANT_INFO_PROVIDER_OPTIONAL_KEY] = True
345+
346+
if (plugin_api := plugin_conf.get("plugin_api", None)) is not None:
347+
variant_providers[ns][VARIANT_INFO_PROVIDER_PLUGIN_API_KEY] = plugin_api
348+
349+
data[VARIANT_INFO_PROVIDER_DATA_KEY] = variant_providers
350+
351+
# ==================== VARIANTS_JSON_VARIANT_DATA_KEY ==================== #
352+
353+
variant_data = defaultdict(lambda: defaultdict(set))
354+
for vprop_str in metadata.variant_properties:
355+
match = VALIDATION_PROPERTY_REGEX.match(vprop_str)
356+
if not match:
357+
raise ValueError(
358+
f"Invalid variant property '{vprop_str}' in variant {metadata.variant_hash}"
359+
)
360+
namespace = match.group('namespace')
361+
feature = match.group('feature')
362+
value = match.group('value')
363+
variant_data[namespace][feature].add(value)
364+
data[VARIANTS_JSON_VARIANT_DATA_KEY][metadata.variant_hash] = variant_data
365+
366+
def preprocess(data):
367+
"""Preprocess the data to ensure it is JSON serializable."""
368+
if isinstance(data, (defaultdict, dict)):
369+
return {k: preprocess(v) for k, v in data.items()}
370+
if isinstance(data, set):
371+
return list(data)
372+
return data
373+
374+
return json.dumps(
375+
preprocess(data), indent=4, sort_keys=True, ensure_ascii=False
376+
)
377+
return ''
378+
self.__variants_json_constructor = constructor
379+
return self.__variants_json_constructor
380+
285381
@property
286382
def shared_data(self) -> dict[str, str]:
287383
if self.__shared_data is None:
@@ -710,6 +806,11 @@ def write_project_metadata(
710806
'METADATA', self.config.core_metadata_constructor(self.metadata, extra_dependencies=extra_dependencies)
711807
)
712808
records.write(record)
809+
record = archive.write_metadata(
810+
VARIANT_DIST_INFO_FILENAME,
811+
self.config.variants_json_constructor(self.metadata),
812+
)
813+
records.write(record)
713814

714815
def add_licenses(self, archive: WheelArchive, records: RecordFile) -> None:
715816
for relative_path in self.metadata.core.license_files:

backend/src/hatchling/cli/build/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ def build_impl(
1515
clean_hooks_after: bool,
1616
clean_only: bool,
1717
show_dynamic_deps: bool,
18+
variant_props: list[str],
19+
variant_null: bool,
20+
variant_label: str | None,
1821
) -> None:
22+
print(f"{__file__}::build_impl")
23+
print(f'{variant_props=}')
24+
print(f'{variant_null=}')
25+
print(f'{variant_label=}')
26+
1927
import os
2028

2129
from hatchling.bridge.app import Application
@@ -72,7 +80,9 @@ def build_impl(
7280
if not (clean_only or show_dynamic_deps) and len(target_data) > 1:
7381
app.display_mini_header(target_name)
7482

83+
print(f"{metadata=}")
7584
builder = builder_class(root, plugin_manager=plugin_manager, metadata=metadata, app=app.get_safe_application())
85+
7686
if show_dynamic_deps:
7787
for dependency in builder.config.dynamic_dependencies:
7888
dynamic_dependencies[dependency] = None
@@ -116,4 +126,28 @@ def build_command(subparsers: argparse._SubParsersAction, defaults: Any) -> None
116126
parser.add_argument('--clean-only', dest='clean_only', action='store_true')
117127
parser.add_argument('--show-dynamic-deps', dest='show_dynamic_deps', action='store_true')
118128
parser.add_argument('--app', dest='called_by_app', action='store_true', help=argparse.SUPPRESS)
129+
130+
group = parser.add_mutually_exclusive_group(required=False)
131+
group.add_argument(
132+
'-p',
133+
'--variant-property',
134+
dest='variant_props',
135+
type=str,
136+
action='extend',
137+
nargs='+',
138+
help=('Variant Properties to add to the Wheel Variant, can be repeated as many times as needed'),
139+
default=None,
140+
)
141+
group.add_argument(
142+
'--null-variant',
143+
dest='variant_null',
144+
action='store_true',
145+
help='Make the variant a `null variant` - no variant property.',
146+
)
147+
parser.add_argument(
148+
'--variant-label',
149+
dest='variant_label',
150+
help='Use a custom variant label (the default is variant hash)',
151+
)
152+
119153
parser.set_defaults(func=build_impl)

0 commit comments

Comments
 (0)