Skip to content

Commit a54471a

Browse files
[ABI Dependency] Best-Effort support for pip (#123)
* [ABI Dependency] Best-Effort support for `pip` * Support Major / Minor / Micro level ABI Compatibility * Update variantlib/resolver/lib.py Co-authored-by: Michał Górny <[email protected]> * Fix PR comments * Move abi_dependency injection into a dedicated function Signed-off-by: Michał Górny <[email protected]> * Add a test for the current implementation Signed-off-by: Michał Górny <[email protected]> * Add tests for envvar overrides Signed-off-by: Michał Górny <[email protected]> * Make envvar override installed package versions explicitly Signed-off-by: Michał Górny <[email protected]> * Import `importlib.metadata` using full name Signed-off-by: Michał Górny <[email protected]> * Normalize package names earlier Signed-off-by: Michał Górny <[email protected]> --------- Signed-off-by: Michał Górny <[email protected]> Co-authored-by: Michał Górny <[email protected]>
1 parent 0ff2a38 commit a54471a

File tree

3 files changed

+225
-1
lines changed

3 files changed

+225
-1
lines changed
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING
5+
6+
import pytest
7+
from variantlib.constants import VARIANT_ABI_DEPENDENCY_NAMESPACE
8+
from variantlib.models.variant import VariantProperty
9+
from variantlib.resolver.lib import inject_abi_dependency
10+
11+
if TYPE_CHECKING:
12+
import pytest_mock
13+
from variantlib.protocols import VariantNamespace
14+
15+
16+
@dataclass
17+
class MockedDistribution:
18+
name: str
19+
version: str
20+
21+
22+
def test_inject_abi_dependency(
23+
monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture
24+
) -> None:
25+
monkeypatch.delenv("VARIANT_ABI_DEPENDENCY", raising=False)
26+
27+
namespace_priorities = ["foo"]
28+
supported_vprops = [
29+
VariantProperty("foo", "bar", "baz"),
30+
]
31+
32+
mocker.patch("importlib.metadata.distributions").return_value = [
33+
MockedDistribution("a", "4"),
34+
MockedDistribution("b", "4.3b1"),
35+
MockedDistribution("c.ns", "7.2.3.post4"),
36+
MockedDistribution("d-foo", "1.2.3.4"),
37+
]
38+
inject_abi_dependency(supported_vprops, namespace_priorities)
39+
40+
assert namespace_priorities == ["foo", VARIANT_ABI_DEPENDENCY_NAMESPACE]
41+
assert supported_vprops == [
42+
VariantProperty("foo", "bar", "baz"),
43+
VariantProperty("abi_dependency", "a", "4"),
44+
VariantProperty("abi_dependency", "a", "4.0"),
45+
VariantProperty("abi_dependency", "a", "4.0.0"),
46+
VariantProperty("abi_dependency", "b", "4"),
47+
VariantProperty("abi_dependency", "b", "4.3"),
48+
VariantProperty("abi_dependency", "b", "4.3.0"),
49+
VariantProperty("abi_dependency", "c_ns", "7"),
50+
VariantProperty("abi_dependency", "c_ns", "7.2"),
51+
VariantProperty("abi_dependency", "c_ns", "7.2.3"),
52+
VariantProperty("abi_dependency", "d_foo", "1"),
53+
VariantProperty("abi_dependency", "d_foo", "1.2"),
54+
VariantProperty("abi_dependency", "d_foo", "1.2.3"),
55+
]
56+
57+
58+
@pytest.mark.parametrize(
59+
"env_value",
60+
[
61+
"",
62+
"c_ns==7.8.9b1",
63+
"c.ns==7.8.9b1",
64+
"d==4.9.4,c.ns==7.8.9",
65+
"a_foo==1.2.79",
66+
"a-foo==1.2.79,d==4.9.4",
67+
# invalid components should be ignored (with a warning)
68+
"no-version",
69+
"a.foo==1.2.79,no-version",
70+
"z>=1.2.3",
71+
],
72+
)
73+
def test_inject_abi_dependency_envvar(
74+
monkeypatch: pytest.MonkeyPatch,
75+
mocker: pytest_mock.MockerFixture,
76+
env_value: str,
77+
) -> None:
78+
monkeypatch.setenv("VARIANT_ABI_DEPENDENCY", env_value)
79+
80+
namespace_priorities: list[VariantNamespace] = []
81+
supported_vprops: list[VariantProperty] = []
82+
83+
mocker.patch("importlib.metadata.distributions").return_value = [
84+
MockedDistribution("a-foo", "1.2.3"),
85+
MockedDistribution("b", "4.7.9"),
86+
]
87+
inject_abi_dependency(supported_vprops, namespace_priorities)
88+
89+
expected = {
90+
VariantProperty("abi_dependency", "b", "4"),
91+
VariantProperty("abi_dependency", "b", "4.7"),
92+
VariantProperty("abi_dependency", "b", "4.7.9"),
93+
}
94+
if "a" not in env_value:
95+
expected |= {
96+
VariantProperty("abi_dependency", "a_foo", "1"),
97+
VariantProperty("abi_dependency", "a_foo", "1.2"),
98+
VariantProperty("abi_dependency", "a_foo", "1.2.3"),
99+
}
100+
else:
101+
expected |= {
102+
VariantProperty("abi_dependency", "a_foo", "1"),
103+
VariantProperty("abi_dependency", "a_foo", "1.2"),
104+
VariantProperty("abi_dependency", "a_foo", "1.2.79"),
105+
}
106+
if "c" in env_value:
107+
expected |= {
108+
VariantProperty("abi_dependency", "c_ns", "7"),
109+
VariantProperty("abi_dependency", "c_ns", "7.8"),
110+
VariantProperty("abi_dependency", "c_ns", "7.8.9"),
111+
}
112+
if "d" in env_value:
113+
expected |= {
114+
VariantProperty("abi_dependency", "d", "4"),
115+
VariantProperty("abi_dependency", "d", "4.9"),
116+
VariantProperty("abi_dependency", "d", "4.9.4"),
117+
}
118+
119+
assert namespace_priorities == [VARIANT_ABI_DEPENDENCY_NAMESPACE]
120+
assert set(supported_vprops) == expected

variantlib/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@
6666
)
6767
VALIDATION_PROVIDER_REQUIRES_REGEX = re.compile(r"[\S ]+")
6868

