Skip to content
120 changes: 120 additions & 0 deletions tests/resolver/test_abi_dependency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

import pytest
from variantlib.constants import VARIANT_ABI_DEPENDENCY_NAMESPACE
from variantlib.models.variant import VariantProperty
from variantlib.resolver.lib import inject_abi_dependency

if TYPE_CHECKING:
import pytest_mock
from variantlib.protocols import VariantNamespace


@dataclass
class MockedDistribution:
name: str
version: str


def test_inject_abi_dependency(
monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture
) -> None:
monkeypatch.delenv("VARIANT_ABI_DEPENDENCY", raising=False)

namespace_priorities = ["foo"]
supported_vprops = [
VariantProperty("foo", "bar", "baz"),
]

mocker.patch("importlib.metadata.distributions").return_value = [
MockedDistribution("a", "4"),
MockedDistribution("b", "4.3b1"),
MockedDistribution("c.ns", "7.2.3.post4"),
MockedDistribution("d-foo", "1.2.3.4"),
]
inject_abi_dependency(supported_vprops, namespace_priorities)

assert namespace_priorities == ["foo", VARIANT_ABI_DEPENDENCY_NAMESPACE]
assert supported_vprops == [
VariantProperty("foo", "bar", "baz"),
VariantProperty("abi_dependency", "a", "4"),
VariantProperty("abi_dependency", "a", "4.0"),
VariantProperty("abi_dependency", "a", "4.0.0"),
VariantProperty("abi_dependency", "b", "4"),
VariantProperty("abi_dependency", "b", "4.3"),
VariantProperty("abi_dependency", "b", "4.3.0"),
VariantProperty("abi_dependency", "c_ns", "7"),
VariantProperty("abi_dependency", "c_ns", "7.2"),
VariantProperty("abi_dependency", "c_ns", "7.2.3"),
VariantProperty("abi_dependency", "d_foo", "1"),
VariantProperty("abi_dependency", "d_foo", "1.2"),
VariantProperty("abi_dependency", "d_foo", "1.2.3"),
]


@pytest.mark.parametrize(
"env_value",
[
"",
"c_ns==7.8.9b1",
"c.ns==7.8.9b1",
"d==4.9.4,c.ns==7.8.9",
"a_foo==1.2.79",
"a-foo==1.2.79,d==4.9.4",
# invalid components should be ignored (with a warning)
"no-version",
"a.foo==1.2.79,no-version",
"z>=1.2.3",
],
)
def test_inject_abi_dependency_envvar(
monkeypatch: pytest.MonkeyPatch,
mocker: pytest_mock.MockerFixture,
env_value: str,
) -> None:
monkeypatch.setenv("VARIANT_ABI_DEPENDENCY", env_value)

namespace_priorities: list[VariantNamespace] = []
supported_vprops: list[VariantProperty] = []

mocker.patch("importlib.metadata.distributions").return_value = [
MockedDistribution("a-foo", "1.2.3"),
MockedDistribution("b", "4.7.9"),
]
inject_abi_dependency(supported_vprops, namespace_priorities)

expected = {
VariantProperty("abi_dependency", "b", "4"),
VariantProperty("abi_dependency", "b", "4.7"),
VariantProperty("abi_dependency", "b", "4.7.9"),
}
if "a" not in env_value:
expected |= {
VariantProperty("abi_dependency", "a_foo", "1"),
VariantProperty("abi_dependency", "a_foo", "1.2"),
VariantProperty("abi_dependency", "a_foo", "1.2.3"),
}
else:
expected |= {
VariantProperty("abi_dependency", "a_foo", "1"),
VariantProperty("abi_dependency", "a_foo", "1.2"),
VariantProperty("abi_dependency", "a_foo", "1.2.79"),
}
if "c" in env_value:
expected |= {
VariantProperty("abi_dependency", "c_ns", "7"),
VariantProperty("abi_dependency", "c_ns", "7.8"),
VariantProperty("abi_dependency", "c_ns", "7.8.9"),
}
if "d" in env_value:
expected |= {
VariantProperty("abi_dependency", "d", "4"),
VariantProperty("abi_dependency", "d", "4.9"),
VariantProperty("abi_dependency", "d", "4.9.4"),
}

assert namespace_priorities == [VARIANT_ABI_DEPENDENCY_NAMESPACE]
assert set(supported_vprops) == expected
2 changes: 2 additions & 0 deletions variantlib/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
)
VALIDATION_PROVIDER_REQUIRES_REGEX = re.compile(r"[\S ]+")

VARIANT_ABI_DEPENDENCY_NAMESPACE: Literal["abi_dependency"] = "abi_dependency"


# VALIDATION_PYTHON_PACKAGE_NAME_REGEX = re.compile(r"[^\s-]+?")
# Per PEP 508: https://peps.python.org/pep-0508/#names
Expand Down
104 changes: 103 additions & 1 deletion variantlib/resolver/lib.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from __future__ import annotations

