diff --git a/tests/resolver/test_abi_dependency.py b/tests/resolver/test_abi_dependency.py new file mode 100644 index 0000000..7bb6ab5 --- /dev/null +++ b/tests/resolver/test_abi_dependency.py @@ -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 diff --git a/variantlib/constants.py b/variantlib/constants.py index 2fb2a0d..604bb00 100644 --- a/variantlib/constants.py +++ b/variantlib/constants.py @@ -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 diff --git a/variantlib/resolver/lib.py b/variantlib/resolver/lib.py index 7cc00a8..c0dab4e 100644 --- a/variantlib/resolver/lib.py +++ b/variantlib/resolver/lib.py @@ -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 @@ -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], @@ -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], @@ -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 @@ -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. @@ -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(