69+
VARIANT_ABI_DEPENDENCY_NAMESPACE: Literal["abi_dependency"] = "abi_dependency"
70+
6971

7072
# VALIDATION_PYTHON_PACKAGE_NAME_REGEX = re.compile(r"[^\s-]+?")
7173
# Per PEP 508: https://peps.python.org/pep-0508/#names

variantlib/resolver/lib.py

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from __future__ import annotations
22

3+
import importlib.metadata
4+
import logging
5+
import os
36
from typing import TYPE_CHECKING
47

8+
from packaging.utils import canonicalize_name
9+
from packaging.version import Version
10+
11+
from variantlib.constants import VARIANT_ABI_DEPENDENCY_NAMESPACE
512
from variantlib.models.variant import VariantDescription
613
from variantlib.models.variant import VariantFeature
714
from variantlib.models.variant import VariantProperty
@@ -20,6 +27,20 @@
2027
from variantlib.protocols import VariantFeatureValue
2128
from variantlib.protocols import VariantNamespace
2229

30+
logger = logging.getLogger(__name__)
31+
32+
33+
def _normalize_package_name(name: str) -> str:
34+
# VALIDATION_FEATURE_NAME_REGEX does not accepts "-"
35+
return canonicalize_name(name).replace("-", "_")
36+
37+
38+
def _generate_version_matches(version: str) -> Generator[str]:
39+
vspec = Version(version)
40+
yield f"{vspec.major}"
41+
yield f"{vspec.major}.{vspec.minor}"
42+
yield f"{vspec.major}.{vspec.minor}.{vspec.micro}"
43+
2344