import importlib.metadata
import logging
import os
from typing import TYPE_CHECKING

from packaging.utils import canonicalize_name
from packaging.version import Version

from variantlib.constants import VARIANT_ABI_DEPENDENCY_NAMESPACE
from variantlib.models.variant import VariantDescription
from variantlib.models.variant import VariantFeature
from variantlib.models.variant import VariantProperty
Expand All @@ -20,6 +27,20 @@
from variantlib.protocols import VariantFeatureValue
from variantlib.protocols import VariantNamespace

logger = logging.getLogger(__name__)


def _normalize_package_name(name: str) -> str:
# VALIDATION_FEATURE_NAME_REGEX does not accepts "-"
return canonicalize_name(name).replace("-", "_")


def _generate_version_matches(version: str) -> Generator[str]:
vspec = Version(version)
yield f"{vspec.major}"
yield f"{vspec.major}.{vspec.minor}"
yield f"{vspec.major}.{vspec.minor}.{vspec.micro}"


def filter_variants(
vdescs: list[VariantDescription],
Expand Down Expand Up @@ -100,6 +121,61 @@ def filter_variants(
yield from result


def inject_abi_dependency(
supported_vprops: list[VariantProperty],
namespace_priorities: list[VariantNamespace],
) -> None:
"""Inject supported vairants for the abi_dependency namespace"""

# 1. Automatically populate from the current python environment
packages = {
_normalize_package_name(dist.name): dist.version
for dist in importlib.metadata.distributions()
}

# 2. Manually fed from environment variable
# Env Var Format: `VARIANT_ABI_DEPENDENCY=packageA==1.2.3,...,packageZ==7.8.9`
if variant_abi_deps_env := os.environ.get("VARIANT_ABI_DEPENDENCY"):
for pkg_spec in variant_abi_deps_env.split(","):
try:
pkg_name, pkg_version = pkg_spec.split("==", maxsplit=1)
except ValueError:
logger.warning(
"`VARIANT_ABI_DEPENDENCY` received an invalid value "
"`%(pkg_spec)s`. It will be ignored.\n"
"Expected format: `packageA==1.2.3,...,packageZ==7.8.9`.",
{"pkg_spec": pkg_spec},
)
continue

pkg_name = _normalize_package_name(pkg_name)
if (old_version := packages.get(pkg_name)) is not None:
logger.warning(
"`VARIANT_ABI_DEPENDENCY` overrides package version: "
"`%(pkg_name)s` from `%(old_ver)s` to `%(new_ver)s`",
{
"pkg_name": pkg_name,
"old_ver": old_version,
"new_ver": pkg_version,
},
)

packages[pkg_name] = pkg_version

for pkg_name, pkg_version in sorted(packages.items()):
supported_vprops.extend(
VariantProperty(
namespace=VARIANT_ABI_DEPENDENCY_NAMESPACE,
feature=pkg_name,
value=_ver,
)
for _ver in _generate_version_matches(pkg_version)
)

# 3. Adding `VARIANT_ABI_DEPENDENCY_NAMESPACE` at the back of`namespace_priorities`
namespace_priorities.append(VARIANT_ABI_DEPENDENCY_NAMESPACE)


def sort_and_filter_supported_variants(
vdescs: list[VariantDescription],
supported_vprops: list[VariantProperty],
Expand All @@ -124,8 +200,28 @@ def sort_and_filter_supported_variants(
:param property_priorities: Ordered list of `VariantProperty` objects.
:return: Sorted and filtered list of `VariantDescription` objects.
"""

validate_type(vdescs, list[VariantDescription])
validate_type(supported_vprops, list[VariantProperty])

if namespace_priorities is None:
namespace_priorities = []

# Avoiding modification in place
namespace_priorities = namespace_priorities.copy()
supported_vprops = supported_vprops.copy()

# ======================================================================= #
# ABI DEPENDENCY INJECTION #
# ======================================================================= #

inject_abi_dependency(supported_vprops, namespace_priorities)

# ======================================================================= #
# NULL VARIANT #
# ======================================================================= #

# Adding the `null-variant` to the list - always "compatible"
if (null_variant := VariantDescription()) not in vdescs:
"""Add a null variant description to the list."""
# This is needed to ensure that we always consider the null variant
Expand All @@ -139,7 +235,9 @@ def sort_and_filter_supported_variants(
"""No supported properties provided, return no variants."""
return []

validate_type(supported_vprops, list[VariantProperty])
# ======================================================================= #
# FILTERING #
# ======================================================================= #

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

# ======================================================================= #
# SORTING #
# ======================================================================= #

# Step 2: we sort the supported `VariantProperty`s based on their respective
# priority.
sorted_supported_vprops = sort_variant_properties(
Expand Down