2445
def filter_variants(
2546
vdescs: list[VariantDescription],
@@ -100,6 +121,61 @@ def filter_variants(
100121
yield from result
101122

102123

124+
def inject_abi_dependency(
125+
supported_vprops: list[VariantProperty],
126+
namespace_priorities: list[VariantNamespace],
127+
) -> None:
128+
"""Inject supported vairants for the abi_dependency namespace"""
129+
130+
# 1. Automatically populate from the current python environment
131+
packages = {
132+
_normalize_package_name(dist.name): dist.version
133+
for dist in importlib.metadata.distributions()
134+
}
135+
136+
# 2. Manually fed from environment variable
137+
# Env Var Format: `VARIANT_ABI_DEPENDENCY=packageA==1.2.3,...,packageZ==7.8.9`
138+
if variant_abi_deps_env := os.environ.get("VARIANT_ABI_DEPENDENCY"):
139+
for pkg_spec in variant_abi_deps_env.split(","):
140+
try:
141+
pkg_name, pkg_version = pkg_spec.split("==", maxsplit=1)
142+
except ValueError:
143+
logger.warning(
144+
"`VARIANT_ABI_DEPENDENCY` received an invalid value "
145+
"`%(pkg_spec)s`. It will be ignored.\n"
146+
"Expected format: `packageA==1.2.3,...,packageZ==7.8.9`.",
147+
{"pkg_spec": pkg_spec},
148+
)
149+
continue
150+
151+
pkg_name = _normalize_package_name(pkg_name)
152+
if (old_version := packages.get(pkg_name)) is not None:
153+
logger.warning(
154+
"`VARIANT_ABI_DEPENDENCY` overrides package version: "
155+
"`%(pkg_name)s` from `%(old_ver)s` to `%(new_ver)s`",
156+
{
157+
"pkg_name": pkg_name,
158+
"old_ver": old_version,
159+
"new_ver": pkg_version,
160+
},
161+
)
162+
163+
packages[pkg_name] = pkg_version
164+
165+
for pkg_name, pkg_version in sorted(packages.items()):
166+
supported_vprops.extend(
167+
VariantProperty(
168+
namespace=VARIANT_ABI_DEPENDENCY_NAMESPACE,
169+
feature=pkg_name,
170+
value=_ver,
171+
)
172+
for _ver in _generate_version_matches(pkg_version)
173+
)
174+
175+
# 3. Adding `VARIANT_ABI_DEPENDENCY_NAMESPACE` at the back of`namespace_priorities`
176+
namespace_priorities.append(VARIANT_ABI_DEPENDENCY_NAMESPACE)
177+
178+
103179
def sort_and_filter_supported_variants(
104180
vdescs: list[VariantDescription],
105181
supported_vprops: list[VariantProperty],
@@ -124,8 +200,28 @@ def sort_and_filter_supported_variants(
124200
:param property_priorities: Ordered list of `VariantProperty` objects.
125201
:return: Sorted and filtered list of `VariantDescription` objects.
126202
"""
203+
127204
validate_type(vdescs, list[VariantDescription])
205+
validate_type(supported_vprops, list[VariantProperty])
206+
207+
if namespace_priorities is None:
208+
namespace_priorities = []
128209

210+
# Avoiding modification in place
211+
namespace_priorities = namespace_priorities.copy()
212+
supported_vprops = supported_vprops.copy()
213+
214+
# ======================================================================= #
215+
# ABI DEPENDENCY INJECTION #
216+
# ======================================================================= #
217+
218+
inject_abi_dependency(supported_vprops, namespace_priorities)
219+
220+
# ======================================================================= #
221+
# NULL VARIANT #
222+
# ======================================================================= #
223+
224+
# Adding the `null-variant` to the list - always "compatible"
129225
if (null_variant := VariantDescription()) not in vdescs:
130226
"""Add a null variant description to the list."""
131227
# This is needed to ensure that we always consider the null variant
@@ -139,7 +235,9 @@ def sort_and_filter_supported_variants(
139235
"""No supported properties provided, return no variants."""
140236
return []
141237

142-
validate_type(supported_vprops, list[VariantProperty])
238+
# ======================================================================= #
239+
# FILTERING #
240+
# ======================================================================= #
143241

144242
# Step 1: we remove any duplicate, or unsupported `VariantDescription` on
145243
# this platform.
@@ -153,6 +251,10 @@ def sort_and_filter_supported_variants(
153251
)
154252
)
155253

254+
# ======================================================================= #
255+
# SORTING #
256+
# ======================================================================= #
257+
156258
# Step 2: we sort the supported `VariantProperty`s based on their respective
157259
# priority.
158260
sorted_supported_vprops = sort_variant_properties(

0 commit comments

Comments
 (0)