From c1ab9aac9b0e1c9507beb3a0be211047206b9b58 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Thu, 3 Apr 2025 02:20:35 -0400 Subject: [PATCH 1/9] Basic Filtering & Sorting added --- tests/resolver/__init__.py | 0 tests/resolver/test_filtering.py | 405 +++++++++++++++++++++++++++ tests/resolver/test_lib.py | 275 ++++++++++++++++++ tests/resolver/test_sorting.py | 434 +++++++++++++++++++++++++++++ variantlib/configuration.py | 123 ++++++++ variantlib/errors.py | 4 + variantlib/models/configuration.py | 117 ++++++++ variantlib/resolver/__init__.py | 0 variantlib/resolver/filtering.py | 180 ++++++++++++ variantlib/resolver/lib.py | 179 ++++++++++++ variantlib/resolver/sorting.py | 198 +++++++++++++ 11 files changed, 1915 insertions(+) create mode 100644 tests/resolver/__init__.py create mode 100644 tests/resolver/test_filtering.py create mode 100644 tests/resolver/test_lib.py create mode 100644 tests/resolver/test_sorting.py create mode 100644 variantlib/configuration.py create mode 100644 variantlib/models/configuration.py create mode 100644 variantlib/resolver/__init__.py create mode 100644 variantlib/resolver/filtering.py create mode 100644 variantlib/resolver/lib.py create mode 100644 variantlib/resolver/sorting.py diff --git a/tests/resolver/__init__.py b/tests/resolver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/resolver/test_filtering.py b/tests/resolver/test_filtering.py new file mode 100644 index 0000000..97fdae8 --- /dev/null +++ b/tests/resolver/test_filtering.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import copy +import random +from collections import deque + +import pytest + +from variantlib.errors import ValidationError +from variantlib.models.variant import VariantDescription +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty +from variantlib.resolver.filtering import filter_variants_by_features +from variantlib.resolver.filtering import filter_variants_by_namespaces +from variantlib.resolver.filtering import filter_variants_by_property +from variantlib.resolver.filtering import remove_duplicates + + +@pytest.fixture(scope="session") +def vprops() -> list[VariantProperty]: + return [ + VariantProperty(namespace="OmniCorp", feature="custom_feat", value="value1"), + VariantProperty( + namespace="TyrellCorporation", feature="client_id", value="value2" + ), + ] + + +@pytest.fixture(scope="session") +def vdescs(vprops: list[VariantProperty]) -> list[VariantDescription]: + """Fixture to create a list of VariantDescription objects.""" + assert len(vprops) == 2 + prop1, prop2 = vprops + + return [ + VariantDescription([prop1]), + VariantDescription([prop2]), + VariantDescription([prop1, prop2]), + ] + + +# =========================== `remove_duplicates` =========================== # + + +def test_remove_duplicates(vdescs: list[VariantDescription]): + # using `copy.deepcopy` to ensure that all objects are actually unique + input_vdescs = [copy.deepcopy(random.choice(vdescs)) for _ in range(100)] + filtered_vdescs = list(remove_duplicates(input_vdescs)) + + assert len(filtered_vdescs) == 3 + + for vdesc in vdescs: + assert vdesc in filtered_vdescs + + +def test_remove_duplicates_empty(): + assert list(remove_duplicates([])) == [] + + +def test_remove_duplicates_validation_error(): + with pytest.raises(ValidationError): + deque(remove_duplicates("not a list"), maxlen=0) # type: ignore[arg-type] + + with pytest.raises(ValidationError): + deque(remove_duplicates(["not a VariantDescription"]), maxlen=0) # type: ignore[list-item] + + +# ===================== `filter_variants_by_namespaces` ===================== # + + +def test_filter_variants_by_namespaces(vdescs: list[VariantDescription]): + vdesc1, vdesc2, _ = vdescs + + # No namespace allowed - should return empty list + assert ( + list( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces=[], + ) + ) + == [] + ) + + # Non existing namespace allowed - should return empty list + assert ( + list( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces=["NonExistentNamespace"], + ) + ) + == [] + ) + + # Only `OmniCorp` allowed - should return `vdesc1` + assert list( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces=["OmniCorp"], + ) + ) == [vdesc1] + + # Only `TyrellCorporation` allowed - should return `vdesc2` + assert list( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces=["TyrellCorporation"], + ) + ) == [vdesc2] + + # Both `OmniCorp` and `TyrellCorporation` - should return all `vdescs` + # Note: order should not matter + assert ( + list( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces=["OmniCorp", "TyrellCorporation"], + ) + ) + == vdescs + ) + + assert ( + list( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces=["TyrellCorporation", "OmniCorp"], + ) + ) + == vdescs + ) + + +def test_filter_variants_by_namespaces_validation_error( + vdescs: list[VariantDescription], +): + with pytest.raises(ValidationError): + deque( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces="not a list", # type: ignore[arg-type] + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_namespaces( + vdescs=vdescs, + allowed_namespaces=[1234], # type: ignore[list-item] + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_namespaces( + vdescs="not a list", # type: ignore[arg-type] + allowed_namespaces=["OmniCorp"], + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_namespaces( + vdescs=["not a `VariantDescription`"], # type: ignore[list-item] + allowed_namespaces=["OmniCorp"], + ), + maxlen=0, + ) + + +# ====================== `filter_variants_by_features` ====================== # + + +def test_filter_variants_by_features(vdescs: list[VariantDescription]): + vdesc1, vdesc2, _ = vdescs + + vfeat1 = VariantFeature(namespace="OmniCorp", feature="custom_feat") + vfeat2 = VariantFeature(namespace="TyrellCorporation", feature="client_id") + + # No feature allowed - should return empty list + assert ( + list( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=[], + ) + ) + == [] + ) + + # Non existing feature allowed - should return empty list + assert ( + list( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=[ + VariantFeature(namespace="UmbrellaCorporation", feature="AI") + ], + ) + ) + == [] + ) + + # Only `vfeat1` allowed - should return `vdesc1` + assert list( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=[vfeat1], + ) + ) == [vdesc1] + + # Only `vfeat2` allowed - should return `vdesc2` + assert list( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=[vfeat2], + ) + ) == [vdesc2] + + # Both of vfeats - should return all `vdescs` + # Note: Order should not matter + assert ( + list( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=[vfeat1, vfeat2], + ) + ) + == vdescs + ) + + assert ( + list( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=[vfeat2, vfeat1], + ) + ) + == vdescs + ) + + +def test_filter_variants_by_features_validation_error( + vdescs: list[VariantDescription], +): + vfeat = VariantFeature(namespace="namespace", feature="feature") + + with pytest.raises(ValidationError): + deque( + filter_variants_by_features( + vdescs=vdescs, + allowed_features="not a list", # type: ignore[arg-type] + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=["not a `VariantFeature`"], # type: ignore[list-item] + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_features( + vdescs="not a list", # type: ignore[arg-type] + allowed_features=[vfeat], + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_features( + vdescs=["not a `VariantDescription`"], # type: ignore[list-item] + allowed_features=[vfeat], + ), + maxlen=0, + ) + + +# ====================== `filter_variants_by_property` ====================== # + + +def test_filter_variants_by_property( + vdescs: list[VariantDescription], + vprops: list[VariantProperty], +): + assert len(vprops) == 2 + prop1, prop2 = vprops + + vdesc1, vdesc2, _ = vdescs + + # No property allowed - should return empty list + assert ( + list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[], + ) + ) + == [] + ) + + # Non existing property allowed - should return empty list + assert ( + list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[ + VariantProperty( + namespace="UmbrellaCorporation", feature="AI", value="ChatBot" + ) + ], + ) + ) + == [] + ) + + # Only `prop1` allowed - should return `vdesc1` + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[prop1], + ) + ) == [vdesc1] + + # Only `prop2` allowed - should return `vdesc2` + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[prop2], + ) + ) == [vdesc2] + + # Both of vfeats - should return all `vdescs` + # Note: Order should not matter + assert ( + list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[prop1, prop2], + ) + ) + == vdescs + ) + + assert ( + list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[prop2, prop1], + ) + ) + == vdescs + ) + + +def test_filter_variants_by_property_validation_error( + vdescs: list[VariantDescription], +): + vprop = VariantProperty(namespace="namespace", feature="feature", value="value") + + with pytest.raises(ValidationError): + deque( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties="not a list", # type: ignore[arg-type] + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=["not a `VariantFeature`"], # type: ignore[list-item] + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_property( + vdescs="not a list", # type: ignore[arg-type] + allowed_properties=[vprop], + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_property( + vdescs=["not a `VariantDescription`"], # type: ignore[list-item] + allowed_properties=[vprop], + ), + maxlen=0, + ) diff --git a/tests/resolver/test_lib.py b/tests/resolver/test_lib.py new file mode 100644 index 0000000..e9c17cd --- /dev/null +++ b/tests/resolver/test_lib.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import pytest + +from variantlib.models.variant import VariantDescription +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty +from variantlib.resolver.filtering import filter_variants_by_features +from variantlib.resolver.filtering import filter_variants_by_namespaces +from variantlib.resolver.filtering import remove_duplicates +from variantlib.resolver.lib import filter_variants + + +@pytest.fixture(scope="session") +def vprops() -> list[VariantProperty]: + """Fixture to create a list of VariantProperty objects.""" + + return [ + VariantProperty(namespace="OmniCorp", feature="access_key", value="value1"), + VariantProperty( + namespace="TyrellCorporation", feature="client_id", value="value2" + ), + VariantProperty( + namespace="TyrellCorporation", feature="client_pass", value="abcde" + ), + VariantProperty( + namespace="TyrellCorporation", feature="client_pass", value="efghij" + ), + ] + + +@pytest.fixture(scope="session") +def vdescs(vprops: list[VariantProperty]) -> list[VariantDescription]: + """Fixture to create a list of VariantDescription objects.""" + + assert len(vprops) == 4 + prop1, prop2, prop3, prop4 = vprops + + return [ + # prop3 and prop4 are mutually exclusive + VariantDescription([prop1, prop2, prop3]), + VariantDescription([prop1, prop3, prop2]), + VariantDescription([prop2, prop3, prop1]), + VariantDescription([prop2, prop1, prop3]), + VariantDescription([prop3, prop2, prop1]), + VariantDescription([prop3, prop1, prop2]), + VariantDescription([prop1, prop2, prop4]), # duplicate with prop4 + VariantDescription([prop1, prop4, prop2]), # duplicate with prop4 + VariantDescription([prop2, prop4, prop1]), # duplicate with prop4 + VariantDescription([prop2, prop1, prop4]), # duplicate with prop4 + VariantDescription([prop4, prop2, prop1]), # duplicate with prop4 + VariantDescription([prop4, prop1, prop2]), # duplicate with prop4 + VariantDescription([prop1, prop2]), + VariantDescription([prop2, prop1]), + VariantDescription([prop1, prop3]), + VariantDescription([prop3, prop1]), + VariantDescription([prop2, prop3]), + VariantDescription([prop3, prop2]), + VariantDescription([prop1, prop4]), # duplicate with prop4 + VariantDescription([prop4, prop1]), # duplicate with prop4 + VariantDescription([prop2, prop4]), # duplicate with prop4 + VariantDescription([prop4, prop2]), # duplicate with prop4 + VariantDescription([prop1]), + VariantDescription([prop2]), + VariantDescription([prop3]), + VariantDescription([prop4]), + ] + + +# =========================== `filter_variants` =========================== # + + +def test_filter_variants( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 4 + _, _, prop3, _ = vprops + + expected_vdescs = [ + # VariantDescription([prop1, prop2, prop3]), # not allowed `namespace` + # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop2, prop4]), # not allowed property + # VariantDescription([prop1, prop4, prop2]), # not allowed property + # VariantDescription([prop2, prop4, prop1]), # not allowed property + # VariantDescription([prop2, prop1, prop4]), # not allowed property + # VariantDescription([prop4, prop2, prop1]), # not allowed property + # VariantDescription([prop4, prop1, prop2]), # not allowed property + # VariantDescription([prop1, prop2]), # not allowed `namespace` + # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop3]), # not allowed `namespace` + # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop3]), # not allowed `feature` + # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop4]), # not allowed property + # VariantDescription([prop4, prop1]), # not allowed property + # VariantDescription([prop2, prop4]), # not allowed property + # VariantDescription([prop4, prop2]), # not allowed property + # VariantDescription([prop1]), # not allowed `namespace` + # VariantDescription([prop2]), # not allowed `feature` + VariantDescription([prop3]), # ================= > the only valid variant + # VariantDescription([prop3]), # not allowed property + ] + + assert ( + list( + filter_variants( + vdescs=vdescs, + allowed_namespaces=["TyrellCorporation"], + allowed_features=[ + VariantFeature(namespace="TyrellCorporation", feature="client_pass") + ], + allowed_properties=[prop3], + ) + ) + == expected_vdescs + ) + + +def test_filter_variants_only_remove_duplicates( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 4 + prop1, prop2, prop3, prop4 = vprops + + expected_vdescs = [ + VariantDescription([prop1, prop2, prop3]), + # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter + VariantDescription([prop1, prop2, prop4]), # duplicate with prop4 + # VariantDescription([prop1, prop4, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop4, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop1, prop4]), # duplicate - order doesn't matter + # VariantDescription([prop4, prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop4, prop1, prop2]), # duplicate - order doesn't matter + VariantDescription([prop1, prop2]), + # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter + VariantDescription([prop1, prop3]), + # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter + VariantDescription([prop2, prop3]), + # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter + VariantDescription([prop1, prop4]), + # VariantDescription([prop4, prop1]), # duplicate - order doesn't matter + VariantDescription([prop2, prop4]), + # VariantDescription([prop4, prop2]), # duplicate - order doesn't matter + VariantDescription([prop1]), + VariantDescription([prop2]), + VariantDescription([prop3]), + VariantDescription([prop4]), + ] + + assert ( + list( + filter_variants( + vdescs=vdescs, + ) + ) + == expected_vdescs + ) + + assert list(remove_duplicates(vdescs=vdescs)) == expected_vdescs + + +def test_filter_variants_remove_duplicates_and_namespaces( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 4 + _, prop2, prop3, prop4 = vprops + + expected_vdescs = [ + # VariantDescription([prop1, prop2, prop3]), # not allowed `namespace` + # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop2, prop4]), # not allowed `namespace` + # VariantDescription([prop1, prop4, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop4, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop1, prop4]), # duplicate - order doesn't matter + # VariantDescription([prop4, prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop4, prop1, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop2]), # not allowed `namespace` + # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop3]), # not allowed `namespace` + # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter + VariantDescription([prop2, prop3]), + # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop4]), # not allowed `namespace` + # VariantDescription([prop4, prop1]), # duplicate - order doesn't matter + VariantDescription([prop2, prop4]), + # VariantDescription([prop4, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop1]), # not allowed `namespace` + VariantDescription([prop2]), + VariantDescription([prop3]), + VariantDescription([prop4]), + ] + + allowed_namespaces = ["TyrellCorporation"] + + assert ( + list( + filter_variants( + vdescs=vdescs, + allowed_namespaces=allowed_namespaces, + ) + ) + == expected_vdescs + ) + + assert ( + list( + filter_variants_by_namespaces( + remove_duplicates(vdescs=vdescs), + allowed_namespaces=allowed_namespaces, + ) + ) + == expected_vdescs + ) + + +def test_filter_variants_remove_duplicates_and_features( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 4 + prop1, prop2, _, _ = vprops + + expected_vdescs = [ + # VariantDescription([prop1, prop2, prop3]), # not allowed `feature` + # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter + VariantDescription([prop1, prop2]), + # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop1, prop3]), # not allowed `feature` + # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter + # VariantDescription([prop2, prop3]), # not allowed `feature` + # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter + VariantDescription([prop1]), + VariantDescription([prop2]), + # VariantDescription([prop3]), # not allowed `feature` + ] + + allowed_features = [ + VariantFeature(namespace="OmniCorp", feature="access_key"), + VariantFeature(namespace="TyrellCorporation", feature="client_id"), + ] + + assert ( + list( + filter_variants( + vdescs=vdescs, + allowed_features=allowed_features, + ) + ) + == expected_vdescs + ) + + assert ( + list( + filter_variants_by_features( + remove_duplicates(vdescs=vdescs), + allowed_features=allowed_features, + ) + ) + == expected_vdescs + ) diff --git a/tests/resolver/test_sorting.py b/tests/resolver/test_sorting.py new file mode 100644 index 0000000..e1e11f1 --- /dev/null +++ b/tests/resolver/test_sorting.py @@ -0,0 +1,434 @@ +import sys + +import pytest + +from variantlib.errors import ValidationError +from variantlib.models.variant import VariantDescription +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty +from variantlib.resolver.sorting import get_feature_priority +from variantlib.resolver.sorting import get_namespace_priority +from variantlib.resolver.sorting import get_property_priority +from variantlib.resolver.sorting import get_variant_property_priority_tuple +from variantlib.resolver.sorting import sort_variant_properties +from variantlib.resolver.sorting import sort_variants_descriptions + +# ========================= get_property_priority =========================== # + + +def test_get_property_priority(): + vprop1 = VariantProperty(namespace="OmniCorp", feature="feat", value="value1") + vprop2 = VariantProperty(namespace="OmniCorp", feature="feat", value="value2") + vprop3 = VariantProperty(namespace="OmniCorp", feature="feat", value="value3") + assert get_property_priority(vprop1, [vprop1, vprop2]) == 0 + assert get_property_priority(vprop2, [vprop1, vprop2]) == 1 + assert get_property_priority(vprop1, [vprop1, vprop2, vprop3]) == 0 + assert get_property_priority(vprop2, [vprop1, vprop2, vprop3]) == 1 + + +def test_negative_get_property_priority(): + vprop1 = VariantProperty(namespace="OmniCorp", feature="feat", value="value1") + vprop2 = VariantProperty(namespace="OmniCorp", feature="feat", value="value2") + + assert get_property_priority(vprop1, None) == sys.maxsize + assert get_property_priority(vprop1, []) == sys.maxsize + assert get_property_priority(vprop1, [vprop2]) == sys.maxsize + + +def test_get_property_priority_validation_error(): + with pytest.raises(ValidationError): + get_property_priority(vprop="not a `VariantProperty`", property_priorities=None) # type: ignore[arg-type] + + vprop = VariantProperty(namespace="OmniCorp", feature="feature", value="value") + + with pytest.raises(ValidationError): + get_property_priority(vprop=vprop, property_priorities="not a list or None") # type: ignore[arg-type] + + with pytest.raises(ValidationError): + get_property_priority( + vprop=vprop, + property_priorities=[{"not a VariantProperty": True}], # type: ignore[list-item] + ) + + +# ========================== get_feature_priority =========================== # + + +def test_get_feature_priority(): + vprop1 = VariantProperty(namespace="OmniCorp", feature="feature", value="value") + vprop2 = VariantProperty(namespace="OmniCorp", feature="other_feat", value="value") + feature_priorities = [ + VariantFeature(namespace="OmniCorp", feature="feature"), + VariantFeature(namespace="OmniCorp", feature="other_feat"), + ] + assert get_feature_priority(vprop1, feature_priorities) == 0 + assert get_feature_priority(vprop2, feature_priorities) == 1 + + +def test_negative_get_feature_priority(): + vprop = VariantProperty(namespace="OmniCorp", feature="no_exist", value="value") + vfeat = VariantFeature(namespace="OmniCorp", feature="feature") + + assert get_feature_priority(vprop, None) == sys.maxsize + assert get_feature_priority(vprop, []) == sys.maxsize + assert get_feature_priority(vprop, [vfeat]) == sys.maxsize + + +def test_get_feature_priority_validation_error(): + with pytest.raises(ValidationError): + get_feature_priority(vprop="not a `VariantProperty`", feature_priorities=None) # type: ignore[arg-type] + + vprop = VariantProperty(namespace="OmniCorp", feature="feature", value="value") + + with pytest.raises(ValidationError): + get_feature_priority(vprop=vprop, feature_priorities="not a list or None") # type: ignore[arg-type] + + with pytest.raises(ValidationError): + get_feature_priority( + vprop=vprop, + feature_priorities=[{"not a VariantFeature": True}], # type: ignore[list-item] + ) + + +# ========================= get_namespace_priority ========================== # + + +def test_get_namespace_priority(): + vprop1 = VariantProperty(namespace="OmniCorp", feature="feature", value="value") + vprop2 = VariantProperty(namespace="OtherCorp", feature="feature", value="value") + vprop3 = VariantProperty(namespace="NoCorp", feature="feature", value="value") + namespace_priorities = ["OmniCorp", "OtherCorp"] + assert get_namespace_priority(vprop1, namespace_priorities) == 0 + assert get_namespace_priority(vprop2, namespace_priorities) == 1 + assert get_namespace_priority(vprop3, namespace_priorities) == sys.maxsize + + +def test_negative_get_namespace_priority(): + vprop = VariantProperty(namespace="OmniCorp", feature="no_exist", value="value") + + assert get_namespace_priority(vprop, None) == sys.maxsize + assert get_namespace_priority(vprop, []) == sys.maxsize + assert get_namespace_priority(vprop, ["OtherCorp"]) == sys.maxsize + + +def test_get_namespace_priority_validation_error(): + with pytest.raises(ValidationError): + get_namespace_priority( + vprop="not a `VariantProperty`", # type: ignore[arg-type] + namespace_priorities=None, + ) + + vprop = VariantProperty(namespace="OmniCorp", feature="feature", value="value") + + with pytest.raises(ValidationError): + get_namespace_priority(vprop=vprop, namespace_priorities="not a list or None") # type: ignore[arg-type] + + with pytest.raises(ValidationError): + get_namespace_priority(vprop=vprop, namespace_priorities=[{"not a str": True}]) # type: ignore[list-item] + + +# =================== get_variant_property_priority_tuple =================== # + + +def test_get_variant_property_priority_tuple(): + vprop = VariantProperty(namespace="OmniCorp", feature="custom_feat", value="value1") + property_priorities = [ + VariantProperty(namespace="OtherCorp", feature="other_feat", value="value2"), + vprop, + ] + feature_priorities = [ + VariantFeature(namespace=vprop.namespace, feature=vprop.feature), + VariantFeature(namespace="OmniCorp", feature="feature"), + ] + namespace_priorities = ["OtherCorp"] + assert get_variant_property_priority_tuple( + vprop, property_priorities, feature_priorities, namespace_priorities + ) == (1, 0, sys.maxsize) + + +def test_get_variant_property_priority_tuple_validation_error(): + with pytest.raises(ValidationError): + get_variant_property_priority_tuple( + vprop="not a VariantProperty", # type: ignore[arg-type] + property_priorities=None, + feature_priorities=None, + namespace_priorities=None, + ) + + +# ========================= sort_variant_properties ========================= # + + +def test_sort_variant_properties(): + vprop_list = [ + VariantProperty(namespace="OmniCorp", feature="featA", value="value"), + VariantProperty(namespace="OmniCorp", feature="featB", value="value1"), + VariantProperty(namespace="OmniCorp", feature="featB", value="value2"), + VariantProperty(namespace="OmniCorp", feature="featC", value="value"), + VariantProperty(namespace="OmniCorp", feature="featD", value="value"), + VariantProperty(namespace="OtherCorp", feature="featA", value="value"), + VariantProperty(namespace="OtherCorp", feature="featB", value="value1"), + VariantProperty(namespace="OtherCorp", feature="featB", value="value2"), + VariantProperty(namespace="OtherCorp", feature="featC", value="value"), + VariantProperty(namespace="OtherCorp", feature="featD", value="value"), + VariantProperty(namespace="AnyCorp", feature="feature", value="value"), + ] + property_priorities = [ + VariantProperty(namespace="OtherCorp", feature="featA", value="value"), + VariantProperty(namespace="OmniCorp", feature="featA", value="value"), + VariantProperty(namespace="OmniCorp", feature="featC", value="value"), + VariantProperty(namespace="OtherCorp", feature="featC", value="value"), + ] + feature_priorities = [ + VariantFeature(namespace="OtherCorp", feature="featB"), + VariantFeature(namespace="OmniCorp", feature="featB"), + ] + namespace_priorities = ["OmniCorp", "OtherCorp"] + sorted_vprops = sort_variant_properties( + vprop_list, property_priorities, feature_priorities, namespace_priorities + ) + assert sorted_vprops == [ + # sorted by property priorities + VariantProperty(namespace="OtherCorp", feature="featA", value="value"), + VariantProperty(namespace="OmniCorp", feature="featA", value="value"), + VariantProperty(namespace="OmniCorp", feature="featC", value="value"), + VariantProperty(namespace="OtherCorp", feature="featC", value="value"), + # sorted by feature priorities + VariantProperty(namespace="OtherCorp", feature="featB", value="value1"), + VariantProperty(namespace="OtherCorp", feature="featB", value="value2"), + VariantProperty(namespace="OmniCorp", feature="featB", value="value1"), + VariantProperty(namespace="OmniCorp", feature="featB", value="value2"), + # sorted by namespace priorities + VariantProperty(namespace="OmniCorp", feature="featD", value="value"), + VariantProperty(namespace="OtherCorp", feature="featD", value="value"), + # not a listed namespace or feature or property + VariantProperty(namespace="AnyCorp", feature="feature", value="value"), + ] + + +def test_sort_variant_properties_validation_error(): + vprops = [VariantProperty(namespace="OmniCorp", feature="feat", value="value")] + with pytest.raises(ValidationError): + sort_variant_properties( + vprops="not a list", # type: ignore[arg-type] + property_priorities=None, + feature_priorities=None, + namespace_priorities=None, + ) + + with pytest.raises(ValidationError): + sort_variant_properties( + vprops=["not a VariantProperty"], # type: ignore[list-item] + property_priorities=None, + feature_priorities=None, + namespace_priorities=None, + ) + + with pytest.raises(ValidationError): + sort_variant_properties( + vprops=vprops, + property_priorities="not a list", # type: ignore[arg-type] + feature_priorities=None, + namespace_priorities=None, + ) + + with pytest.raises(ValidationError): + sort_variant_properties( + vprops=vprops, + property_priorities=["not a VariantProperty"], # type: ignore[list-item] + feature_priorities=None, + namespace_priorities=None, + ) + + with pytest.raises(ValidationError): + sort_variant_properties( + vprops=vprops, + property_priorities=None, + feature_priorities="not a list", # type: ignore[arg-type] + namespace_priorities=None, + ) + + with pytest.raises(ValidationError): + sort_variant_properties( + vprops=vprops, + property_priorities=None, + feature_priorities=["not a VariantProperty"], # type: ignore[list-item] + namespace_priorities=None, + ) + + with pytest.raises(ValidationError): + sort_variant_properties( + vprops=vprops, + property_priorities=None, + feature_priorities=None, + namespace_priorities="not a list", # type: ignore[arg-type] + ) + + with pytest.raises(ValidationError): + sort_variant_properties( + vprops=vprops, + property_priorities=None, + feature_priorities=None, + namespace_priorities=[{"not a str": True}], # type: ignore[list-item] + ) + + +# ========================= sort_variants_descriptions ========================= # + + +def test_sort_variants_descriptions(): + vprops_proprioty_list = [ + VariantProperty(namespace="OmniCorp", feature="featA", value="value"), + VariantProperty(namespace="OmniCorp", feature="featB", value="value1"), + VariantProperty(namespace="OmniCorp", feature="featB", value="value2"), + VariantProperty(namespace="OmniCorp", feature="featC", value="value"), + VariantProperty(namespace="OmniCorp", feature="featD", value="value"), + VariantProperty(namespace="OtherCorp", feature="featA", value="value"), + VariantProperty(namespace="OtherCorp", feature="featB", value="value1"), + VariantProperty(namespace="OtherCorp", feature="featB", value="value2"), + VariantProperty(namespace="OtherCorp", feature="featC", value="value"), + VariantProperty(namespace="OtherCorp", feature="featD", value="value"), + VariantProperty(namespace="AnyCorp", feature="feature", value="value"), + ] + + vdesc1 = VariantDescription( + [ + VariantProperty(namespace="OtherCorp", feature="featA", value="value"), + VariantProperty(namespace="OmniCorp", feature="featB", value="value1"), + VariantProperty(namespace="OmniCorp", feature="featC", value="value"), + VariantProperty(namespace="OtherCorp", feature="featC", value="value"), + ] + ) + vdesc2 = VariantDescription( + [VariantProperty(namespace="OtherCorp", feature="featA", value="value")] + ) + vdesc3 = VariantDescription( + [VariantProperty(namespace="OmniCorp", feature="featA", value="value")] + ) + + assert sort_variants_descriptions( + vdescs=[vdesc1], property_priorities=vprops_proprioty_list + ) == [vdesc1] + + assert sort_variants_descriptions( + vdescs=[vdesc1, vdesc2], property_priorities=vprops_proprioty_list + ) == [vdesc1, vdesc2] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc2, vdesc1], property_priorities=vprops_proprioty_list + ) == [vdesc1, vdesc2] + + assert sort_variants_descriptions( + vdescs=[vdesc1, vdesc3], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc3, vdesc1], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1] + + assert sort_variants_descriptions( + vdescs=[vdesc2, vdesc3], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc2] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc3, vdesc2], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc2] + + assert sort_variants_descriptions( + vdescs=[vdesc1, vdesc2, vdesc3], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1, vdesc2] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc2, vdesc1, vdesc3], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1, vdesc2] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc1, vdesc3, vdesc2], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1, vdesc2] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc2, vdesc3, vdesc1], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1, vdesc2] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc3, vdesc2, vdesc1], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1, vdesc2] + + # order permutation + assert sort_variants_descriptions( + vdescs=[vdesc3, vdesc1, vdesc2], property_priorities=vprops_proprioty_list + ) == [vdesc3, vdesc1, vdesc2] + + +def test_sort_variants_descriptions_ranking_validation_error(): + vprops = [VariantProperty(namespace="OmniCorp", feature="feat", value="value")] + vdesc1 = [ + VariantDescription( + properties=[ + VariantProperty( + namespace="OmniCorp", feature="feat", value="other_value" + ) + ] + ) + ] + + # Test with a completely different property (same feature, different value) + with pytest.raises(ValidationError, match="Filtering should be applied first."): + sort_variants_descriptions( + vdescs=vdesc1, + property_priorities=vprops, + ) + + vdescs2 = [ + VariantDescription( + properties=[ + *vprops, + VariantProperty( + namespace="OmniCorp", feature="other_feat", value="other_value" + ), + ], + ) + ] + + # Test with an extra property not included in the property priorities + with pytest.raises(ValidationError, match="Filtering should be applied first."): + sort_variants_descriptions( + vdescs=vdescs2, + property_priorities=vprops, + ) + + +def test_sort_variants_descriptions_validation_error(): + vprops = [VariantProperty(namespace="OmniCorp", feature="feat", value="value")] + vdescs = [VariantDescription(properties=vprops)] + + with pytest.raises(ValidationError): + sort_variants_descriptions( + vdescs="not a list", # type: ignore[arg-type] + property_priorities=vprops, + ) + + with pytest.raises(ValidationError): + sort_variants_descriptions( + vdescs=vprops, # type: ignore[arg-type] + property_priorities=vprops, + ) + + with pytest.raises(ValidationError): + sort_variants_descriptions( + vdescs=vdescs, + property_priorities="not a list", # type: ignore[arg-type] + ) + + with pytest.raises(ValidationError): + sort_variants_descriptions( + vdescs=vdescs, + property_priorities=vdescs, # type: ignore[arg-type] + ) diff --git a/variantlib/configuration.py b/variantlib/configuration.py new file mode 100644 index 0000000..5c088a5 --- /dev/null +++ b/variantlib/configuration.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import logging +import sys +from enum import IntEnum +from functools import cache +from functools import wraps +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Self +from typing import TypeVar + +import platformdirs +import tomllib + +from variantlib import errors +from variantlib.constants import CONFIG_FILENAME +from variantlib.models.configuration import Configuration as ConfigurationModel +from variantlib.utils import classproperty + +if TYPE_CHECKING: + from variantlib.models.variant import VariantFeature + from variantlib.models.variant import VariantProperty + +logger = logging.getLogger(__name__) + + +class ConfigEnvironments(IntEnum): + LOCAL = 1 + PROJECT = 2 + VIRTUALENV = 3 + USER = 4 + GLOBAL = 5 + + +@cache +def get_configuration_files() -> dict[ConfigEnvironments, Path]: + return { + ConfigEnvironments.LOCAL: Path.cwd() / CONFIG_FILENAME, + ConfigEnvironments.PROJECT: Path.cwd() / "pyproject.toml", + ConfigEnvironments.VIRTUALENV: Path(sys.prefix) / CONFIG_FILENAME, + ConfigEnvironments.USER: ( + Path( + platformdirs.user_config_dir( + "variantlib", appauthor=False, roaming=True + ) + ) + / CONFIG_FILENAME + ), + ConfigEnvironments.GLOBAL: ( + Path(platformdirs.site_config_dir("variantlib", appauthor=False)) + / CONFIG_FILENAME + ), + } + + +R = TypeVar("R") + + +def check_initialized(func: Callable[..., R]) -> Callable[..., R]: + @wraps(func) + def wrapper(cls: type[Configuration], *args: Any, **kwargs: Any) -> R: + if cls._config is None: + cls._config = cls.get_config() + + return func(cls, *args, **kwargs) + + return wrapper + + +class Configuration: + _config: ConfigurationModel | None = None + + def __new__(cls, *args: Any, **kwargs: dict[str, Any]) -> Self: + raise RuntimeError(f"Cannot instantiate {cls.__name__}") + + @classmethod + def reset(cls) -> None: + """Reset the configuration to its initial state""" + cls._config = None + + @classmethod + def get_config(cls) -> ConfigurationModel: + """Load the configuration from the configuration files""" + # TODO: Read namespace priority configuration + # TODO: Read namespace-feature prority configuration + # TODO: Read namespace-feature-value prority configuration + config_files = get_configuration_files() + + for config_name in ConfigEnvironments: + if (cfg_f := config_files[config_name]).exists(): + logger.info("Loading configuration file: %s", config_files[config_name]) + with cfg_f.open("rb") as f: + try: + if config_name == ConfigEnvironments.PROJECT: + config = tomllib.load(f)["tool"]["variantlib"] + else: + config = tomllib.load(f) + + except tomllib.TOMLDecodeError as e: + raise errors.ConfigurationError from e + + return ConfigurationModel.from_toml_config(**config) + + # No user-configuration file found + return ConfigurationModel.default() + + @classproperty + @check_initialized + def namespaces_priority(cls) -> list[str]: # noqa: N805 + return cls._config.namespaces_priority # type: ignore[union-attr] + + @classproperty + @check_initialized + def features_priority(cls) -> list[VariantFeature]: # noqa: N805 + return cls._config.features_priority # type: ignore[union-attr] + + @classproperty + @check_initialized + def property_priority(cls) -> list[VariantProperty]: # noqa: N805 + return cls._config.property_priority # type: ignore[union-attr] diff --git a/variantlib/errors.py b/variantlib/errors.py index 6a63f18..5ce60f4 100644 --- a/variantlib/errors.py +++ b/variantlib/errors.py @@ -12,3 +12,7 @@ class PluginMissingError(RuntimeError): class InvalidVariantEnvSpecError(ValueError): """Environment specifier for variants is invalid""" + + +class ConfigurationError(ValueError): + pass diff --git a/variantlib/models/configuration.py b/variantlib/models/configuration.py new file mode 100644 index 0000000..05c7974 --- /dev/null +++ b/variantlib/models/configuration.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import Self + +from variantlib.constants import VALIDATION_NAMESPACE_REGEX +from variantlib.models.base import BaseModel +from variantlib.models.validators import validate_and +from variantlib.models.validators import validate_instance_of +from variantlib.models.validators import validate_list_matches_re +from variantlib.models.validators import validate_list_of +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty + + +@dataclass(frozen=True) +class Configuration(BaseModel): + """ + Configuration class for variantlib. + + This class is used to define the configuration for the variantlib library. + It includes fields for the namespace, feature, and value, along with validation + checks for each field. + + Attributes: + namespaces_by_priority (list): The namespace of the configuration. + features_by_priority (list): The feature of the configuration. + """ + + namespaces_priority: list[str] = field( + metadata={ + "validator": lambda val: validate_and( + [ + lambda v: validate_instance_of(v, list), + lambda v: validate_list_of(v, str), + lambda v: validate_list_matches_re(v, VALIDATION_NAMESPACE_REGEX), + ], + value=val, + ) + } + ) + + features_priority: list[VariantFeature] = field( + metadata={ + "validator": lambda val: validate_and( + [ + lambda v: validate_instance_of(v, list), + lambda v: validate_list_of(v, VariantFeature), + ], + value=val, + ) + } + ) + + property_priority: list[VariantProperty] = field( + metadata={ + "validator": lambda val: validate_and( + [ + lambda v: validate_instance_of(v, list), + lambda v: validate_list_of(v, VariantProperty), + ], + value=val, + ) + } + ) + + @classmethod + def default(cls) -> Self: + """ + Create a default Configuration instance. + + Returns: + Configuration: A new Configuration instance with default values. + """ + + # TODO: Verify the default values make sense + + return cls( + namespaces_priority=[], + features_priority=[], + property_priority=[], + ) + + @classmethod + def from_toml_config( + cls, + namespaces_priority: list[str] | None = None, + features_priority: list[str] | None = None, + property_priority: list[str] | None = None, + ) -> Self: + """ + Create a Configuration instance from TOML-based configuration. + + Returns: + Configuration: A new Configuration instance. + """ + + # Convert the `features_priority: list[str]` into `list[VariantFeature]` + _features_priority: list[VariantFeature] = [] + if features_priority is not None: + for feature in features_priority: + validate_instance_of(feature, str) + _features_priority.append(VariantFeature.from_str(feature)) + + # Convert the `property_priority: list[str]` into `list[VariantProperty]` + _property_priority: list[VariantProperty] = [] + if property_priority is not None: + for vprop in property_priority: + validate_instance_of(vprop, str) + _property_priority.append(VariantProperty.from_str(feature)) + + return cls( + namespaces_priority=namespaces_priority or [], + features_priority=_features_priority, + property_priority=_property_priority, + ) diff --git a/variantlib/resolver/__init__.py b/variantlib/resolver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/variantlib/resolver/filtering.py b/variantlib/resolver/filtering.py new file mode 100644 index 0000000..0513c7b --- /dev/null +++ b/variantlib/resolver/filtering.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from variantlib.models.validators import validate_instance_of +from variantlib.models.validators import validate_list_of +from variantlib.models.variant import VariantDescription +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty + +if TYPE_CHECKING: + from collections.abc import Generator + +logger = logging.getLogger(__name__) + + +def remove_duplicates( + vdescs: Iterable[VariantDescription], +) -> Generator[VariantDescription]: + # Input validation + validate_instance_of(vdescs, Iterable) + + seen = set() + + def _should_include(vdesc: VariantDescription) -> bool: + """ + Check if any of the namespaces in the variant description are not allowed. + """ + validate_instance_of(vdesc, VariantDescription) + + if vdesc.hexdigest in seen: + logger.info( + "Variant `%(vhash)s` has been removed because it is a duplicate", + {"vhash": vdesc.hexdigest}, + ) + return False + + seen.add(vdesc.hexdigest) + return True + + yield from filter(_should_include, vdescs) + + +def filter_variants_by_namespaces( + vdescs: Iterable[VariantDescription], allowed_namespaces: list[str] +) -> Generator[VariantDescription]: + """ + Filters out `VariantDescription` that contain any unsupported variant namespace. + + :param vdescs: list of `VariantDescription` to filter. + :param allowed_namespaces: List of allowed variant namespaces as `str`. + :return: Filtered list of `VariantDescription`. + """ + + # Input validation + validate_instance_of(vdescs, Iterable) + validate_instance_of(allowed_namespaces, list) + validate_list_of(allowed_namespaces, str) + + # Note: for performance reasons we convert the list to a set to avoid O(n) lookups + _allowed_namespaces = set(allowed_namespaces) + + def _should_include(vdesc: VariantDescription) -> bool: + """ + Check if any of the namespaces in the variant description are not allowed. + """ + validate_instance_of(vdesc, VariantDescription) + + if any( + vprop.namespace not in _allowed_namespaces for vprop in vdesc.properties + ): + logger.info( + "Variant `%(vhash)s` has been rejected because one of variant " + "namespaces `[%(namespaces)s]` is not allowed. Allowed: " + "`[%(allowed_namespaces)s]`.", + { + "vhash": vdesc.hexdigest, + "namespaces": ", ".join( + [vprop.namespace for vprop in vdesc.properties] + ), + "allowed_namespaces": ", ".join(_allowed_namespaces), + }, + ) + return False + + return True + + yield from filter(_should_include, vdescs) + + +def filter_variants_by_features( + vdescs: Iterable[VariantDescription], allowed_features: list[VariantFeature] +) -> Generator[VariantDescription]: + """ + Filters out `VariantDescription` that contain any unsupported variant feature. + + :param vdescs: list of `VariantDescription` to filter. + :param allowed_features: List of allowed `VariantFeature`. + :return: Filtered list of `VariantDescription`. + """ + + # Input validation + validate_instance_of(vdescs, Iterable) + validate_instance_of(allowed_features, list) + validate_list_of(allowed_features, VariantFeature) + + # for performance reasons we convert the list to a set to avoid O(n) lookups + allowed_feature_hexs = {vfeat.feature_hash for vfeat in allowed_features} + + def _should_include(vdesc: VariantDescription) -> bool: + """ + Check if any of the namespaces in the variant description are not allowed. + """ + validate_instance_of(vdesc, VariantDescription) + + if forbidden_vprops := [ + vprop + for vprop in vdesc.properties + if vprop.feature_hash not in allowed_feature_hexs + ]: + logger.info( + "Variant `%(vhash)s` has been rejected because one of the variant " + "features `[%(vprops)s]` is not supported on this platform.", + { + "vhash": vdesc.hexdigest, + "vprops": ", ".join([vprop.to_str() for vprop in forbidden_vprops]), + }, + ) + return False + + return True + + yield from filter(_should_include, vdescs) + + +def filter_variants_by_property( + vdescs: Iterable[VariantDescription], allowed_properties: list[VariantProperty] +) -> Generator[VariantDescription]: + """ + Filters out `VariantDescription` that contain any unsupported variant feature. + + :param vdescs: list of `VariantDescription` to filter. + :param allowed_properties: List of allowed `VariantProperty`. + :return: Filtered list of `VariantDescription`. + """ + + # Input validation + validate_instance_of(vdescs, Iterable) + validate_instance_of(allowed_properties, list) + validate_list_of(allowed_properties, VariantProperty) + + # for performance reasons we convert the list to a set to avoid O(n) lookups + allowed_properties_hexs = {vfeat.property_hash for vfeat in allowed_properties} + + def _should_include(vdesc: VariantDescription) -> bool: + """ + Check if any of the namespaces in the variant description are not allowed. + """ + validate_instance_of(vdesc, VariantDescription) + + if forbidden_vprops := [ + vprop + for vprop in vdesc.properties + if vprop.property_hash not in allowed_properties_hexs + ]: + logger.info( + "Variant `%(vhash)s` has been rejected because one of the variant " + "features `[%(vprops)s]` is not supported on this platform.", + { + "vhash": vdesc.hexdigest, + "vprops": ", ".join([vprop.to_str() for vprop in forbidden_vprops]), + }, + ) + return False + + return True + + yield from filter(_should_include, vdescs) diff --git a/variantlib/resolver/lib.py b/variantlib/resolver/lib.py new file mode 100644 index 0000000..0ff3f57 --- /dev/null +++ b/variantlib/resolver/lib.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from variantlib.models.validators import validate_instance_of +from variantlib.models.validators import validate_list_of +from variantlib.models.variant import VariantDescription +from variantlib.models.variant import VariantProperty +from variantlib.resolver.filtering import filter_variants_by_features +from variantlib.resolver.filtering import filter_variants_by_namespaces +from variantlib.resolver.filtering import filter_variants_by_property +from variantlib.resolver.filtering import remove_duplicates +from variantlib.resolver.sorting import sort_variant_properties +from variantlib.resolver.sorting import sort_variants_descriptions + +if TYPE_CHECKING: + from collections.abc import Generator + + from variantlib.models.variant import VariantFeature + + +def filter_variants( + vdescs: list[VariantDescription], + allowed_namespaces: list[str] | None = None, + allowed_features: list[VariantFeature] | None = None, + allowed_properties: list[VariantProperty] | None = None, +) -> Generator[VariantDescription]: + """ + Filters out a `list` of `VariantDescription` with the following filters: + - Duplicates removed + - Unsupported `variant namespaces` removed - if `allowed_namespaces` is not None + - Unsupported `variant features` removed - if `allowed_features` is not None + - Unsupported `variant feature values` removed - if `allowed_properties` is not None + + :param vdescs: list of `VariantDescription` to filter. + :param allowed_namespaces: List of allowed variant namespaces as `str`. + :param allowed_features: List of allowed `VariantFeature`. + :param allowed_properties: List of allowed `VariantProperty`. + :return: Filtered list of `VariantDescription`. + """ + + # ========================= IMPLEMENTATION NOTE ========================= # + # Technically, only step 4 is absolutely necessary to filter out the + # `VariantDescription` that are not supported on this platform. + # 1. There should never be any duplicates on the index + # - filename collision (same filename & same hash) + # - hash collision inside `variants.json` + # => Added for safety and to avoid any potential bugs + # (Note: In all fairness, even if it was to happen, it would most + # likely not be a problem given that we just pick the best match + # 2. namespaces are included inside the `VariantProperty` of step 4 + # 3. features are included inside the `VariantProperty` of step 4 + # + # However, I (Jonathan Dekhtiar) strongly recommend to keep all of them: + # - Easier to read and understand + # - Easier to debug + # - Easier to maintain + # - Easier to extend + # - Easier to test + # - Allows `tooling` to provide CLI/configuration flags to explicitly + # reject a `namespace` or `namespace::feature` without complex + # processing. + # ======================================================================= # + + # Step 1: Remove duplicates - should never happen, but just in case + result = remove_duplicates(vdescs) + + # Step 2: Remove any `VariantDescription` which declare any `VariantProperty` with + # a variant namespace unsupported on this platform. + if allowed_namespaces is not None: + result = filter_variants_by_namespaces( + vdescs=result, + allowed_namespaces=allowed_namespaces, + ) + + # Step 3: Remove any `VariantDescription` which declare any `VariantProperty` with + # `namespace :: feature` (aka. `VariantFeature`) unsupported on this platform. + if allowed_features is not None: + result = filter_variants_by_features( + vdescs=result, allowed_features=allowed_features + ) + + # Step 4: Remove any `VariantDescription` which declare any `VariantProperty` + # `namespace :: feature :: value` unsupported on this platform. + if allowed_properties is not None: + result = filter_variants_by_property( + vdescs=result, allowed_properties=allowed_properties + ) + + yield from result + + +def sort_variants_by_supported_properties( + vdescs: list[VariantDescription], + supported_vprops: list[VariantProperty], + property_priorities: list[VariantProperty] | None = None, + feature_priorities: list[VariantFeature] | None = None, + namespace_priorities: list[str] | None = None, +) -> list[VariantDescription]: + """ + Sort a list of `VariantDescription` objects based on their `VariantProperty`s. + + :param vdescs: List of `VariantDescription` objects. + :param supported_vprops: list of `VariantProperty` objects supported on the platform + :param property_priorities: ordered list of `VariantProperty` objects. + :param feature_priorities: ordered list of `VariantFeature` objects. + :param namespace_priorities: ordered list of `str` objects. + :return: Sorted list of `VariantDescription` objects. + """ + validate_instance_of(vdescs, list) + validate_list_of(vdescs, VariantDescription) + + if supported_vprops is None: + return [] + + validate_instance_of(supported_vprops, list) + validate_list_of(supported_vprops, VariantProperty) + + # Step 1: we sort the supported properties based on the priorities + sorted_supported_vprops = sort_variant_properties( + vprops=supported_vprops, + property_priorities=property_priorities, + feature_priorities=feature_priorities, + namespace_priorities=namespace_priorities, + ) + + # Step 2: we sort the `VariantDescription` based on the sorted supported properties + return sort_variants_descriptions( + vdescs, + property_priorities=sorted_supported_vprops, + ) + + +def sort_and_filter_supported_variants( + vdescs: list[VariantDescription], + supported_vprops: list[VariantProperty] | None = None, + property_priorities: list[VariantProperty] | None = None, + feature_priorities: list[VariantFeature] | None = None, + namespace_priorities: list[str] | None = None, +) -> list[VariantDescription]: + """ + Sort and filter a list of `VariantDescription` objects based on their + `VariantProperty`s. + + :param vdescs: List of `VariantDescription` objects. + :param supported_vprops: List of `VariantProperty` objects supported on the platform + :param property_priorities: Ordered list of `VariantProperty` objects. + :param feature_priorities: Ordered list of `VariantFeature` objects. + :param namespace_priorities: Ordered list of `str` objects. + :return: Sorted and filtered list of `VariantDescription` objects. + """ + validate_instance_of(vdescs, list) + validate_list_of(vdescs, VariantDescription) + + if supported_vprops is None: + """No sdupported properties provided, return no variants.""" + return [] + + validate_instance_of(supported_vprops, list) + validate_list_of(supported_vprops, VariantProperty) + + # Step 1: we remove any duplicate, or unsupported `VariantDescription` on + # this platform. + filtered_vdescs = filter_variants( + vdescs=vdescs, + allowed_properties=supported_vprops, + allowed_features=feature_priorities, + allowed_namespaces=namespace_priorities, + ) + + # Step 2: we sort the `VariantDescription` based on the supported properties + # and the priorities + return sort_variants_by_supported_properties( + vdescs=list(filtered_vdescs), + supported_vprops=supported_vprops, + property_priorities=property_priorities, + feature_priorities=feature_priorities, + namespace_priorities=namespace_priorities, + ) diff --git a/variantlib/resolver/sorting.py b/variantlib/resolver/sorting.py new file mode 100644 index 0000000..3787eab --- /dev/null +++ b/variantlib/resolver/sorting.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import logging +import sys + +from variantlib.errors import ValidationError +from variantlib.models.validators import validate_instance_of +from variantlib.models.validators import validate_list_of +from variantlib.models.variant import VariantDescription +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty + +logger = logging.getLogger(__name__) + + +def get_property_priority( + vprop: VariantProperty, + property_priorities: list[VariantProperty] | None, +) -> int: + """ + Get the property priority of a `VariantProperty` object. + + :param vprop: `VariantProperty` object. + :param property_priorities: ordered list of `VariantProperty` objects. + :return: Property priority of the `VariantProperty` object. + """ + validate_instance_of(vprop, VariantProperty) + + if property_priorities is None: + return sys.maxsize + validate_instance_of(property_priorities, list) + validate_list_of(property_priorities, VariantProperty) + + _property_priorities = [vprop.property_hash for vprop in property_priorities] + + # if not present push at the end + try: + return _property_priorities.index(vprop.property_hash) + except ValueError: + return sys.maxsize + + +def get_feature_priority( + vprop: VariantProperty, + feature_priorities: list[VariantFeature] | None, +) -> int: + """ + Get the feature priority of a `VariantProperty` object. + + :param vprop: `VariantProperty` object. + :param feature_priorities: ordered list of `VariantFeature` objects. + :return: Feature priority of the `VariantProperty` object. + """ + validate_instance_of(vprop, VariantProperty) + + if feature_priorities is None: + return sys.maxsize + validate_instance_of(feature_priorities, list) + validate_list_of(feature_priorities, VariantFeature) + + _feature_priorities = [vfeat.feature_hash for vfeat in feature_priorities] + + # if not present push at the end + try: + return _feature_priorities.index(vprop.feature_hash) + except ValueError: + return sys.maxsize + + +def get_namespace_priority( + vprop: VariantProperty, + namespace_priorities: list[str] | None, +) -> int: + """ + Get the namespace priority of a `VariantProperty` object. + + :param vprop: `VariantProperty` object. + :param namespace_priorities: ordered list of `str` objects. + :return: Namespace priority of the `VariantProperty` object. + """ + validate_instance_of(vprop, VariantProperty) + + if namespace_priorities is None: + return sys.maxsize + validate_instance_of(namespace_priorities, list) + validate_list_of(namespace_priorities, str) + + # if not present push at the end + try: + return namespace_priorities.index(vprop.namespace) + except ValueError: + return sys.maxsize + + +def get_variant_property_priority_tuple( + vprop: VariantProperty, + property_priorities: list[VariantProperty] | None, + feature_priorities: list[VariantFeature] | None, + namespace_priorities: list[str] | None, +) -> tuple[int, int, int]: + """ + Get the variant property priority of a `VariantProperty` object. + + :param vprop: `VariantProperty` object. + :param property_priorities: ordered list of `VariantProperty` objects. + :param feature_priorities: ordered list of `VariantFeature` objects. + :param namespace_priorities: ordered list of `str` objects. + :return: Variant property priority of the `VariantProperty` object. + """ + validate_instance_of(vprop, VariantProperty) + + return ( + # First Priority + get_property_priority(vprop, property_priorities), + # Second Priority + get_feature_priority(vprop, feature_priorities), + # Third Priority + get_namespace_priority(vprop, namespace_priorities), + ) + + +def sort_variant_properties( + vprops: list[VariantProperty], + property_priorities: list[VariantProperty] | None, + feature_priorities: list[VariantFeature] | None, + namespace_priorities: list[str] | None, +) -> list[VariantProperty]: + """ + Sort a list of `VariantProperty` objects based on their priority. + + :param vprops: List of `VariantProperty` objects. + :param property_priorities: ordered list of `VariantProperty` objects. + :param feature_priorities: ordered list of `VariantFeature` objects. + :param namespace_priorities: ordered list of `str` objects. + :return: Sorted list of `VariantProperty` objects. + """ + validate_instance_of(vprops, list) + validate_list_of(vprops, VariantProperty) + + return sorted( + vprops, + key=lambda x: get_variant_property_priority_tuple( + x, property_priorities, feature_priorities, namespace_priorities + ), + ) + + +def sort_variants_descriptions( + vdescs: list[VariantDescription], property_priorities: list[VariantProperty] +) -> list[VariantDescription]: + """ + Sort a list of `VariantDescription` objects based on their `VariantProperty`s. + + :param vdescs: List of `VariantDescription` objects. + :param property_priorities: ordered list of `VariantProperty` objects. + :return: Sorted list of `VariantDescription` objects. + """ + validate_instance_of(vdescs, list) + validate_list_of(vdescs, VariantDescription) + + validate_instance_of(property_priorities, list) + validate_list_of(property_priorities, VariantProperty) + + # Pre-compute the property hash for the property priorities + # This is used to speed up the sorting process. + # The property_hash is used to compare the `VariantProperty` objects + property_hash_priorities = [vprop.property_hash for vprop in property_priorities] + + def _get_rank_tuple(vdesc: VariantDescription) -> tuple[int, ...]: + """ + Get the rank tuple of a `VariantDescription` object. + + :param vdesc: `VariantDescription` object. + :return: Rank tuple of the `VariantDescription` object. + """ + # using a set for performance reason: O(1) access time. + vdesc_prop_hashes = {vprop.property_hash for vprop in vdesc.properties} + + # N-dimensional tuple with tuple[N] of 1 or sys.maxsize + # 1 if the property is present in the `VariantDescription` object, + # sys.maxsize if not present. + # This is used to sort the `VariantDescription` objects based on their + # `VariantProperty`s. + ranking_tuple = tuple( + 1 if vprop_hash in vdesc_prop_hashes else sys.maxsize + for vprop_hash in property_hash_priorities + ) + + if sum(1 for x in ranking_tuple if x != sys.maxsize) != len(vdesc.properties): + raise ValidationError( + f"VariantDescription {vdesc} contains properties not in the property " + "priorities list - this should not happen. Filtering should be applied " + "first." + ) + + return ranking_tuple + + return sorted(vdescs, key=_get_rank_tuple) From e58433e33f797e961b2d935f5a8ecf0cf5ed5c1a Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Fri, 4 Apr 2025 17:12:19 -0400 Subject: [PATCH 2/9] WIP on sorting_and_filtering --- tests/resolver/test_lib.py | 114 +++++++++++++++++++++++++++++++++ tests/resolver/test_sorting.py | 12 +++- variantlib/configuration.py | 2 +- variantlib/constants.py | 2 +- variantlib/resolver/lib.py | 69 +++++--------------- variantlib/resolver/sorting.py | 9 ++- 6 files changed, 150 insertions(+), 58 deletions(-) diff --git a/tests/resolver/test_lib.py b/tests/resolver/test_lib.py index e9c17cd..a21398f 100644 --- a/tests/resolver/test_lib.py +++ b/tests/resolver/test_lib.py @@ -2,6 +2,7 @@ import pytest +from variantlib.errors import ValidationError from variantlib.models.variant import VariantDescription from variantlib.models.variant import VariantFeature from variantlib.models.variant import VariantProperty @@ -9,6 +10,7 @@ from variantlib.resolver.filtering import filter_variants_by_namespaces from variantlib.resolver.filtering import remove_duplicates from variantlib.resolver.lib import filter_variants +from variantlib.resolver.lib import sort_and_filter_supported_variants @pytest.fixture(scope="session") @@ -273,3 +275,115 @@ def test_filter_variants_remove_duplicates_and_features( ) == expected_vdescs ) + + +# =================== `sort_and_filter_supported_variants` ================== # + + +def test_sort_and_filter_supported_variants( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + # vprops = [ + # VariantProperty(namespace="OmniCorp", feature="access_key", value="value1"), + # VariantProperty( + # namespace="TyrellCorporation", feature="client_id", value="value2" + # ), + # VariantProperty( + # namespace="TyrellCorporation", feature="client_pass", value="abcde" + # ), + # VariantProperty( + # namespace="TyrellCorporation", feature="client_pass", value="efghij" + # ), + # ] + + # vdescs = [ + # # prop3 and prop4 are mutually exclusive + # [00] VariantDescription([prop1, prop2, prop3]), # Rank 0 + # [01] VariantDescription([prop1, prop3, prop2]), # duplicate + # [02] VariantDescription([prop2, prop3, prop1]), # duplicate + # [03] VariantDescription([prop2, prop1, prop3]), # duplicate + # [04] VariantDescription([prop3, prop2, prop1]), # duplicate + # [05] VariantDescription([prop3, prop1, prop2]), # duplicate + # [06] VariantDescription([prop1, prop2, prop4]), # rank 2 + # [07] VariantDescription([prop1, prop4, prop2]), # duplicate + # [08] VariantDescription([prop2, prop4, prop1]), # duplicate + # [09] VariantDescription([prop2, prop1, prop4]), # duplicate + # [10] VariantDescription([prop4, prop2, prop1]), # duplicate + # [11] VariantDescription([prop4, prop1, prop2]), # duplicate + # [12] VariantDescription([prop1, prop2]), # Rank 4 + # [13] VariantDescription([prop2, prop1]), # duplicate + # [14] VariantDescription([prop1, prop3]), # Rank 6 + # [15] VariantDescription([prop3, prop1]), # duplicate + # [16] VariantDescription([prop2, prop3]), # Rank 1 + # [17] VariantDescription([prop3, prop2]), # duplicate + # [18] VariantDescription([prop1, prop4]), # Rank 8 + # [19] VariantDescription([prop4, prop1]), # duplicate + # [20] VariantDescription([prop2, prop4]), # Rank 3 + # [21] VariantDescription([prop4, prop2]), # duplicate + # [22] VariantDescription([prop1]), # Rank 10 + # [23] VariantDescription([prop2]), # Rank 5 + # [24] VariantDescription([prop3]), # Rank 7 + # [25] VariantDescription([prop4]), # Rank 9 + # ] + vprop1 = VariantProperty( + namespace="TyrellCorporation", feature="client_id", value="value2" + ) + vfeat1 = VariantFeature(namespace="TyrellCorporation", feature="client_pass") + + assert sort_and_filter_supported_variants( + vdescs=vdescs, + supported_vprops=vprops, + property_priorities=[vprop1], + feature_priorities=[vfeat1], + namespace_priorities=["OmniCorp", "TyrellCorporation"], + ) == [ + # everything that contains `vprop2` with vfeat1 important and OmniCorp first + vdescs[0], # 0 + vdescs[16], # 1 + vdescs[6], # 2 + vdescs[20], # 3 + vdescs[12], # 4 + vdescs[23], # 5 + # everything that contains `vfeat1` + vdescs[14], # 6 + vdescs[24], # 7 + vdescs[18], # 8 + vdescs[25], # 9 + # lastly prop1 alone - ranked by namespace alone + vdescs[22], # 10 + ] + + +def test_sort_and_filter_supported_variants_validation_errors( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + with pytest.raises(ValidationError): + sort_and_filter_supported_variants( + vdescs="not a list", # type: ignore [arg-type] + supported_vprops=vprops, + ) + + with pytest.raises(ValidationError): + sort_and_filter_supported_variants( + vdescs=["not a VariantDescription"], # type: ignore [list-item] + supported_vprops=vprops, + ) + + with pytest.raises(ValidationError): + sort_and_filter_supported_variants( + vdescs=vdescs, + supported_vprops="not a list", # type: ignore [arg-type] + ) + + with pytest.raises(ValidationError): + sort_and_filter_supported_variants( + vdescs=vdescs, + supported_vprops=["not a VariantProperty"], # type: ignore [list-item] + ) + + # This one specifies no ordering/priority => can't sort + with pytest.raises(ValidationError, match="has no priority"): + sort_and_filter_supported_variants( + vdescs=vdescs, + supported_vprops=vprops, + ) diff --git a/tests/resolver/test_sorting.py b/tests/resolver/test_sorting.py index e1e11f1..95afaf9 100644 --- a/tests/resolver/test_sorting.py +++ b/tests/resolver/test_sorting.py @@ -171,7 +171,6 @@ def test_sort_variant_properties(): VariantProperty(namespace="OtherCorp", feature="featB", value="value2"), VariantProperty(namespace="OtherCorp", feature="featC", value="value"), VariantProperty(namespace="OtherCorp", feature="featD", value="value"), - VariantProperty(namespace="AnyCorp", feature="feature", value="value"), ] property_priorities = [ VariantProperty(namespace="OtherCorp", feature="featA", value="value"), @@ -201,8 +200,6 @@ def test_sort_variant_properties(): # sorted by namespace priorities VariantProperty(namespace="OmniCorp", feature="featD", value="value"), VariantProperty(namespace="OtherCorp", feature="featD", value="value"), - # not a listed namespace or feature or property - VariantProperty(namespace="AnyCorp", feature="feature", value="value"), ] @@ -272,6 +269,15 @@ def test_sort_variant_properties_validation_error(): namespace_priorities=[{"not a str": True}], # type: ignore[list-item] ) + # Can't sort without ordering priorities + with pytest.raises(ValidationError, match="has no priority"): + sort_variant_properties( + vprops=vprops, + property_priorities=None, + feature_priorities=None, + namespace_priorities=None, # type: ignore[list-item] + ) + # ========================= sort_variants_descriptions ========================= # diff --git a/variantlib/configuration.py b/variantlib/configuration.py index 5c088a5..d107fb6 100644 --- a/variantlib/configuration.py +++ b/variantlib/configuration.py @@ -95,7 +95,7 @@ def get_config(cls) -> ConfigurationModel: with cfg_f.open("rb") as f: try: if config_name == ConfigEnvironments.PROJECT: - config = tomllib.load(f)["tool"]["variantlib"] + config = tomllib.load(f)["variants"] else: config = tomllib.load(f) diff --git a/variantlib/constants.py b/variantlib/constants.py index af591f3..31319a7 100644 --- a/variantlib/constants.py +++ b/variantlib/constants.py @@ -3,7 +3,7 @@ import re VARIANT_HASH_LEN = 8 -CONFIG_FILENAME = "wheelvariant.toml" +CONFIG_FILENAME = "variants.toml" VALIDATION_NAMESPACE_REGEX = r"^[A-Za-z0-9_]+$" VALIDATION_FEATURE_REGEX = r"^[A-Za-z0-9_]+$" diff --git a/variantlib/resolver/lib.py b/variantlib/resolver/lib.py index 0ff3f57..874cb8b 100644 --- a/variantlib/resolver/lib.py +++ b/variantlib/resolver/lib.py @@ -90,47 +90,6 @@ def filter_variants( yield from result -def sort_variants_by_supported_properties( - vdescs: list[VariantDescription], - supported_vprops: list[VariantProperty], - property_priorities: list[VariantProperty] | None = None, - feature_priorities: list[VariantFeature] | None = None, - namespace_priorities: list[str] | None = None, -) -> list[VariantDescription]: - """ - Sort a list of `VariantDescription` objects based on their `VariantProperty`s. - - :param vdescs: List of `VariantDescription` objects. - :param supported_vprops: list of `VariantProperty` objects supported on the platform - :param property_priorities: ordered list of `VariantProperty` objects. - :param feature_priorities: ordered list of `VariantFeature` objects. - :param namespace_priorities: ordered list of `str` objects. - :return: Sorted list of `VariantDescription` objects. - """ - validate_instance_of(vdescs, list) - validate_list_of(vdescs, VariantDescription) - - if supported_vprops is None: - return [] - - validate_instance_of(supported_vprops, list) - validate_list_of(supported_vprops, VariantProperty) - - # Step 1: we sort the supported properties based on the priorities - sorted_supported_vprops = sort_variant_properties( - vprops=supported_vprops, - property_priorities=property_priorities, - feature_priorities=feature_priorities, - namespace_priorities=namespace_priorities, - ) - - # Step 2: we sort the `VariantDescription` based on the sorted supported properties - return sort_variants_descriptions( - vdescs, - property_priorities=sorted_supported_vprops, - ) - - def sort_and_filter_supported_variants( vdescs: list[VariantDescription], supported_vprops: list[VariantProperty] | None = None, @@ -160,20 +119,26 @@ def sort_and_filter_supported_variants( validate_list_of(supported_vprops, VariantProperty) # Step 1: we remove any duplicate, or unsupported `VariantDescription` on - # this platform. - filtered_vdescs = filter_variants( - vdescs=vdescs, - allowed_properties=supported_vprops, - allowed_features=feature_priorities, - allowed_namespaces=namespace_priorities, + # this platform. + filtered_vdescs = list( + filter_variants( + vdescs=vdescs, + allowed_namespaces=namespace_priorities, + ) ) - # Step 2: we sort the `VariantDescription` based on the supported properties - # and the priorities - return sort_variants_by_supported_properties( - vdescs=list(filtered_vdescs), - supported_vprops=supported_vprops, + # Step 2: we sort the supported `VariantProperty`s based on their respective + # priority. + sorted_supported_vprops = sort_variant_properties( + vprops=supported_vprops, property_priorities=property_priorities, feature_priorities=feature_priorities, namespace_priorities=namespace_priorities, ) + + # Step 3: we sort the `VariantDescription` based on the sorted supported properties + # and their respective priority. + return sort_variants_descriptions( + filtered_vdescs, + property_priorities=sorted_supported_vprops, + ) diff --git a/variantlib/resolver/sorting.py b/variantlib/resolver/sorting.py index 3787eab..529a496 100644 --- a/variantlib/resolver/sorting.py +++ b/variantlib/resolver/sorting.py @@ -109,7 +109,7 @@ def get_variant_property_priority_tuple( """ validate_instance_of(vprop, VariantProperty) - return ( + ranking_tuple = ( # First Priority get_property_priority(vprop, property_priorities), # Second Priority @@ -118,6 +118,13 @@ def get_variant_property_priority_tuple( get_namespace_priority(vprop, namespace_priorities), ) + if all(x == sys.maxsize for x in ranking_tuple): + raise ValidationError( + f"VariantProperty {vprop} has no priority - this should not happen." + ) + + return ranking_tuple + def sort_variant_properties( vprops: list[VariantProperty], From 4cd24213a9401b3349b8580afb6339906ce9eacb Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Sun, 6 Apr 2025 15:33:33 -0400 Subject: [PATCH 3/9] Adding some unittests --- pyproject.toml | 2 + tests/models/test_configuration.py | 107 ++++++++++++++++++++ tests/test_configuration.py | 151 +++++++++++++++++++++++++++++ variantlib/configuration.py | 30 +++--- variantlib/models/configuration.py | 34 ++++--- variantlib/models/variant.py | 6 +- variantlib/resolver/sorting.py | 15 ++- variants.dist.toml | 38 ++++++++ 8 files changed, 352 insertions(+), 31 deletions(-) create mode 100644 tests/models/test_configuration.py create mode 100644 tests/test_configuration.py create mode 100644 variants.dist.toml diff --git a/pyproject.toml b/pyproject.toml index 9446c67..32ae00e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ maintainers = [ ] dependencies = [ "importlib-metadata; python_version < '3.10'", + "platformdirs>=4.3,<5.0", + "tomli; python_version < '3.11'", "typing-extensions; python_version < '3.11'", ] diff --git a/tests/models/test_configuration.py b/tests/models/test_configuration.py new file mode 100644 index 0000000..8513148 --- /dev/null +++ b/tests/models/test_configuration.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from variantlib.constants import VALIDATION_FEATURE_REGEX +from variantlib.constants import VALIDATION_NAMESPACE_REGEX +from variantlib.constants import VALIDATION_VALUE_REGEX +from variantlib.models.configuration import VariantConfiguration +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty + + +def test_default_configuration(): + config = VariantConfiguration.default() + assert config.namespaces_priority == [] + assert config.features_priority == [] + assert config.property_priority == [] + + +@pytest.mark.parametrize( + "config_params", + [ + { + "namespaces": ["OmniCorp"], + "features": [], + "properties": [], + }, + { + "namespaces": ["OmniCorp"], + "features": ["OmniCorp::custom_feat"], + "properties": ["OmniCorp::custom_feat::secret_value"], + }, + { + "namespaces": ["OmniCorp", "AcmeCorp"], + "features": ["OmniCorp::custom_feat", "AcmeCorp :: custom_feat"], + "properties": [ + "OmniCorp :: custom_featA :: secret_value", + "OmniCorp :: custom_featB:: secret_value", + "AcmeCorp::custom_feat::secret_value", + ], + }, + ], +) +def test_from_toml_config(config_params: dict[str, list[str]]): + _ = VariantConfiguration.from_toml_config( + namespaces_priority=config_params["namespaces"], + features_priority=config_params["features"], + property_priority=config_params["properties"], + ) + + +@given(st.lists(st.from_regex(VALIDATION_NAMESPACE_REGEX))) +def test_namespaces_priority_validation(namespaces: list[str]): + config = VariantConfiguration(namespaces_priority=namespaces) + assert config.namespaces_priority == namespaces + + +@given( + st.lists(st.from_regex(VALIDATION_NAMESPACE_REGEX)), + st.lists( + st.builds( + VariantFeature, + namespace=st.just("OmniCorp"), + feature=st.from_regex(VALIDATION_FEATURE_REGEX), + ) + ), +) +def test_features_priority_validation( + namespaces: list[str], features: list[VariantFeature] +): + config = VariantConfiguration( + namespaces_priority=namespaces, features_priority=features + ) + assert config.features_priority == features + + +@given( + st.lists(st.from_regex(VALIDATION_NAMESPACE_REGEX)), + st.lists( + st.builds( + VariantFeature, + namespace=st.from_regex(VALIDATION_NAMESPACE_REGEX), + feature=st.from_regex(VALIDATION_FEATURE_REGEX), + ) + ), + st.lists( + st.builds( + VariantProperty, + namespace=st.from_regex(VALIDATION_NAMESPACE_REGEX), + feature=st.from_regex(VALIDATION_FEATURE_REGEX), + value=st.from_regex(VALIDATION_VALUE_REGEX), + ) + ), +) +def test_property_priority_validation( + namespaces: list[str], + features: list[VariantFeature], + properties: list[VariantProperty], +): + config = VariantConfiguration( + namespaces_priority=namespaces, + features_priority=features, + property_priority=properties, + ) + assert config.property_priority == properties diff --git a/tests/test_configuration.py b/tests/test_configuration.py new file mode 100644 index 0000000..7174f4f --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +import platformdirs + +from variantlib.configuration import ConfigEnvironments +from variantlib.configuration import VariantConfiguration +from variantlib.configuration import get_configuration_files +from variantlib.constants import CONFIG_FILENAME +from variantlib.models.configuration import VariantConfiguration as ConfigurationModel +from variantlib.models.variant import VariantFeature +from variantlib.models.variant import VariantProperty + + +def dict_to_toml(d: dict, indent: int = 0) -> str: + lines = [] + for key, value in d.items(): + if isinstance(value, dict): + lines.append(f"\n{' ' * indent}[{key}]") + lines.append(dict_to_toml(value, indent)) + elif isinstance(value, list): + items = ", ".join(f'"{v}"' if isinstance(v, str) else str(v) for v in value) + lines.append(f"{' ' * indent}{key} = [{items}]") + elif isinstance(value, str): + lines.append(f'{" " * indent}{key} = "{value}"') + else: + raise TypeError(f"Unsupported type for key '{key}': {type(value)}") + return "\n".join(lines) + + +def test_reset(): + VariantConfiguration._config = ConfigurationModel.default() # noqa: SLF001 + assert VariantConfiguration._config is not None # noqa: SLF001 + VariantConfiguration.reset() + assert VariantConfiguration._config is None # noqa: SLF001 + + +def test_get_configuration_files(): + config_files = get_configuration_files() + assert config_files[ConfigEnvironments.LOCAL] == Path.cwd() / CONFIG_FILENAME + assert ( + config_files[ConfigEnvironments.VIRTUALENV] + == Path(sys.prefix) / CONFIG_FILENAME + ) + assert ( + config_files[ConfigEnvironments.USER] + == Path( + platformdirs.user_config_dir("variantlib", appauthor=False, roaming=True) + ) + / CONFIG_FILENAME + ) + assert ( + config_files[ConfigEnvironments.GLOBAL] + == Path(platformdirs.site_config_dir("variantlib", appauthor=False)) + / CONFIG_FILENAME + ) + + +@patch("variantlib.configuration.get_configuration_files") +def test_get_config_no_files(mock_get_config_files): + mock_get_config_files.return_value = { + ConfigEnvironments.LOCAL: Path("/nonexistent/config.toml"), + ConfigEnvironments.VIRTUALENV: Path("/nonexistent/config.toml"), + ConfigEnvironments.USER: Path("/nonexistent/config.toml"), + ConfigEnvironments.GLOBAL: Path("/nonexistent/config.toml"), + } + config = VariantConfiguration.get_config() + assert config == ConfigurationModel.default() + + +@patch("variantlib.configuration.get_configuration_files") +def test_get_default_config_with_no_file(mock_get_config_files): + mock_get_config_files.return_value = { + ConfigEnvironments.LOCAL: Path("/nonexistent/config.toml"), + ConfigEnvironments.VIRTUALENV: Path("/nonexistent/config.toml"), + ConfigEnvironments.USER: Path("/nonexistent/config.toml"), + ConfigEnvironments.GLOBAL: Path("/nonexistent/config.toml"), + } + config = VariantConfiguration.get_config() + assert config == ConfigurationModel.default() + + +@patch("variantlib.configuration.get_configuration_files") +def test_get_config_from_file(mock_get_config_files): + data = { + "property_priority": [ + "fictional_hw::architecture::mother", + "fictional_tech::risk_exposure::25", + ], + "features_priority": [ + "fictional_hw::architecture", + "fictional_tech::risk_exposure", + "simd_x86_64::feature3", + ], + "namespaces_priority": [ + "fictional_hw", + "fictional_tech", + "simd_x86_64", + "non_existent_provider", + ], + } + with tempfile.NamedTemporaryFile(mode="w+", suffix=".toml") as temp_file: + temp_file.write(dict_to_toml(data)) + temp_file.flush() + + def _get_config_files() -> dict[ConfigEnvironments, Path]: + return { + ConfigEnvironments.LOCAL: Path("/nonexistent/config.toml"), + ConfigEnvironments.VIRTUALENV: Path("/nonexistent/config.toml"), + ConfigEnvironments.USER: Path("/nonexistent/config.toml"), + ConfigEnvironments.GLOBAL: Path("/nonexistent/config.toml"), + } + + features_priority = [ + VariantFeature.from_str(f) for f in data["features_priority"] + ] + + property_priority = [ + VariantProperty.from_str(f) for f in data["property_priority"] + ] + + for env in ConfigEnvironments: + config_files = _get_config_files() + config_files[env] = Path(temp_file.name) + mock_get_config_files.return_value = config_files + + config = VariantConfiguration.get_config() + assert config.features_priority == features_priority + assert config.property_priority == property_priority + assert config.namespaces_priority == data["namespaces_priority"] + + +def test_class_properties_with_default(): + VariantConfiguration.reset() + assert VariantConfiguration._config is None # noqa: SLF001 + assert VariantConfiguration.namespaces_priority == [] + assert VariantConfiguration._config is not None # noqa: SLF001 + + VariantConfiguration.reset() + assert VariantConfiguration._config is None # noqa: SLF001 + assert VariantConfiguration.features_priority == [] + assert VariantConfiguration._config is not None # noqa: SLF001 + + VariantConfiguration.reset() + assert VariantConfiguration._config is None # noqa: SLF001 + assert VariantConfiguration.property_priority == [] + assert VariantConfiguration._config is not None # noqa: SLF001 diff --git a/variantlib/configuration.py b/variantlib/configuration.py index d107fb6..3fc036f 100644 --- a/variantlib/configuration.py +++ b/variantlib/configuration.py @@ -9,37 +9,41 @@ from typing import TYPE_CHECKING from typing import Any from typing import Callable -from typing import Self from typing import TypeVar import platformdirs -import tomllib from variantlib import errors from variantlib.constants import CONFIG_FILENAME -from variantlib.models.configuration import Configuration as ConfigurationModel +from variantlib.models.configuration import VariantConfiguration as ConfigurationModel from variantlib.utils import classproperty if TYPE_CHECKING: from variantlib.models.variant import VariantFeature from variantlib.models.variant import VariantProperty +if sys.version_info >= (3, 11): + from typing import Self + + import tomllib +else: + import tomli as tomllib + from typing_extensions import Self + logger = logging.getLogger(__name__) class ConfigEnvironments(IntEnum): LOCAL = 1 - PROJECT = 2 - VIRTUALENV = 3 - USER = 4 - GLOBAL = 5 + VIRTUALENV = 2 + USER = 3 + GLOBAL = 4 @cache def get_configuration_files() -> dict[ConfigEnvironments, Path]: return { ConfigEnvironments.LOCAL: Path.cwd() / CONFIG_FILENAME, - ConfigEnvironments.PROJECT: Path.cwd() / "pyproject.toml", ConfigEnvironments.VIRTUALENV: Path(sys.prefix) / CONFIG_FILENAME, ConfigEnvironments.USER: ( Path( @@ -61,7 +65,7 @@ def get_configuration_files() -> dict[ConfigEnvironments, Path]: def check_initialized(func: Callable[..., R]) -> Callable[..., R]: @wraps(func) - def wrapper(cls: type[Configuration], *args: Any, **kwargs: Any) -> R: + def wrapper(cls: type[VariantConfiguration], *args: Any, **kwargs: Any) -> R: if cls._config is None: cls._config = cls.get_config() @@ -70,7 +74,7 @@ def wrapper(cls: type[Configuration], *args: Any, **kwargs: Any) -> R: return wrapper -class Configuration: +class VariantConfiguration: _config: ConfigurationModel | None = None def __new__(cls, *args: Any, **kwargs: dict[str, Any]) -> Self: @@ -94,11 +98,7 @@ def get_config(cls) -> ConfigurationModel: logger.info("Loading configuration file: %s", config_files[config_name]) with cfg_f.open("rb") as f: try: - if config_name == ConfigEnvironments.PROJECT: - config = tomllib.load(f)["variants"] - else: - config = tomllib.load(f) - + config = tomllib.load(f) except tomllib.TOMLDecodeError as e: raise errors.ConfigurationError from e diff --git a/variantlib/models/configuration.py b/variantlib/models/configuration.py index 05c7974..18c85d9 100644 --- a/variantlib/models/configuration.py +++ b/variantlib/models/configuration.py @@ -1,8 +1,8 @@ from __future__ import annotations +import sys from dataclasses import dataclass from dataclasses import field -from typing import Self from variantlib.constants import VALIDATION_NAMESPACE_REGEX from variantlib.models.base import BaseModel @@ -13,9 +13,14 @@ from variantlib.models.variant import VariantFeature from variantlib.models.variant import VariantProperty +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + @dataclass(frozen=True) -class Configuration(BaseModel): +class VariantConfiguration(BaseModel): """ Configuration class for variantlib. @@ -23,9 +28,12 @@ class Configuration(BaseModel): It includes fields for the namespace, feature, and value, along with validation checks for each field. + # Sorting Note: First is best. + Attributes: - namespaces_by_priority (list): The namespace of the configuration. - features_by_priority (list): The feature of the configuration. + namespaces_priority (list): Sorted list of "variant namespaces" by priority. + features_priority (list): Sorted list of `VariantFeature` by priority. + property_priority (list): Sorted list of `VariantProperty` by priority. """ namespaces_priority: list[str] = field( @@ -50,7 +58,8 @@ class Configuration(BaseModel): ], value=val, ) - } + }, + default_factory=list, ) property_priority: list[VariantProperty] = field( @@ -62,16 +71,17 @@ class Configuration(BaseModel): ], value=val, ) - } + }, + default_factory=list, ) @classmethod def default(cls) -> Self: """ - Create a default Configuration instance. + Create a default `VariantConfiguration` instance. Returns: - Configuration: A new Configuration instance with default values. + VariantConfiguration: A new instance with default values. """ # TODO: Verify the default values make sense @@ -99,16 +109,16 @@ def from_toml_config( # Convert the `features_priority: list[str]` into `list[VariantFeature]` _features_priority: list[VariantFeature] = [] if features_priority is not None: - for feature in features_priority: - validate_instance_of(feature, str) - _features_priority.append(VariantFeature.from_str(feature)) + for vfeat in features_priority: + validate_instance_of(vfeat, str) + _features_priority.append(VariantFeature.from_str(vfeat)) # Convert the `property_priority: list[str]` into `list[VariantProperty]` _property_priority: list[VariantProperty] = [] if property_priority is not None: for vprop in property_priority: validate_instance_of(vprop, str) - _property_priority.append(VariantProperty.from_str(feature)) + _property_priority.append(VariantProperty.from_str(vprop)) return cls( namespaces_priority=namespaces_priority or [], diff --git a/variantlib/models/variant.py b/variantlib/models/variant.py index e56389f..c64ecbd 100644 --- a/variantlib/models/variant.py +++ b/variantlib/models/variant.py @@ -84,7 +84,7 @@ def from_str(cls, input_str: str) -> Self: if match is None: raise ValidationError( - f"Invalid format: {input_str}. Expected format: " + f"Invalid format: `{input_str}`, expected format: " "' :: '" ) @@ -133,8 +133,8 @@ def from_str(cls, input_str: str) -> Self: if match is None: raise ValidationError( - f"Invalid format: {input_str}. " - "Expected format: ' :: :: '" + f"Invalid format: `{input_str}`, " + "expected format: ` :: :: `" ) # Extract the namespace, feature, and value from the match groups diff --git a/variantlib/resolver/sorting.py b/variantlib/resolver/sorting.py index 529a496..b8712bd 100644 --- a/variantlib/resolver/sorting.py +++ b/variantlib/resolver/sorting.py @@ -178,8 +178,21 @@ def _get_rank_tuple(vdesc: VariantDescription) -> tuple[int, ...]: Get the rank tuple of a `VariantDescription` object. :param vdesc: `VariantDescription` object. - :return: Rank tuple of the `VariantDescription` object. + :return: Rank tuple[int, ...] of the `VariantDescription` object. """ + + # --------------------------- Implementation Notes --------------------------- # + # - `property_hash_priorities` is ordered. It's a list. + # - `vdesc_prop_hashes` is unordered. It's a set. + # + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Performance Notes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # + # * Only `property_hash_priorities` needs to be ordered. The set is used for + # performance reasons. + # * `vdesc.properties` hashes are pre-computed and saved to avoid recomputing + # them multiple times. + # * `property_priorities` are also pre-hashed to avoid recomputing them + # ---------------------------------------------------------------------------- # + # using a set for performance reason: O(1) access time. vdesc_prop_hashes = {vprop.property_hash for vprop in vdesc.properties} diff --git a/variants.dist.toml b/variants.dist.toml new file mode 100644 index 0000000..5c7c9ac --- /dev/null +++ b/variants.dist.toml @@ -0,0 +1,38 @@ +# ======================= EXAMPLE `variants.toml` FILE ====================== # + +# ============= Top-Most Priority ============= # + +# 1. Define the variant properties `VariantProperty` to "bump to the top of the priority list" +# Expected format: "namespace::feature::value" +# OPTIONAL: In most cases - the user will not specify the following +# Note: This is a lazy-list: Only specify the ones you want to "bump up" to the top of the list +property_priority = [ + "fictional_hw::architecture::mother", + "fictional_tech::risk_exposure:25", +] + +# ============= Second-Most Priority ============= # + +# 2. Define the variant features: `VariantFeature` to "bump to the top of the priority list" +# These come after the ones defined in 1) [`property_priority`] +# Expected format: "namespace::feature" +# OPTIONAL: In most cases - the user will not specify the following +# Note: This is a lazy-list: Only specify the ones you want to "bump up" to the top of the list +features_priority = [ + "fictional_hw::architecture", + "fictional_tech::risk_exposure", + "simd_x86_64::feature3", +] + +# ============= Default Priority Ordering ============= # + +# 3. Define the variant namespaces by priority +# MANDATORY: as long as there is more than 1 variant plugin installed. This field must be specified +# If not one installed plugin is missing => should error out. +# Namespaces can be specified but not installed. +namespaces_priority = [ + "fictional_hw", + "fictional_tech", + "simd_x86_64", + "non_existent_provider", +] From 79c8ff3f6d6713269ff33e0708583bfb36cdb072 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Mon, 14 Apr 2025 13:32:48 -0400 Subject: [PATCH 4/9] Validation API Update --- variantlib/models/configuration.py | 16 ++++++---------- variantlib/resolver/filtering.py | 28 ++++++++++++---------------- variantlib/resolver/lib.py | 9 +++------ variantlib/resolver/sorting.py | 30 +++++++++++------------------- 4 files changed, 32 insertions(+), 51 deletions(-) diff --git a/variantlib/models/configuration.py b/variantlib/models/configuration.py index 18c85d9..9b7d26a 100644 --- a/variantlib/models/configuration.py +++ b/variantlib/models/configuration.py @@ -7,9 +7,8 @@ from variantlib.constants import VALIDATION_NAMESPACE_REGEX from variantlib.models.base import BaseModel from variantlib.models.validators import validate_and -from variantlib.models.validators import validate_instance_of from variantlib.models.validators import validate_list_matches_re -from variantlib.models.validators import validate_list_of +from variantlib.models.validators import validate_type from variantlib.models.variant import VariantFeature from variantlib.models.variant import VariantProperty @@ -40,8 +39,7 @@ class VariantConfiguration(BaseModel): metadata={ "validator": lambda val: validate_and( [ - lambda v: validate_instance_of(v, list), - lambda v: validate_list_of(v, str), + lambda v: validate_type(v, list[str]), lambda v: validate_list_matches_re(v, VALIDATION_NAMESPACE_REGEX), ], value=val, @@ -53,8 +51,7 @@ class VariantConfiguration(BaseModel): metadata={ "validator": lambda val: validate_and( [ - lambda v: validate_instance_of(v, list), - lambda v: validate_list_of(v, VariantFeature), + lambda v: validate_type(v, list[VariantFeature]), ], value=val, ) @@ -66,8 +63,7 @@ class VariantConfiguration(BaseModel): metadata={ "validator": lambda val: validate_and( [ - lambda v: validate_instance_of(v, list), - lambda v: validate_list_of(v, VariantProperty), + lambda v: validate_type(v, list[VariantProperty]), ], value=val, ) @@ -110,14 +106,14 @@ def from_toml_config( _features_priority: list[VariantFeature] = [] if features_priority is not None: for vfeat in features_priority: - validate_instance_of(vfeat, str) + validate_type(vfeat, str) _features_priority.append(VariantFeature.from_str(vfeat)) # Convert the `property_priority: list[str]` into `list[VariantProperty]` _property_priority: list[VariantProperty] = [] if property_priority is not None: for vprop in property_priority: - validate_instance_of(vprop, str) + validate_type(vprop, str) _property_priority.append(VariantProperty.from_str(vprop)) return cls( diff --git a/variantlib/resolver/filtering.py b/variantlib/resolver/filtering.py index 0513c7b..d710098 100644 --- a/variantlib/resolver/filtering.py +++ b/variantlib/resolver/filtering.py @@ -4,8 +4,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING -from variantlib.models.validators import validate_instance_of -from variantlib.models.validators import validate_list_of +from variantlib.models.validators import validate_type from variantlib.models.variant import VariantDescription from variantlib.models.variant import VariantFeature from variantlib.models.variant import VariantProperty @@ -20,7 +19,7 @@ def remove_duplicates( vdescs: Iterable[VariantDescription], ) -> Generator[VariantDescription]: # Input validation - validate_instance_of(vdescs, Iterable) + validate_type(vdescs, Iterable) seen = set() @@ -28,7 +27,7 @@ def _should_include(vdesc: VariantDescription) -> bool: """ Check if any of the namespaces in the variant description are not allowed. """ - validate_instance_of(vdesc, VariantDescription) + validate_type(vdesc, VariantDescription) if vdesc.hexdigest in seen: logger.info( @@ -55,9 +54,8 @@ def filter_variants_by_namespaces( """ # Input validation - validate_instance_of(vdescs, Iterable) - validate_instance_of(allowed_namespaces, list) - validate_list_of(allowed_namespaces, str) + validate_type(vdescs, Iterable) + validate_type(allowed_namespaces, list[str]) # Note: for performance reasons we convert the list to a set to avoid O(n) lookups _allowed_namespaces = set(allowed_namespaces) @@ -66,7 +64,7 @@ def _should_include(vdesc: VariantDescription) -> bool: """ Check if any of the namespaces in the variant description are not allowed. """ - validate_instance_of(vdesc, VariantDescription) + validate_type(vdesc, VariantDescription) if any( vprop.namespace not in _allowed_namespaces for vprop in vdesc.properties @@ -102,9 +100,8 @@ def filter_variants_by_features( """ # Input validation - validate_instance_of(vdescs, Iterable) - validate_instance_of(allowed_features, list) - validate_list_of(allowed_features, VariantFeature) + validate_type(vdescs, Iterable) + validate_type(allowed_features, list[VariantFeature]) # for performance reasons we convert the list to a set to avoid O(n) lookups allowed_feature_hexs = {vfeat.feature_hash for vfeat in allowed_features} @@ -113,7 +110,7 @@ def _should_include(vdesc: VariantDescription) -> bool: """ Check if any of the namespaces in the variant description are not allowed. """ - validate_instance_of(vdesc, VariantDescription) + validate_type(vdesc, VariantDescription) if forbidden_vprops := [ vprop @@ -147,9 +144,8 @@ def filter_variants_by_property( """ # Input validation - validate_instance_of(vdescs, Iterable) - validate_instance_of(allowed_properties, list) - validate_list_of(allowed_properties, VariantProperty) + validate_type(vdescs, Iterable) + validate_type(allowed_properties, list[VariantProperty]) # for performance reasons we convert the list to a set to avoid O(n) lookups allowed_properties_hexs = {vfeat.property_hash for vfeat in allowed_properties} @@ -158,7 +154,7 @@ def _should_include(vdesc: VariantDescription) -> bool: """ Check if any of the namespaces in the variant description are not allowed. """ - validate_instance_of(vdesc, VariantDescription) + validate_type(vdesc, VariantDescription) if forbidden_vprops := [ vprop diff --git a/variantlib/resolver/lib.py b/variantlib/resolver/lib.py index 874cb8b..e3a130f 100644 --- a/variantlib/resolver/lib.py +++ b/variantlib/resolver/lib.py @@ -2,8 +2,7 @@ from typing import TYPE_CHECKING -from variantlib.models.validators import validate_instance_of -from variantlib.models.validators import validate_list_of +from variantlib.models.validators import validate_type from variantlib.models.variant import VariantDescription from variantlib.models.variant import VariantProperty from variantlib.resolver.filtering import filter_variants_by_features @@ -108,15 +107,13 @@ def sort_and_filter_supported_variants( :param namespace_priorities: Ordered list of `str` objects. :return: Sorted and filtered list of `VariantDescription` objects. """ - validate_instance_of(vdescs, list) - validate_list_of(vdescs, VariantDescription) + validate_type(vdescs, list[VariantDescription]) if supported_vprops is None: """No sdupported properties provided, return no variants.""" return [] - validate_instance_of(supported_vprops, list) - validate_list_of(supported_vprops, VariantProperty) + validate_type(supported_vprops, list[VariantProperty]) # Step 1: we remove any duplicate, or unsupported `VariantDescription` on # this platform. diff --git a/variantlib/resolver/sorting.py b/variantlib/resolver/sorting.py index b8712bd..856478b 100644 --- a/variantlib/resolver/sorting.py +++ b/variantlib/resolver/sorting.py @@ -4,8 +4,7 @@ import sys from variantlib.errors import ValidationError -from variantlib.models.validators import validate_instance_of -from variantlib.models.validators import validate_list_of +from variantlib.models.validators import validate_type from variantlib.models.variant import VariantDescription from variantlib.models.variant import VariantFeature from variantlib.models.variant import VariantProperty @@ -24,12 +23,11 @@ def get_property_priority( :param property_priorities: ordered list of `VariantProperty` objects. :return: Property priority of the `VariantProperty` object. """ - validate_instance_of(vprop, VariantProperty) + validate_type(vprop, VariantProperty) if property_priorities is None: return sys.maxsize - validate_instance_of(property_priorities, list) - validate_list_of(property_priorities, VariantProperty) + validate_type(property_priorities, list[VariantProperty]) _property_priorities = [vprop.property_hash for vprop in property_priorities] @@ -51,12 +49,11 @@ def get_feature_priority( :param feature_priorities: ordered list of `VariantFeature` objects. :return: Feature priority of the `VariantProperty` object. """ - validate_instance_of(vprop, VariantProperty) + validate_type(vprop, VariantProperty) if feature_priorities is None: return sys.maxsize - validate_instance_of(feature_priorities, list) - validate_list_of(feature_priorities, VariantFeature) + validate_type(feature_priorities, list[VariantFeature]) _feature_priorities = [vfeat.feature_hash for vfeat in feature_priorities] @@ -78,12 +75,11 @@ def get_namespace_priority( :param namespace_priorities: ordered list of `str` objects. :return: Namespace priority of the `VariantProperty` object. """ - validate_instance_of(vprop, VariantProperty) + validate_type(vprop, VariantProperty) if namespace_priorities is None: return sys.maxsize - validate_instance_of(namespace_priorities, list) - validate_list_of(namespace_priorities, str) + validate_type(namespace_priorities, list[str]) # if not present push at the end try: @@ -107,7 +103,7 @@ def get_variant_property_priority_tuple( :param namespace_priorities: ordered list of `str` objects. :return: Variant property priority of the `VariantProperty` object. """ - validate_instance_of(vprop, VariantProperty) + validate_type(vprop, VariantProperty) ranking_tuple = ( # First Priority @@ -141,8 +137,7 @@ def sort_variant_properties( :param namespace_priorities: ordered list of `str` objects. :return: Sorted list of `VariantProperty` objects. """ - validate_instance_of(vprops, list) - validate_list_of(vprops, VariantProperty) + validate_type(vprops, list[VariantProperty]) return sorted( vprops, @@ -162,11 +157,8 @@ def sort_variants_descriptions( :param property_priorities: ordered list of `VariantProperty` objects. :return: Sorted list of `VariantDescription` objects. """ - validate_instance_of(vdescs, list) - validate_list_of(vdescs, VariantDescription) - - validate_instance_of(property_priorities, list) - validate_list_of(property_priorities, VariantProperty) + validate_type(vdescs, list[VariantDescription]) + validate_type(property_priorities, list[VariantProperty]) # Pre-compute the property hash for the property priorities # This is used to speed up the sorting process. From 0708c07b2581bdeff71aaf612931ff6d3b494f12 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Wed, 16 Apr 2025 12:02:04 -0400 Subject: [PATCH 5/9] Work in progress --- variantlib/commands/generate_index_json.py | 2 +- variantlib/models/variant.py | 15 +++-- variantlib/resolver/filtering.py | 65 +++++++++++++++++++--- variantlib/resolver/lib.py | 29 +++++++++- variants.dist.toml | 36 ++++++++---- 5 files changed, 119 insertions(+), 28 deletions(-) diff --git a/variantlib/commands/generate_index_json.py b/variantlib/commands/generate_index_json.py index c625711..2e911dd 100644 --- a/variantlib/commands/generate_index_json.py +++ b/variantlib/commands/generate_index_json.py @@ -17,7 +17,7 @@ logger.setLevel(logging.INFO) -def generate_index_json(args: list[str]) -> None: # noqa: PLR0912 +def generate_index_json(args: list[str]) -> None: parser = argparse.ArgumentParser( prog="generate_index_json", description="Generate a JSON index of all package variants", diff --git a/variantlib/models/variant.py b/variantlib/models/variant.py index c64ecbd..1bd9866 100644 --- a/variantlib/models/variant.py +++ b/variantlib/models/variant.py @@ -7,6 +7,7 @@ from dataclasses import asdict from dataclasses import dataclass from dataclasses import field +from functools import cached_property from variantlib.constants import VALIDATION_FEATURE_REGEX from variantlib.constants import VALIDATION_NAMESPACE_REGEX @@ -51,7 +52,7 @@ class VariantFeature(BaseModel): } ) - @property + @cached_property def feature_hash(self) -> int: # __class__ is being added to guarantee the hash to be specific to this class # note: can't use `self.__class__` because of inheritance @@ -110,11 +111,15 @@ class VariantProperty(VariantFeature): } ) - @property + @cached_property def property_hash(self) -> int: # __class__ is being added to guarantee the hash to be specific to this class return hash((self.__class__, self.namespace, self.feature, self.value)) + @cached_property + def feature_object(self) -> VariantFeature: + return VariantFeature(namespace=self.namespace, feature=self.feature) + def to_str(self) -> str: # Variant: :: :: return f"{self.namespace} :: {self.feature} :: {self.value}" @@ -184,7 +189,7 @@ def __post_init__(self) -> None: # Execute the validator super().__post_init__() - @property + @cached_property def hexdigest(self) -> str: """ Compute the hash of the object. @@ -225,12 +230,12 @@ def is_valid(self, allow_unknown_plugins: bool = True) -> bool: allow_unknown_plugins or None not in self.results.values() ) - @property + @cached_property def invalid_properties(self) -> list[VariantProperty]: """List of properties declared invalid by plugins""" return [x for x, y in self.results.items() if y is False] - @property + @cached_property def unknown_properties(self) -> list[VariantProperty]: """List of properties not in any recognized namespace""" return [x for x, y in self.results.items() if y is None] diff --git a/variantlib/resolver/filtering.py b/variantlib/resolver/filtering.py index d710098..ae6ae81 100644 --- a/variantlib/resolver/filtering.py +++ b/variantlib/resolver/filtering.py @@ -43,19 +43,33 @@ def _should_include(vdesc: VariantDescription) -> bool: def filter_variants_by_namespaces( - vdescs: Iterable[VariantDescription], allowed_namespaces: list[str] + vdescs: Iterable[VariantDescription], + allowed_namespaces: list[str], + forbidden_namespaces: list[str] | None = None, ) -> Generator[VariantDescription]: """ Filters out `VariantDescription` that contain any unsupported variant namespace. + ** Implementation Note:** + - Installer will provide the list of allowed namespaces from variant provider + plugins. + - User can [optionally] provide a list of forbidden namespaces to be excluded. + Forbidden namespaces take precedence of "allowed namespaces" and will be excluded. + :param vdescs: list of `VariantDescription` to filter. :param allowed_namespaces: List of allowed variant namespaces as `str`. + Any variant namespace not explicitly allowed is forbidden + :param forbidden_namespaces: List of forbidden variant namespaces as `str`. :return: Filtered list of `VariantDescription`. """ + if forbidden_namespaces is None: + forbidden_namespaces = [] + # Input validation validate_type(vdescs, Iterable) validate_type(allowed_namespaces, list[str]) + validate_type(forbidden_namespaces, list[str]) # Note: for performance reasons we convert the list to a set to avoid O(n) lookups _allowed_namespaces = set(allowed_namespaces) @@ -89,37 +103,53 @@ def _should_include(vdesc: VariantDescription) -> bool: def filter_variants_by_features( - vdescs: Iterable[VariantDescription], allowed_features: list[VariantFeature] + vdescs: Iterable[VariantDescription], + allowed_features: list[VariantFeature], + forbidden_features: list[VariantFeature] | None = None, ) -> Generator[VariantDescription]: """ Filters out `VariantDescription` that contain any unsupported variant feature. + ** Implementation Note:** + - Installer will provide the list of allowed features from variant provider plugins. + - User can [optionally] provide a list of forbidden features to be excluded. + Forbidden features take precedence of "allowed features" and will be excluded. + :param vdescs: list of `VariantDescription` to filter. :param allowed_features: List of allowed `VariantFeature`. + :param forbidden_features: List of forbidden `VariantFeature`. :return: Filtered list of `VariantDescription`. """ + if forbidden_features is None: + forbidden_features = [] + # Input validation validate_type(vdescs, Iterable) validate_type(allowed_features, list[VariantFeature]) + validate_type(forbidden_features, list[VariantFeature]) # for performance reasons we convert the list to a set to avoid O(n) lookups allowed_feature_hexs = {vfeat.feature_hash for vfeat in allowed_features} + forbidden_feature_hexs = {vfeat.feature_hash for vfeat in forbidden_features} def _should_include(vdesc: VariantDescription) -> bool: """ - Check if any of the namespaces in the variant description are not allowed. + Check if any of the VariantFeatures in the variant description are not allowed. """ validate_type(vdesc, VariantDescription) if forbidden_vprops := [ vprop for vprop in vdesc.properties - if vprop.feature_hash not in allowed_feature_hexs + if ( + vprop.feature_hash not in allowed_feature_hexs + or vprop.feature_hash in forbidden_feature_hexs + ) ]: logger.info( - "Variant `%(vhash)s` has been rejected because one of the variant " - "features `[%(vprops)s]` is not supported on this platform.", + "Variant `%(vhash)s` has been rejected because one or many of the " + "variant features `[%(vprops)s]` is not supported on this platform.", { "vhash": vdesc.hexdigest, "vprops": ", ".join([vprop.to_str() for vprop in forbidden_vprops]), @@ -133,22 +163,36 @@ def _should_include(vdesc: VariantDescription) -> bool: def filter_variants_by_property( - vdescs: Iterable[VariantDescription], allowed_properties: list[VariantProperty] + vdescs: Iterable[VariantDescription], + allowed_properties: list[VariantProperty], + forbidden_properties: list[VariantProperty] | None = None, ) -> Generator[VariantDescription]: """ - Filters out `VariantDescription` that contain any unsupported variant feature. + Filters out `VariantDescription` that contain any unsupported variant property. + + ** Implementation Note:** + - Installer will provide the list of allowed properties from variant provider + plugins. + - User can [optionally] provide a list of forbidden properties to be excluded. + Forbidden properties take precedence of "allowed properties" and will be excluded. :param vdescs: list of `VariantDescription` to filter. :param allowed_properties: List of allowed `VariantProperty`. + :param forbidden_properties: List of forbidden `VariantProperty`. :return: Filtered list of `VariantDescription`. """ + if forbidden_properties is None: + forbidden_properties = [] + # Input validation validate_type(vdescs, Iterable) validate_type(allowed_properties, list[VariantProperty]) + validate_type(forbidden_properties, list[VariantProperty]) # for performance reasons we convert the list to a set to avoid O(n) lookups allowed_properties_hexs = {vfeat.property_hash for vfeat in allowed_properties} + forbidden_properties_hexs = {vfeat.property_hash for vfeat in forbidden_properties} def _should_include(vdesc: VariantDescription) -> bool: """ @@ -159,7 +203,10 @@ def _should_include(vdesc: VariantDescription) -> bool: if forbidden_vprops := [ vprop for vprop in vdesc.properties - if vprop.property_hash not in allowed_properties_hexs + if ( + vprop.property_hash not in allowed_properties_hexs + or vprop.property_hash in forbidden_properties_hexs + ) ]: logger.info( "Variant `%(vhash)s` has been rejected because one of the variant " diff --git a/variantlib/resolver/lib.py b/variantlib/resolver/lib.py index e3a130f..2b1a75a 100644 --- a/variantlib/resolver/lib.py +++ b/variantlib/resolver/lib.py @@ -21,8 +21,11 @@ def filter_variants( vdescs: list[VariantDescription], allowed_namespaces: list[str] | None = None, + forbidden_namespaces: list[str] | None = None, allowed_features: list[VariantFeature] | None = None, + forbidden_features: list[VariantFeature] | None = None, allowed_properties: list[VariantProperty] | None = None, + forbidden_properties: list[VariantProperty] | None = None, ) -> Generator[VariantDescription]: """ Filters out a `list` of `VariantDescription` with the following filters: @@ -33,8 +36,11 @@ def filter_variants( :param vdescs: list of `VariantDescription` to filter. :param allowed_namespaces: List of allowed variant namespaces as `str`. + :param forbidden_namespaces: List of forbidden variant namespaces as `str`. :param allowed_features: List of allowed `VariantFeature`. + :param forbidden_features: List of forbidden `VariantFeature`. :param allowed_properties: List of allowed `VariantProperty`. + :param forbidden_properties: List of forbidden `VariantProperty`. :return: Filtered list of `VariantDescription`. """ @@ -70,20 +76,37 @@ def filter_variants( result = filter_variants_by_namespaces( vdescs=result, allowed_namespaces=allowed_namespaces, + forbidden_namespaces=forbidden_namespaces, + ) + elif forbidden_namespaces is not None: + raise ValueError( + "`forbidden_namespaces` without `allowed_namespaces` is not supported." ) # Step 3: Remove any `VariantDescription` which declare any `VariantProperty` with # `namespace :: feature` (aka. `VariantFeature`) unsupported on this platform. if allowed_features is not None: result = filter_variants_by_features( - vdescs=result, allowed_features=allowed_features + vdescs=result, + allowed_features=allowed_features, + forbidden_features=forbidden_features, + ) + elif forbidden_features is not None: + raise ValueError( + "`forbidden_features` without `allowed_features` is not supported." ) # Step 4: Remove any `VariantDescription` which declare any `VariantProperty` # `namespace :: feature :: value` unsupported on this platform. if allowed_properties is not None: result = filter_variants_by_property( - vdescs=result, allowed_properties=allowed_properties + vdescs=result, + allowed_properties=allowed_properties, + forbidden_properties=forbidden_properties, + ) + elif forbidden_properties is not None: + raise ValueError( + "`forbidden_properties` without `allowed_properties` is not supported." ) yield from result @@ -121,6 +144,8 @@ def sort_and_filter_supported_variants( filter_variants( vdescs=vdescs, allowed_namespaces=namespace_priorities, + allowed_features=[vprop.feature_object for vprop in supported_vprops], + allowed_properties=supported_vprops, ) ) diff --git a/variants.dist.toml b/variants.dist.toml index 5c7c9ac..a270b36 100644 --- a/variants.dist.toml +++ b/variants.dist.toml @@ -2,10 +2,14 @@ # ============= Top-Most Priority ============= # -# 1. Define the variant properties `VariantProperty` to "bump to the top of the priority list" -# Expected format: "namespace::feature::value" -# OPTIONAL: In most cases - the user will not specify the following +# 1. Define the priority of variant properties - 1st order priority. +# => Expected format: "namespace::feature::value" +# +# OPTIONAL: In most cases - users will not specify the following. Only used when the user wants +# to modify the default VariantProperty priority ordering. +# # Note: This is a lazy-list: Only specify the ones you want to "bump up" to the top of the list + property_priority = [ "fictional_hw::architecture::mother", "fictional_tech::risk_exposure:25", @@ -13,11 +17,14 @@ property_priority = [ # ============= Second-Most Priority ============= # -# 2. Define the variant features: `VariantFeature` to "bump to the top of the priority list" -# These come after the ones defined in 1) [`property_priority`] -# Expected format: "namespace::feature" -# OPTIONAL: In most cases - the user will not specify the following +# 2. Define the priority of variant features - 2nd order priority under the variant properties. +# => Expected format: "namespace::feature" +# +# OPTIONAL: In most cases - users will not specify the following. Only used when the user wants +# to modify the default VariantFeature priority ordering. +# # Note: This is a lazy-list: Only specify the ones you want to "bump up" to the top of the list + features_priority = [ "fictional_hw::architecture", "fictional_tech::risk_exposure", @@ -26,10 +33,17 @@ features_priority = [ # ============= Default Priority Ordering ============= # -# 3. Define the variant namespaces by priority -# MANDATORY: as long as there is more than 1 variant plugin installed. This field must be specified -# If not one installed plugin is missing => should error out. -# Namespaces can be specified but not installed. +# 3. Define the priority of variant namespaces +# => Expected format: "namespace" + +# MANDATORY AND IMPORTANT - PLEASE READ ! +# - As long as there is more than 1 variant plugin installed. This field must be specified. +# => no default ordering is assumed. +# +# - If len(plugins) > 1 and an installed plugin is missing in the priority list => raise ConfigurationError +# +# - If namespaces is specified by no plugin uses it => warning issued. + namespaces_priority = [ "fictional_hw", "fictional_tech", From b4295f8af7ab9da9e7391f01a574d40b7e44f66e Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Thu, 17 Apr 2025 12:50:55 -0400 Subject: [PATCH 6/9] Review Comments --- tests/resolver/test_filtering.py | 20 +++++++++++++++++++- variantlib/models/validators.py | 14 +++++++++++++- variants.dist.toml | 2 +- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/resolver/test_filtering.py b/tests/resolver/test_filtering.py index 97fdae8..15862d8 100644 --- a/tests/resolver/test_filtering.py +++ b/tests/resolver/test_filtering.py @@ -267,6 +267,15 @@ def test_filter_variants_by_features_validation_error( maxlen=0, ) + with pytest.raises(ValidationError): + deque( + filter_variants_by_features( + vdescs=vdescs, + allowed_features=[VariantProperty("not", "a", "feature")], # type: ignore[list-item] + ), + maxlen=0, + ) + with pytest.raises(ValidationError): deque( filter_variants_by_features( @@ -381,7 +390,16 @@ def test_filter_variants_by_property_validation_error( deque( filter_variants_by_property( vdescs=vdescs, - allowed_properties=["not a `VariantFeature`"], # type: ignore[list-item] + allowed_properties=["not a `VariantProperty`"], # type: ignore[list-item] + ), + maxlen=0, + ) + + with pytest.raises(ValidationError): + deque( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[VariantFeature("not_a", "property")], # type: ignore[list-item] ), maxlen=0, ) diff --git a/variantlib/models/validators.py b/variantlib/models/validators.py index e9fbacb..ca9e8b2 100644 --- a/variantlib/models/validators.py +++ b/variantlib/models/validators.py @@ -6,6 +6,7 @@ from types import GenericAlias from typing import Any from typing import Callable +from typing import Protocol from typing import Union from typing import get_args from typing import get_origin @@ -107,8 +108,10 @@ def validate_and(validators: list[Callable], value: Any) -> None: def _validate_type(value: Any, expected_type: type) -> type | None: if isinstance(expected_type, GenericAlias): list_type = get_origin(expected_type) + if not isinstance(value, list_type): return type(value) + if list_type is dict: assert isinstance(value, dict) key_type, value_type = get_args(expected_type) @@ -122,6 +125,7 @@ def _validate_type(value: Any, expected_type: type) -> type | None: key_ored = Union.__getitem__((key_type, *incorrect_key_types)) value_ored = Union.__getitem__((value_type, *incorrect_value_types)) return list_type[key_ored, value_ored] # type: ignore[index] + else: (item_type,) = get_args(expected_type) assert isinstance(value, Iterable) @@ -130,8 +134,16 @@ def _validate_type(value: Any, expected_type: type) -> type | None: if incorrect_types: ored = Union.__getitem__((item_type, *incorrect_types)) return list_type[ored] # type: ignore[index] - elif not isinstance(value, expected_type): + + # Protocols and Iterable must enable subclassing to pass + elif issubclass(expected_type, (Protocol, Iterable)): + if not isinstance(value, expected_type): + return type(value) + + # Do not use isinstance here - we want to reject subclasses + elif type(value) is not expected_type: return type(value) + return None diff --git a/variants.dist.toml b/variants.dist.toml index a270b36..95ba5e5 100644 --- a/variants.dist.toml +++ b/variants.dist.toml @@ -35,7 +35,7 @@ features_priority = [ # 3. Define the priority of variant namespaces # => Expected format: "namespace" - +# # MANDATORY AND IMPORTANT - PLEASE READ ! # - As long as there is more than 1 variant plugin installed. This field must be specified. # => no default ordering is assumed. From e36e93161dc007e0b326bb9d5422111d4bf3192f Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Thu, 17 Apr 2025 14:48:40 -0400 Subject: [PATCH 7/9] Review Fixes --- pyproject.toml | 1 + tests/test_configuration.py | 19 ++----------------- variantlib/models/validators.py | 2 +- variantlib/resolver/lib.py | 8 ++++---- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 32ae00e..4fae1d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "ruff>=0.10,<1.0", ] test = [ + "tomli_w>=1.2,<1.3", "jsondiff>=2.2,<2.3", "hypothesis>=6.0.0,<7", "pytest>=8.0.0,<9.0.0", diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 7174f4f..466e66a 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -6,6 +6,7 @@ from unittest.mock import patch import platformdirs +import tomli_w from variantlib.configuration import ConfigEnvironments from variantlib.configuration import VariantConfiguration @@ -16,22 +17,6 @@ from variantlib.models.variant import VariantProperty -def dict_to_toml(d: dict, indent: int = 0) -> str: - lines = [] - for key, value in d.items(): - if isinstance(value, dict): - lines.append(f"\n{' ' * indent}[{key}]") - lines.append(dict_to_toml(value, indent)) - elif isinstance(value, list): - items = ", ".join(f'"{v}"' if isinstance(v, str) else str(v) for v in value) - lines.append(f"{' ' * indent}{key} = [{items}]") - elif isinstance(value, str): - lines.append(f'{" " * indent}{key} = "{value}"') - else: - raise TypeError(f"Unsupported type for key '{key}': {type(value)}") - return "\n".join(lines) - - def test_reset(): VariantConfiguration._config = ConfigurationModel.default() # noqa: SLF001 assert VariantConfiguration._config is not None # noqa: SLF001 @@ -104,7 +89,7 @@ def test_get_config_from_file(mock_get_config_files): ], } with tempfile.NamedTemporaryFile(mode="w+", suffix=".toml") as temp_file: - temp_file.write(dict_to_toml(data)) + temp_file.write(tomli_w.dumps(data)) temp_file.flush() def _get_config_files() -> dict[ConfigEnvironments, Path]: diff --git a/variantlib/models/validators.py b/variantlib/models/validators.py index ca9e8b2..163528c 100644 --- a/variantlib/models/validators.py +++ b/variantlib/models/validators.py @@ -136,7 +136,7 @@ def _validate_type(value: Any, expected_type: type) -> type | None: return list_type[ored] # type: ignore[index] # Protocols and Iterable must enable subclassing to pass - elif issubclass(expected_type, (Protocol, Iterable)): + elif issubclass(expected_type, (Protocol, Iterable)): # type: ignore[arg-type] if not isinstance(value, expected_type): return type(value) diff --git a/variantlib/resolver/lib.py b/variantlib/resolver/lib.py index 2b1a75a..d65df55 100644 --- a/variantlib/resolver/lib.py +++ b/variantlib/resolver/lib.py @@ -115,9 +115,9 @@ def filter_variants( def sort_and_filter_supported_variants( vdescs: list[VariantDescription], supported_vprops: list[VariantProperty] | None = None, - property_priorities: list[VariantProperty] | None = None, - feature_priorities: list[VariantFeature] | None = None, namespace_priorities: list[str] | None = None, + feature_priorities: list[VariantFeature] | None = None, + property_priorities: list[VariantProperty] | None = None, ) -> list[VariantDescription]: """ Sort and filter a list of `VariantDescription` objects based on their @@ -125,9 +125,9 @@ def sort_and_filter_supported_variants( :param vdescs: List of `VariantDescription` objects. :param supported_vprops: List of `VariantProperty` objects supported on the platform - :param property_priorities: Ordered list of `VariantProperty` objects. - :param feature_priorities: Ordered list of `VariantFeature` objects. :param namespace_priorities: Ordered list of `str` objects. + :param feature_priorities: Ordered list of `VariantFeature` objects. + :param property_priorities: Ordered list of `VariantProperty` objects. :return: Sorted and filtered list of `VariantDescription` objects. """ validate_type(vdescs, list[VariantDescription]) From 938b46c68e30c74d76759ea3ed9be06a0aba79b3 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Thu, 17 Apr 2025 19:18:13 -0400 Subject: [PATCH 8/9] Unittests more rugged --- pyproject.toml | 5 +- tests/resolver/test_lib.py | 639 ++++++++++++++++++++++-------------- tests/test_configuration.py | 12 - 3 files changed, 394 insertions(+), 262 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4fae1d0..ca1456c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,9 +46,10 @@ dev = [ "ruff>=0.10,<1.0", ] test = [ - "tomli_w>=1.2,<1.3", + "deepdiff>=8.0,<9.0", "jsondiff>=2.2,<2.3", "hypothesis>=6.0.0,<7", + "parameterized>=0.9.0,<0.10", "pytest>=8.0.0,<9.0.0", "pytest-cov>=5.0.0,<6.0.0", "pytest-dotenv>=0.5.0,<1.0.0", @@ -56,7 +57,7 @@ test = [ "pytest-mock>=3.14.0,<4.0.0", "pytest-runner>=6.0.0,<7.0.0", "pytest-ordering>=0.6,<1.0.0", - "parameterized>=0.9.0,<0.10", + "tomli_w>=1.2,<1.3", ] [project.scripts] diff --git a/tests/resolver/test_lib.py b/tests/resolver/test_lib.py index a21398f..ab1d6f6 100644 --- a/tests/resolver/test_lib.py +++ b/tests/resolver/test_lib.py @@ -1,6 +1,9 @@ from __future__ import annotations +import random + import pytest +from deepdiff import DeepDiff from variantlib.errors import ValidationError from variantlib.models.variant import VariantDescription @@ -8,26 +11,59 @@ from variantlib.models.variant import VariantProperty from variantlib.resolver.filtering import filter_variants_by_features from variantlib.resolver.filtering import filter_variants_by_namespaces +from variantlib.resolver.filtering import filter_variants_by_property from variantlib.resolver.filtering import remove_duplicates from variantlib.resolver.lib import filter_variants from variantlib.resolver.lib import sort_and_filter_supported_variants +def deep_diff( + a: list[VariantDescription], b: list[VariantDescription], ignore_ordering=False +) -> DeepDiff: + """Helper function to compare two objects using DeepDiff.""" + assert isinstance(a, list) + assert isinstance(b, list) + assert all(isinstance(vdesc, VariantDescription) for vdesc in a) + assert all(isinstance(vdesc, VariantDescription) for vdesc in b) + + return DeepDiff( + [vdesc.hexdigest for vdesc in a], + [vdesc.hexdigest for vdesc in b], + ignore_order=ignore_ordering, + ) + + +def shuffle_vdescs_with_duplicates( + vdescs: list[VariantDescription], +) -> list[VariantDescription]: + inputs_vdescs = [vdesc for vdesc in vdescs for _ in range(5)] + assert len(inputs_vdescs) == len(vdescs) * 5 + random.shuffle(inputs_vdescs) + return inputs_vdescs + + @pytest.fixture(scope="session") def vprops() -> list[VariantProperty]: """Fixture to create a list of VariantProperty objects.""" + # This list assume sorting by feature and properties coming from the plugins + # Does not assume filtering per namespace. Only features & properties. return [ - VariantProperty(namespace="OmniCorp", feature="access_key", value="value1"), - VariantProperty( - namespace="TyrellCorporation", feature="client_id", value="value2" - ), - VariantProperty( - namespace="TyrellCorporation", feature="client_pass", value="abcde" - ), - VariantProperty( - namespace="TyrellCorporation", feature="client_pass", value="efghij" - ), + # -------------------------- Plugin `OmniCorp` -------------------------- # + # Feature 1: `OmniCorp :: featA` + VariantProperty(namespace="OmniCorp", feature="featA", value="value"), + # Feature 2: `OmniCorp :: featB` + VariantProperty(namespace="OmniCorp", feature="featB", value="value"), + # ------------------------- Plugin `TyrellCorp` ------------------------- # + # Feature 1: `TyrellCorp :: featA` + VariantProperty(namespace="TyrellCorp", feature="featA", value="value"), + # Feature 2: `TyrellCorp :: featB` + # Property 2.1: `TyrellCorp :: featB :: abcde` + VariantProperty(namespace="TyrellCorp", feature="featB", value="abcde"), + # Property 2.2: `TyrellCorp :: featB :: efghij` + VariantProperty(namespace="TyrellCorp", feature="featB", value="efghij"), + # Feature 3: `TyrellCorp :: featC` + VariantProperty(namespace="TyrellCorp", feature="featC", value="value"), ] @@ -35,246 +71,340 @@ def vprops() -> list[VariantProperty]: def vdescs(vprops: list[VariantProperty]) -> list[VariantDescription]: """Fixture to create a list of VariantDescription objects.""" - assert len(vprops) == 4 - prop1, prop2, prop3, prop4 = vprops + assert len(vprops) == 6 + vprop1, vprop2, vprop3, vprop4, vprop5, vprop6 = vprops + # fmt: off + # Important: vprop4 and vprop5 are mutually exclusive return [ - # prop3 and prop4 are mutually exclusive - VariantDescription([prop1, prop2, prop3]), - VariantDescription([prop1, prop3, prop2]), - VariantDescription([prop2, prop3, prop1]), - VariantDescription([prop2, prop1, prop3]), - VariantDescription([prop3, prop2, prop1]), - VariantDescription([prop3, prop1, prop2]), - VariantDescription([prop1, prop2, prop4]), # duplicate with prop4 - VariantDescription([prop1, prop4, prop2]), # duplicate with prop4 - VariantDescription([prop2, prop4, prop1]), # duplicate with prop4 - VariantDescription([prop2, prop1, prop4]), # duplicate with prop4 - VariantDescription([prop4, prop2, prop1]), # duplicate with prop4 - VariantDescription([prop4, prop1, prop2]), # duplicate with prop4 - VariantDescription([prop1, prop2]), - VariantDescription([prop2, prop1]), - VariantDescription([prop1, prop3]), - VariantDescription([prop3, prop1]), - VariantDescription([prop2, prop3]), - VariantDescription([prop3, prop2]), - VariantDescription([prop1, prop4]), # duplicate with prop4 - VariantDescription([prop4, prop1]), # duplicate with prop4 - VariantDescription([prop2, prop4]), # duplicate with prop4 - VariantDescription([prop4, prop2]), # duplicate with prop4 - VariantDescription([prop1]), - VariantDescription([prop2]), - VariantDescription([prop3]), - VariantDescription([prop4]), + # variants with 5 properties + VariantDescription([vprop1, vprop2, vprop3, vprop4, vprop6]), + VariantDescription([vprop1, vprop2, vprop3, vprop5, vprop6]), + + # variants with 4 properties + VariantDescription([vprop1, vprop2, vprop3, vprop4]), # - vprop6 + VariantDescription([vprop1, vprop2, vprop3, vprop5]), # - vprop6 + + VariantDescription([vprop1, vprop2, vprop3, vprop6]), # - vprop4/5 + + VariantDescription([vprop1, vprop2, vprop4, vprop6]), # - vprop3 + VariantDescription([vprop1, vprop2, vprop5, vprop6]), # - vprop3 + + VariantDescription([vprop1, vprop3, vprop4, vprop6]), # - vprop2 + VariantDescription([vprop1, vprop3, vprop5, vprop6]), # - vprop2 + + VariantDescription([vprop2, vprop3, vprop5, vprop6]), # - vprop1 + VariantDescription([vprop2, vprop3, vprop5, vprop6]), # - vprop1 + + # variants with 3 properties + # --- vprop1 --- # + VariantDescription([vprop1, vprop2, vprop3]), + VariantDescription([vprop1, vprop2, vprop4]), + VariantDescription([vprop1, vprop2, vprop5]), + VariantDescription([vprop1, vprop2, vprop6]), + + VariantDescription([vprop1, vprop3, vprop4]), + VariantDescription([vprop1, vprop3, vprop5]), + VariantDescription([vprop1, vprop3, vprop6]), + + VariantDescription([vprop1, vprop4, vprop6]), + VariantDescription([vprop1, vprop5, vprop6]), + + # --- vprop2 --- # + VariantDescription([vprop2, vprop3, vprop4]), + VariantDescription([vprop2, vprop3, vprop5]), + VariantDescription([vprop2, vprop3, vprop6]), + + VariantDescription([vprop2, vprop4, vprop6]), + VariantDescription([vprop2, vprop5, vprop6]), + + # --- vprop3 --- # + VariantDescription([vprop3, vprop4, vprop6]), + VariantDescription([vprop3, vprop5, vprop6]), + + # variants with 2 properties + # --- vprop1 --- # + VariantDescription([vprop1, vprop2]), + VariantDescription([vprop1, vprop3]), + VariantDescription([vprop1, vprop4]), + VariantDescription([vprop1, vprop5]), + VariantDescription([vprop1, vprop6]), + + # --- vprop2 --- # + VariantDescription([vprop2, vprop3]), + VariantDescription([vprop2, vprop4]), + VariantDescription([vprop2, vprop5]), + VariantDescription([vprop2, vprop6]), + + # --- vprop3 --- # + VariantDescription([vprop3, vprop4]), + VariantDescription([vprop3, vprop5]), + VariantDescription([vprop3, vprop6]), + + # --- vprop4 --- # + VariantDescription([vprop4, vprop6]), + + # --- vprop5 --- # + VariantDescription([vprop5, vprop6]), + + # variants with 1 property + VariantDescription([vprop1]), + VariantDescription([vprop2]), + VariantDescription([vprop3]), + VariantDescription([vprop4]), + VariantDescription([vprop5]), + VariantDescription([vprop6]), ] + # fmt: on # =========================== `filter_variants` =========================== # -def test_filter_variants( +def test_filter_variants_only_one_prop_allowed( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): - assert len(vprops) == 4 - _, _, prop3, _ = vprops + assert len(vprops) == 6 + _, _, _, vprop4, _, _ = vprops - expected_vdescs = [ - # VariantDescription([prop1, prop2, prop3]), # not allowed `namespace` - # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop2, prop4]), # not allowed property - # VariantDescription([prop1, prop4, prop2]), # not allowed property - # VariantDescription([prop2, prop4, prop1]), # not allowed property - # VariantDescription([prop2, prop1, prop4]), # not allowed property - # VariantDescription([prop4, prop2, prop1]), # not allowed property - # VariantDescription([prop4, prop1, prop2]), # not allowed property - # VariantDescription([prop1, prop2]), # not allowed `namespace` - # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop3]), # not allowed `namespace` - # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop3]), # not allowed `feature` - # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop4]), # not allowed property - # VariantDescription([prop4, prop1]), # not allowed property - # VariantDescription([prop2, prop4]), # not allowed property - # VariantDescription([prop4, prop2]), # not allowed property - # VariantDescription([prop1]), # not allowed `namespace` - # VariantDescription([prop2]), # not allowed `feature` - VariantDescription([prop3]), # ================= > the only valid variant - # VariantDescription([prop3]), # not allowed property - ] + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) + + assert list( + filter_variants( + vdescs=inputs_vdescs, + allowed_namespaces=["TyrellCorp"], + allowed_features=[vprop4.feature_object], + allowed_properties=[vprop4], + ) + ) == [VariantDescription([vprop4])] + + assert list( + filter_variants( + vdescs=inputs_vdescs, + allowed_namespaces=None, + allowed_features=[vprop4.feature_object], + allowed_properties=[vprop4], + ) + ) == [VariantDescription([vprop4])] + + assert list( + filter_variants( + vdescs=inputs_vdescs, + allowed_namespaces=["TyrellCorp"], + allowed_features=None, + allowed_properties=[vprop4], + ) + ) == [VariantDescription([vprop4])] + + assert list( + filter_variants( + vdescs=inputs_vdescs, + allowed_namespaces=None, + allowed_features=None, + allowed_properties=[vprop4], + ) + ) == [VariantDescription([vprop4])] + + +def test_filter_variants_disallowed_feature_allowed_prop( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 6 + _, vprop2, _, vprop4, _, _ = vprops + + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) assert ( list( filter_variants( - vdescs=vdescs, - allowed_namespaces=["TyrellCorporation"], - allowed_features=[ - VariantFeature(namespace="TyrellCorporation", feature="client_pass") - ], - allowed_properties=[prop3], + vdescs=inputs_vdescs, + allowed_namespaces=None, + allowed_features=[vprop2.feature_object], + allowed_properties=[vprop4], ) ) - == expected_vdescs + == [] ) -def test_filter_variants_only_remove_duplicates( +def test_filter_variants_disallowed_namespace_allowed_prop( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): - assert len(vprops) == 4 - prop1, prop2, prop3, prop4 = vprops + assert len(vprops) == 6 + _, _, _, vprop4, _, _ = vprops - expected_vdescs = [ - VariantDescription([prop1, prop2, prop3]), - # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter - VariantDescription([prop1, prop2, prop4]), # duplicate with prop4 - # VariantDescription([prop1, prop4, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop4, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop1, prop4]), # duplicate - order doesn't matter - # VariantDescription([prop4, prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop4, prop1, prop2]), # duplicate - order doesn't matter - VariantDescription([prop1, prop2]), - # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter - VariantDescription([prop1, prop3]), - # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter - VariantDescription([prop2, prop3]), - # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter - VariantDescription([prop1, prop4]), - # VariantDescription([prop4, prop1]), # duplicate - order doesn't matter - VariantDescription([prop2, prop4]), - # VariantDescription([prop4, prop2]), # duplicate - order doesn't matter - VariantDescription([prop1]), - VariantDescription([prop2]), - VariantDescription([prop3]), - VariantDescription([prop4]), - ] + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) assert ( list( filter_variants( - vdescs=vdescs, + vdescs=inputs_vdescs, + allowed_namespaces=["NotExisting"], + allowed_features=None, + allowed_properties=[vprop4], ) ) - == expected_vdescs + == [] ) - assert list(remove_duplicates(vdescs=vdescs)) == expected_vdescs + +def test_filter_variants_only_remove_duplicates(vdescs: list[VariantDescription]): + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) + + ddiff = deep_diff( + list(filter_variants(vdescs=inputs_vdescs)), vdescs, ignore_ordering=True + ) + assert ddiff == {}, ddiff + + ddiff = deep_diff( + list(remove_duplicates(vdescs=inputs_vdescs)), vdescs, ignore_ordering=True + ) + assert ddiff == {}, ddiff def test_filter_variants_remove_duplicates_and_namespaces( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): - assert len(vprops) == 4 - _, prop2, prop3, prop4 = vprops + assert len(vprops) == 6 + _, _, vprop3, vprop4, vprop5, vprop6 = vprops + + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) expected_vdescs = [ - # VariantDescription([prop1, prop2, prop3]), # not allowed `namespace` - # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop2, prop4]), # not allowed `namespace` - # VariantDescription([prop1, prop4, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop4, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop1, prop4]), # duplicate - order doesn't matter - # VariantDescription([prop4, prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop4, prop1, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop2]), # not allowed `namespace` - # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop3]), # not allowed `namespace` - # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter - VariantDescription([prop2, prop3]), - # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop4]), # not allowed `namespace` - # VariantDescription([prop4, prop1]), # duplicate - order doesn't matter - VariantDescription([prop2, prop4]), - # VariantDescription([prop4, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop1]), # not allowed `namespace` - VariantDescription([prop2]), - VariantDescription([prop3]), - VariantDescription([prop4]), + # --- vprop3 --- # + VariantDescription([vprop3, vprop4, vprop6]), + VariantDescription([vprop3, vprop5, vprop6]), + # variants with 2 properties + # --- vprop3 --- # + VariantDescription([vprop3, vprop4]), + VariantDescription([vprop3, vprop5]), + VariantDescription([vprop3, vprop6]), + # --- vprop4 --- # + VariantDescription([vprop4, vprop6]), + # --- vprop5 --- # + VariantDescription([vprop5, vprop6]), + # variants with 1 property + VariantDescription([vprop3]), + VariantDescription([vprop4]), + VariantDescription([vprop5]), + VariantDescription([vprop6]), ] - allowed_namespaces = ["TyrellCorporation"] - - assert ( + ddiff = deep_diff( list( filter_variants( - vdescs=vdescs, - allowed_namespaces=allowed_namespaces, + vdescs=inputs_vdescs, + allowed_namespaces=["TyrellCorp"], ) - ) - == expected_vdescs + ), + expected_vdescs, + ignore_ordering=True, ) + assert ddiff == {}, ddiff - assert ( + ddiff = deep_diff( list( filter_variants_by_namespaces( - remove_duplicates(vdescs=vdescs), - allowed_namespaces=allowed_namespaces, + remove_duplicates(vdescs=inputs_vdescs), + allowed_namespaces=["TyrellCorp"], ) - ) - == expected_vdescs + ), + expected_vdescs, + ignore_ordering=True, ) + assert ddiff == {}, ddiff def test_filter_variants_remove_duplicates_and_features( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): - assert len(vprops) == 4 - prop1, prop2, _, _ = vprops + assert len(vprops) == 6 + vprop1, _, _, vprop4, vprop5, _ = vprops + + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) expected_vdescs = [ - # VariantDescription([prop1, prop2, prop3]), # not allowed `feature` - # VariantDescription([prop1, prop3, prop2]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop3, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop1, prop3]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop3, prop1, prop2]), # duplicate - order doesn't matter - VariantDescription([prop1, prop2]), - # VariantDescription([prop2, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop1, prop3]), # not allowed `feature` - # VariantDescription([prop3, prop1]), # duplicate - order doesn't matter - # VariantDescription([prop2, prop3]), # not allowed `feature` - # VariantDescription([prop3, prop2]), # duplicate - order doesn't matter - VariantDescription([prop1]), - VariantDescription([prop2]), - # VariantDescription([prop3]), # not allowed `feature` + # variants with 2 properties + # --- vprop1 --- # + VariantDescription([vprop1, vprop4]), + VariantDescription([vprop1, vprop5]), + # variants with 1 property + VariantDescription([vprop1]), + VariantDescription([vprop4]), + VariantDescription([vprop5]), ] - allowed_features = [ - VariantFeature(namespace="OmniCorp", feature="access_key"), - VariantFeature(namespace="TyrellCorporation", feature="client_id"), - ] + allowed_features = [vprop1.feature_object, vprop4.feature_object] - assert ( + ddiff = deep_diff( list( filter_variants( - vdescs=vdescs, + vdescs=inputs_vdescs, allowed_features=allowed_features, ) - ) - == expected_vdescs + ), + expected_vdescs, + ignore_ordering=True, ) + assert ddiff == {}, ddiff - assert ( + ddiff = deep_diff( list( filter_variants_by_features( - remove_duplicates(vdescs=vdescs), + remove_duplicates(vdescs=inputs_vdescs), allowed_features=allowed_features, ) - ) - == expected_vdescs + ), + expected_vdescs, + ignore_ordering=True, ) + assert ddiff == {}, ddiff + + +def test_filter_variants_remove_duplicates_and_properties( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 6 + vprop1, _, _, vprop4, vprop5, _ = vprops + + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) + + expected_vdescs = [ + # variants with 2 properties + # --- vprop1 --- # + VariantDescription([vprop1, vprop4]), + VariantDescription([vprop1, vprop5]), + # variants with 1 property + VariantDescription([vprop1]), + VariantDescription([vprop4]), + VariantDescription([vprop5]), + ] + + allowed_properties = [vprop1, vprop4, vprop5] + + ddiff = deep_diff( + list( + filter_variants( + vdescs=inputs_vdescs, + allowed_properties=allowed_properties, + ) + ), + expected_vdescs, + ignore_ordering=True, + ) + assert ddiff == {}, ddiff + + ddiff = deep_diff( + list( + filter_variants_by_property( + remove_duplicates(vdescs=inputs_vdescs), + allowed_properties=allowed_properties, + ) + ), + expected_vdescs, + ignore_ordering=True, + ) + assert ddiff == {}, ddiff # =================== `sort_and_filter_supported_variants` ================== # @@ -283,75 +413,88 @@ def test_filter_variants_remove_duplicates_and_features( def test_sort_and_filter_supported_variants( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): - # vprops = [ - # VariantProperty(namespace="OmniCorp", feature="access_key", value="value1"), - # VariantProperty( - # namespace="TyrellCorporation", feature="client_id", value="value2" - # ), - # VariantProperty( - # namespace="TyrellCorporation", feature="client_pass", value="abcde" - # ), - # VariantProperty( - # namespace="TyrellCorporation", feature="client_pass", value="efghij" - # ), - # ] - - # vdescs = [ - # # prop3 and prop4 are mutually exclusive - # [00] VariantDescription([prop1, prop2, prop3]), # Rank 0 - # [01] VariantDescription([prop1, prop3, prop2]), # duplicate - # [02] VariantDescription([prop2, prop3, prop1]), # duplicate - # [03] VariantDescription([prop2, prop1, prop3]), # duplicate - # [04] VariantDescription([prop3, prop2, prop1]), # duplicate - # [05] VariantDescription([prop3, prop1, prop2]), # duplicate - # [06] VariantDescription([prop1, prop2, prop4]), # rank 2 - # [07] VariantDescription([prop1, prop4, prop2]), # duplicate - # [08] VariantDescription([prop2, prop4, prop1]), # duplicate - # [09] VariantDescription([prop2, prop1, prop4]), # duplicate - # [10] VariantDescription([prop4, prop2, prop1]), # duplicate - # [11] VariantDescription([prop4, prop1, prop2]), # duplicate - # [12] VariantDescription([prop1, prop2]), # Rank 4 - # [13] VariantDescription([prop2, prop1]), # duplicate - # [14] VariantDescription([prop1, prop3]), # Rank 6 - # [15] VariantDescription([prop3, prop1]), # duplicate - # [16] VariantDescription([prop2, prop3]), # Rank 1 - # [17] VariantDescription([prop3, prop2]), # duplicate - # [18] VariantDescription([prop1, prop4]), # Rank 8 - # [19] VariantDescription([prop4, prop1]), # duplicate - # [20] VariantDescription([prop2, prop4]), # Rank 3 - # [21] VariantDescription([prop4, prop2]), # duplicate - # [22] VariantDescription([prop1]), # Rank 10 - # [23] VariantDescription([prop2]), # Rank 5 - # [24] VariantDescription([prop3]), # Rank 7 - # [25] VariantDescription([prop4]), # Rank 9 - # ] - vprop1 = VariantProperty( - namespace="TyrellCorporation", feature="client_id", value="value2" - ) - vfeat1 = VariantFeature(namespace="TyrellCorporation", feature="client_pass") - - assert sort_and_filter_supported_variants( - vdescs=vdescs, - supported_vprops=vprops, - property_priorities=[vprop1], - feature_priorities=[vfeat1], - namespace_priorities=["OmniCorp", "TyrellCorporation"], - ) == [ - # everything that contains `vprop2` with vfeat1 important and OmniCorp first - vdescs[0], # 0 - vdescs[16], # 1 - vdescs[6], # 2 - vdescs[20], # 3 - vdescs[12], # 4 - vdescs[23], # 5 - # everything that contains `vfeat1` - vdescs[14], # 6 - vdescs[24], # 7 - vdescs[18], # 8 - vdescs[25], # 9 - # lastly prop1 alone - ranked by namespace alone - vdescs[22], # 10 + assert len(vprops) == 6 + + # Let's remove vprop2 [not supported] + vprop1, _, vprop3, vprop4, vprop5, vprop6 = vprops + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~ SORTING PARAMETERS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # + + # Top Priority: Anything that contains `vprop3` + prio_vprops: list[VariantProperty] = [vprop3] + + # Second Priority: Vprop4 and Vprop5 are prioritized priority (same feature) + # With vprop4 > vprop5 given that vprop4 is first in the list + prio_vfeats: list[VariantFeature] = [vprop4.feature_object] + + # Third Priority: namespaces + # vprop3, vprop4, vprop5, vprop6 [TyrellCorp] > vprop1, vprop2 ["OmniCorp"] # noqa: E501 + prio_namespaces = ["NotExistingNamespace", "TyrellCorp", "OmniCorp"] + + # Default Ordering: properties are assumed pre-sorted in features/properties + # vprop1 > vprop2 > vprop3 > vprop4 > vprop5 > vprop6 + # Note: Namespace is already accounted for in 3) + + # Last Preferential Order: More features are preferred over less features + + # ----------------------------------------------------------------------------- # + + # fmt: off + expected_vdescs = [ + # ============ A - Everything with vprop3 ============ # + # ============ A.1 - Everything with vprop3 & vprop4 ============ # + VariantDescription([vprop1, vprop3, vprop4, vprop6]), + VariantDescription([vprop3, vprop4, vprop6]), # TyrellCorp > OmniCorp + VariantDescription([vprop1, vprop3, vprop4]), # TyrellCorp > OmniCorp + VariantDescription([vprop3, vprop4]), + # ============ A.2 - Everything with vprop3 & vprop5 ============ # + VariantDescription([vprop1, vprop3, vprop5, vprop6]), + VariantDescription([vprop3, vprop5, vprop6]), # TyrellCorp > OmniCorp + VariantDescription([vprop1, vprop3, vprop5]), # TyrellCorp > OmniCorp + VariantDescription([vprop3, vprop5]), + # =========== A.4 - Everything with vprop3 & without vprop5/vprop6 =========== # + VariantDescription([vprop1, vprop3, vprop6]), + VariantDescription([vprop3, vprop6]), # TyrellCorp > OmniCorp + VariantDescription([vprop1, vprop3]), # TyrellCorp > OmniCorp + # =========== A.5 - vprop3 alone =========== # + VariantDescription([vprop3]), + + # ============ B - Everything without vprop3 ============ # + # ============ B.1 - Everything without vprop3 & with vprop4 ============ # + VariantDescription([vprop1, vprop4, vprop6]), + VariantDescription([vprop4, vprop6]), # TyrellCorp > OmniCorp + VariantDescription([vprop1, vprop4]), # TyrellCorp > OmniCorp + VariantDescription([vprop4]), + + # ============ B.2 - Everything without vprop3 & with vprop5 ============ # + VariantDescription([vprop1, vprop5, vprop6]), + VariantDescription([vprop5, vprop6]), # TyrellCorp > OmniCorp + VariantDescription([vprop1, vprop5]), # TyrellCorp > OmniCorp + VariantDescription([vprop5]), + + # == C - Everything without vprop3/vprop4/vprop5 and TyrellCorp > OmniCorp == # + VariantDescription([vprop1, vprop6]), + VariantDescription([vprop6]), + VariantDescription([vprop1]), ] + # fmt: on + + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) + + ddiff = deep_diff( + sort_and_filter_supported_variants( + vdescs=inputs_vdescs, + supported_vprops=[vprop1, vprop3, vprop4, vprop5, vprop6], + property_priorities=prio_vprops, + feature_priorities=prio_vfeats, + namespace_priorities=prio_namespaces, + ), + expected_vdescs, + ) + assert ddiff == {}, ddiff + + +# =================== `Validation Testing` ================== # def test_sort_and_filter_supported_variants_validation_errors( diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 466e66a..c005130 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -45,18 +45,6 @@ def test_get_configuration_files(): ) -@patch("variantlib.configuration.get_configuration_files") -def test_get_config_no_files(mock_get_config_files): - mock_get_config_files.return_value = { - ConfigEnvironments.LOCAL: Path("/nonexistent/config.toml"), - ConfigEnvironments.VIRTUALENV: Path("/nonexistent/config.toml"), - ConfigEnvironments.USER: Path("/nonexistent/config.toml"), - ConfigEnvironments.GLOBAL: Path("/nonexistent/config.toml"), - } - config = VariantConfiguration.get_config() - assert config == ConfigurationModel.default() - - @patch("variantlib.configuration.get_configuration_files") def test_get_default_config_with_no_file(mock_get_config_files): mock_get_config_files.return_value = { From 00044705c4f176d59fd212ae71df504b124edd62 Mon Sep 17 00:00:00 2001 From: Jonathan Dekhtiar Date: Thu, 17 Apr 2025 22:40:47 -0400 Subject: [PATCH 9/9] Final Cleanups --- tests/resolver/test_filtering.py | 388 ++++++++++++++++++------------- tests/resolver/test_lib.py | 183 +++++++++------ tests/resolver/test_sorting.py | 289 +++++++++++------------ variantlib/resolver/filtering.py | 53 ++--- variantlib/resolver/lib.py | 102 ++++---- variantlib/resolver/sorting.py | 18 +- 6 files changed, 540 insertions(+), 493 deletions(-) diff --git a/tests/resolver/test_filtering.py b/tests/resolver/test_filtering.py index 15862d8..f7a5756 100644 --- a/tests/resolver/test_filtering.py +++ b/tests/resolver/test_filtering.py @@ -30,12 +30,12 @@ def vprops() -> list[VariantProperty]: def vdescs(vprops: list[VariantProperty]) -> list[VariantDescription]: """Fixture to create a list of VariantDescription objects.""" assert len(vprops) == 2 - prop1, prop2 = vprops + vprop1, vprop2 = vprops return [ - VariantDescription([prop1]), - VariantDescription([prop2]), - VariantDescription([prop1, prop2]), + VariantDescription([vprop1]), + VariantDescription([vprop2]), + VariantDescription([vprop1, vprop2]), ] @@ -43,6 +43,8 @@ def vdescs(vprops: list[VariantProperty]) -> list[VariantDescription]: def test_remove_duplicates(vdescs: list[VariantDescription]): + assert len(vdescs) == 3 + # using `copy.deepcopy` to ensure that all objects are actually unique input_vdescs = [copy.deepcopy(random.choice(vdescs)) for _ in range(100)] filtered_vdescs = list(remove_duplicates(input_vdescs)) @@ -57,116 +59,106 @@ def test_remove_duplicates_empty(): assert list(remove_duplicates([])) == [] -def test_remove_duplicates_validation_error(): - with pytest.raises(ValidationError): - deque(remove_duplicates("not a list"), maxlen=0) # type: ignore[arg-type] - +@pytest.mark.parametrize( + "vdescs", + ["not a list", ["not a VariantDescription"]], +) +def test_remove_duplicates_validation_error(vdescs: list[VariantDescription]): with pytest.raises(ValidationError): - deque(remove_duplicates(["not a VariantDescription"]), maxlen=0) # type: ignore[list-item] + deque(remove_duplicates(vdescs=vdescs), maxlen=0) # ===================== `filter_variants_by_namespaces` ===================== # def test_filter_variants_by_namespaces(vdescs: list[VariantDescription]): + assert len(vdescs) == 3 vdesc1, vdesc2, _ = vdescs - # No namespace allowed - should return empty list + # No namespace forbidden - should return everything assert ( list( filter_variants_by_namespaces( vdescs=vdescs, - allowed_namespaces=[], + forbidden_namespaces=[], ) ) - == [] + == vdescs ) - # Non existing namespace allowed - should return empty list + # Non existing namespace forbidden - should return everything assert ( list( filter_variants_by_namespaces( vdescs=vdescs, - allowed_namespaces=["NonExistentNamespace"], + forbidden_namespaces=["NonExistentNamespace"], ) ) - == [] + == vdescs ) - # Only `OmniCorp` allowed - should return `vdesc1` + # Only `OmniCorp` forbidden - should return `vdesc2` assert list( filter_variants_by_namespaces( vdescs=vdescs, - allowed_namespaces=["OmniCorp"], + forbidden_namespaces=["OmniCorp"], ) - ) == [vdesc1] + ) == [vdesc2] - # Only `TyrellCorporation` allowed - should return `vdesc2` + # Only `TyrellCorporation` forbidden - should return `vdesc1` assert list( filter_variants_by_namespaces( vdescs=vdescs, - allowed_namespaces=["TyrellCorporation"], + forbidden_namespaces=["TyrellCorporation"], ) - ) == [vdesc2] + ) == [vdesc1] - # Both `OmniCorp` and `TyrellCorporation` - should return all `vdescs` - # Note: order should not matter + # Both `OmniCorp` and `TyrellCorporation` forbidden - should return empty + # Note: Order should not matter assert ( list( filter_variants_by_namespaces( vdescs=vdescs, - allowed_namespaces=["OmniCorp", "TyrellCorporation"], + forbidden_namespaces=["OmniCorp", "TyrellCorporation"], ) ) - == vdescs + == [] ) assert ( list( filter_variants_by_namespaces( vdescs=vdescs, - allowed_namespaces=["TyrellCorporation", "OmniCorp"], + forbidden_namespaces=["TyrellCorporation", "OmniCorp"], ) ) - == vdescs + == [] ) +@pytest.mark.parametrize( + ("vdescs", "forbidden_namespaces"), + [ + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + "not a list", + ), + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + [VariantProperty("not", "a", "str")], + ), + ("not a list", ["OmniCorp"]), + (["not a `VariantDescription`"], ["OmniCorp"]), + ], +) def test_filter_variants_by_namespaces_validation_error( - vdescs: list[VariantDescription], + vdescs: list[VariantDescription], forbidden_namespaces: list[str] ): with pytest.raises(ValidationError): deque( filter_variants_by_namespaces( vdescs=vdescs, - allowed_namespaces="not a list", # type: ignore[arg-type] - ), - maxlen=0, - ) - - with pytest.raises(ValidationError): - deque( - filter_variants_by_namespaces( - vdescs=vdescs, - allowed_namespaces=[1234], # type: ignore[list-item] - ), - maxlen=0, - ) - - with pytest.raises(ValidationError): - deque( - filter_variants_by_namespaces( - vdescs="not a list", # type: ignore[arg-type] - allowed_namespaces=["OmniCorp"], - ), - maxlen=0, - ) - - with pytest.raises(ValidationError): - deque( - filter_variants_by_namespaces( - vdescs=["not a `VariantDescription`"], # type: ignore[list-item] - allowed_namespaces=["OmniCorp"], + forbidden_namespaces=forbidden_namespaces, ), maxlen=0, ) @@ -175,121 +167,103 @@ def test_filter_variants_by_namespaces_validation_error( # ====================== `filter_variants_by_features` ====================== # -def test_filter_variants_by_features(vdescs: list[VariantDescription]): +def test_filter_variants_by_features( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 2 + vprop1, vprop2 = vprops + + assert len(vdescs) == 3 vdesc1, vdesc2, _ = vdescs - vfeat1 = VariantFeature(namespace="OmniCorp", feature="custom_feat") - vfeat2 = VariantFeature(namespace="TyrellCorporation", feature="client_id") + vfeat1 = vprop1.feature_object + vfeat2 = vprop2.feature_object - # No feature allowed - should return empty list + # No feature forbidden - should return everything assert ( list( filter_variants_by_features( vdescs=vdescs, - allowed_features=[], + forbidden_features=[], ) ) - == [] + == vdescs ) - # Non existing feature allowed - should return empty list + # Non existing feature forbidden - should return everything assert ( list( filter_variants_by_features( vdescs=vdescs, - allowed_features=[ + forbidden_features=[ VariantFeature(namespace="UmbrellaCorporation", feature="AI") ], ) ) - == [] + == vdescs ) - # Only `vfeat1` allowed - should return `vdesc1` + # Only `vfeat1` forbidden - should return `vdesc2` assert list( filter_variants_by_features( vdescs=vdescs, - allowed_features=[vfeat1], + forbidden_features=[vfeat1], ) - ) == [vdesc1] + ) == [vdesc2] - # Only `vfeat2` allowed - should return `vdesc2` + # Only `vfeat2` forbidden - should return `vdesc1` assert list( filter_variants_by_features( vdescs=vdescs, - allowed_features=[vfeat2], + forbidden_features=[vfeat2], ) - ) == [vdesc2] + ) == [vdesc1] - # Both of vfeats - should return all `vdescs` + # Both of vfeats forbidden - should return empty # Note: Order should not matter assert ( list( filter_variants_by_features( vdescs=vdescs, - allowed_features=[vfeat1, vfeat2], + forbidden_features=[vfeat1, vfeat2], ) ) - == vdescs + == [] ) assert ( list( filter_variants_by_features( vdescs=vdescs, - allowed_features=[vfeat2, vfeat1], + forbidden_features=[vfeat2, vfeat1], ) ) - == vdescs + == [] ) +@pytest.mark.parametrize( + ("vdescs", "forbidden_features"), + [ + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + "not a list", + ), + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + ["not a `VariantFeature`"], + ), + ("not a list", VariantFeature("a", "b")), + (["not a `VariantDescription`"], VariantFeature("a", "b")), + ], +) def test_filter_variants_by_features_validation_error( - vdescs: list[VariantDescription], + vdescs: list[VariantDescription], forbidden_features: list[VariantFeature] ): - vfeat = VariantFeature(namespace="namespace", feature="feature") - with pytest.raises(ValidationError): deque( filter_variants_by_features( - vdescs=vdescs, - allowed_features="not a list", # type: ignore[arg-type] - ), - maxlen=0, - ) - - with pytest.raises(ValidationError): - deque( - filter_variants_by_features( - vdescs=vdescs, - allowed_features=["not a `VariantFeature`"], # type: ignore[list-item] - ), - maxlen=0, - ) - - with pytest.raises(ValidationError): - deque( - filter_variants_by_features( - vdescs=vdescs, - allowed_features=[VariantProperty("not", "a", "feature")], # type: ignore[list-item] - ), - maxlen=0, - ) - - with pytest.raises(ValidationError): - deque( - filter_variants_by_features( - vdescs="not a list", # type: ignore[arg-type] - allowed_features=[vfeat], - ), - maxlen=0, - ) - - with pytest.raises(ValidationError): - deque( - filter_variants_by_features( - vdescs=["not a `VariantDescription`"], # type: ignore[list-item] - allowed_features=[vfeat], + vdescs=vdescs, forbidden_features=forbidden_features ), maxlen=0, ) @@ -303,8 +277,9 @@ def test_filter_variants_by_property( vprops: list[VariantProperty], ): assert len(vprops) == 2 - prop1, prop2 = vprops + vprop1, vprop2 = vprops + assert len(vdescs) == 3 vdesc1, vdesc2, _ = vdescs # No property allowed - should return empty list @@ -313,6 +288,7 @@ def test_filter_variants_by_property( filter_variants_by_property( vdescs=vdescs, allowed_properties=[], + forbidden_properties=[], ) ) == [] @@ -328,96 +304,188 @@ def test_filter_variants_by_property( namespace="UmbrellaCorporation", feature="AI", value="ChatBot" ) ], + forbidden_properties=[], + ) + ) + == [] + ) + + # Non existing property forbidden - should return empty list + assert ( + list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[], + forbidden_properties=[ + VariantProperty( + namespace="UmbrellaCorporation", feature="AI", value="ChatBot" + ) + ], ) ) == [] ) - # Only `prop1` allowed - should return `vdesc1` + # Only `vprop1` allowed - should return `vdesc1` if not forbidden explicitly assert list( filter_variants_by_property( vdescs=vdescs, - allowed_properties=[prop1], + allowed_properties=[vprop1], + forbidden_properties=[], ) ) == [vdesc1] - # Only `prop2` allowed - should return `vdesc2` + assert ( + list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[vprop1], + forbidden_properties=[vprop1], + ) + ) + == [] + ) + assert list( filter_variants_by_property( vdescs=vdescs, - allowed_properties=[prop2], + allowed_properties=[vprop1], + forbidden_properties=[vprop2], + ) + ) == [vdesc1] + + # Only `vprop2` allowed - should return `vdesc2` if not forbidden explicitly + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[vprop2], + forbidden_properties=[], + ) + ) == [vdesc2] + + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[vprop2], + forbidden_properties=[vprop1], ) ) == [vdesc2] - # Both of vfeats - should return all `vdescs` - # Note: Order should not matter assert ( list( filter_variants_by_property( vdescs=vdescs, - allowed_properties=[prop1, prop2], + allowed_properties=[vprop2], + forbidden_properties=[vprop2], ) ) - == vdescs + == [] ) + # Both of vprops - should return all `vdescs` if neither vprop1 or vprop2 is + # forbidden explictly + # Note: Order should not matter assert ( list( filter_variants_by_property( vdescs=vdescs, - allowed_properties=[prop2, prop1], + allowed_properties=[vprop1, vprop2], + forbidden_properties=[], ) ) == vdescs ) + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[vprop1, vprop2], + forbidden_properties=[vprop1], + ) + ) == [vdesc2] -def test_filter_variants_by_property_validation_error( - vdescs: list[VariantDescription], -): - vprop = VariantProperty(namespace="namespace", feature="feature", value="value") - - with pytest.raises(ValidationError): - deque( - filter_variants_by_property( - vdescs=vdescs, - allowed_properties="not a list", # type: ignore[arg-type] - ), - maxlen=0, + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[vprop1, vprop2], + forbidden_properties=[vprop2], ) + ) == [vdesc1] - with pytest.raises(ValidationError): - deque( + assert ( + list( filter_variants_by_property( vdescs=vdescs, - allowed_properties=["not a `VariantProperty`"], # type: ignore[list-item] - ), - maxlen=0, + allowed_properties=[vprop2, vprop1], + forbidden_properties=[], + ) ) + == vdescs + ) - with pytest.raises(ValidationError): - deque( - filter_variants_by_property( - vdescs=vdescs, - allowed_properties=[VariantFeature("not_a", "property")], # type: ignore[list-item] - ), - maxlen=0, + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[vprop2, vprop1], + forbidden_properties=[vprop1], ) + ) == [vdesc2] - with pytest.raises(ValidationError): - deque( - filter_variants_by_property( - vdescs="not a list", # type: ignore[arg-type] - allowed_properties=[vprop], - ), - maxlen=0, + assert list( + filter_variants_by_property( + vdescs=vdescs, + allowed_properties=[vprop2, vprop1], + forbidden_properties=[vprop2], ) + ) == [vdesc1] + +@pytest.mark.parametrize( + ("vdescs", "allowed_properties", "forbidden_properties"), + [ + ( + "not a list", + [VariantProperty("a", "b", "c")], + [VariantProperty("a", "b", "c")], + ), + ( + [VariantProperty("not", "a", "VariantDescription")], + [VariantProperty("a", "b", "c")], + [VariantProperty("a", "b", "c")], + ), + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + "not a list", + [VariantProperty("a", "b", "c")], + ), + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + ["not a `VariantFeature`"], + [VariantProperty("a", "b", "c")], + ), + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + [VariantProperty("a", "b", "c")], + "not a list", + ), + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + [VariantProperty("a", "b", "c")], + ["not a `VariantFeature`"], + ), + ], +) +def test_filter_variants_by_property_validation_error( + vdescs: list[VariantDescription], + allowed_properties: list[VariantProperty], + forbidden_properties: list[VariantProperty], +): with pytest.raises(ValidationError): deque( filter_variants_by_property( - vdescs=["not a `VariantDescription`"], # type: ignore[list-item] - allowed_properties=[vprop], + vdescs=vdescs, + allowed_properties=allowed_properties, + forbidden_properties=forbidden_properties, ), maxlen=0, ) diff --git a/tests/resolver/test_lib.py b/tests/resolver/test_lib.py index ab1d6f6..1af3527 100644 --- a/tests/resolver/test_lib.py +++ b/tests/resolver/test_lib.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import random import pytest @@ -169,90 +170,110 @@ def test_filter_variants_only_one_prop_allowed( inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) - assert list( - filter_variants( - vdescs=inputs_vdescs, - allowed_namespaces=["TyrellCorp"], - allowed_features=[vprop4.feature_object], - allowed_properties=[vprop4], + assert ( + list( + filter_variants( + vdescs=inputs_vdescs, + allowed_properties=[], + ) ) - ) == [VariantDescription([vprop4])] + == [] + ) assert list( filter_variants( vdescs=inputs_vdescs, - allowed_namespaces=None, - allowed_features=[vprop4.feature_object], allowed_properties=[vprop4], ) ) == [VariantDescription([vprop4])] - assert list( - filter_variants( - vdescs=inputs_vdescs, - allowed_namespaces=["TyrellCorp"], - allowed_features=None, - allowed_properties=[vprop4], + assert ( + list( + filter_variants( + vdescs=inputs_vdescs, + allowed_properties=[vprop4], + forbidden_namespaces=[vprop4.namespace], + ) ) - ) == [VariantDescription([vprop4])] + == [] + ) - assert list( - filter_variants( - vdescs=inputs_vdescs, - allowed_namespaces=None, - allowed_features=None, - allowed_properties=[vprop4], + assert ( + list( + filter_variants( + vdescs=inputs_vdescs, + allowed_properties=[vprop4], + forbidden_features=[vprop4.feature_object], + ) ) - ) == [VariantDescription([vprop4])] - - -def test_filter_variants_disallowed_feature_allowed_prop( - vdescs: list[VariantDescription], vprops: list[VariantProperty] -): - assert len(vprops) == 6 - _, vprop2, _, vprop4, _, _ = vprops - - inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) + == [] + ) assert ( list( filter_variants( vdescs=inputs_vdescs, - allowed_namespaces=None, - allowed_features=[vprop2.feature_object], allowed_properties=[vprop4], + forbidden_properties=[vprop4], ) ) == [] ) -def test_filter_variants_disallowed_namespace_allowed_prop( +def test_filter_variants_forbidden_feature_allowed_prop( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): assert len(vprops) == 6 - _, _, _, vprop4, _, _ = vprops + _, vprop2, _, vprop4, _, _ = vprops inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) - assert ( - list( - filter_variants( - vdescs=inputs_vdescs, - allowed_namespaces=["NotExisting"], - allowed_features=None, - allowed_properties=[vprop4], - ) + assert list( + filter_variants( + vdescs=inputs_vdescs, + allowed_properties=[vprop4], + forbidden_features=[vprop2.feature_object], ) - == [] - ) + ) == [VariantDescription([vprop4])] -def test_filter_variants_only_remove_duplicates(vdescs: list[VariantDescription]): +def test_filter_variants_forbidden_namespace_allowed_prop( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 6 + vprop1, _, _, vprop4, _, _ = vprops + + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) + + assert list( + filter_variants( + vdescs=inputs_vdescs, + allowed_properties=[vprop4], + forbidden_namespaces=["NotExisting"], + ) + ) == [VariantDescription([vprop4])] + + assert list( + filter_variants( + vdescs=inputs_vdescs, + allowed_properties=[vprop4], + forbidden_namespaces=[vprop1.namespace], + ) + ) == [VariantDescription([vprop4])] + + +def test_filter_variants_only_remove_duplicates( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): + assert len(vprops) == 6 + inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) ddiff = deep_diff( - list(filter_variants(vdescs=inputs_vdescs)), vdescs, ignore_ordering=True + list(filter_variants(vdescs=inputs_vdescs, allowed_properties=vprops)), + vdescs, + ignore_ordering=True, ) assert ddiff == {}, ddiff @@ -294,7 +315,8 @@ def test_filter_variants_remove_duplicates_and_namespaces( list( filter_variants( vdescs=inputs_vdescs, - allowed_namespaces=["TyrellCorp"], + allowed_properties=vprops, + forbidden_namespaces=["OmniCorp"], ) ), expected_vdescs, @@ -306,7 +328,7 @@ def test_filter_variants_remove_duplicates_and_namespaces( list( filter_variants_by_namespaces( remove_duplicates(vdescs=inputs_vdescs), - allowed_namespaces=["TyrellCorp"], + forbidden_namespaces=["OmniCorp"], ) ), expected_vdescs, @@ -319,7 +341,7 @@ def test_filter_variants_remove_duplicates_and_features( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): assert len(vprops) == 6 - vprop1, _, _, vprop4, vprop5, _ = vprops + vprop1, vprop2, vprop3, vprop4, vprop5, vprop6 = vprops inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) @@ -334,13 +356,18 @@ def test_filter_variants_remove_duplicates_and_features( VariantDescription([vprop5]), ] - allowed_features = [vprop1.feature_object, vprop4.feature_object] + forbidden_features = [ + vprop2.feature_object, + vprop3.feature_object, + vprop6.feature_object, + ] ddiff = deep_diff( list( filter_variants( vdescs=inputs_vdescs, - allowed_features=allowed_features, + allowed_properties=vprops, + forbidden_features=forbidden_features, ) ), expected_vdescs, @@ -352,7 +379,7 @@ def test_filter_variants_remove_duplicates_and_features( list( filter_variants_by_features( remove_duplicates(vdescs=inputs_vdescs), - allowed_features=allowed_features, + forbidden_features=forbidden_features, ) ), expected_vdescs, @@ -365,7 +392,7 @@ def test_filter_variants_remove_duplicates_and_properties( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): assert len(vprops) == 6 - vprop1, _, _, vprop4, vprop5, _ = vprops + vprop1, vprop2, _, vprop4, vprop5, _ = vprops inputs_vdescs = shuffle_vdescs_with_duplicates(vdescs=vdescs) @@ -380,13 +407,14 @@ def test_filter_variants_remove_duplicates_and_properties( VariantDescription([vprop5]), ] - allowed_properties = [vprop1, vprop4, vprop5] + allowed_properties = [vprop1, vprop2, vprop4, vprop5] ddiff = deep_diff( list( filter_variants( vdescs=inputs_vdescs, allowed_properties=allowed_properties, + forbidden_properties=[vprop2], ) ), expected_vdescs, @@ -399,6 +427,7 @@ def test_filter_variants_remove_duplicates_and_properties( filter_variants_by_property( remove_duplicates(vdescs=inputs_vdescs), allowed_properties=allowed_properties, + forbidden_properties=[vprop2], ) ), expected_vdescs, @@ -494,36 +523,42 @@ def test_sort_and_filter_supported_variants( assert ddiff == {}, ddiff -# =================== `Validation Testing` ================== # +# # =================== `Validation Testing` ================== # +@pytest.mark.parametrize( + ("vdescs", "vprops"), + [ + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + "not a list", + ), + ( + [VariantDescription([VariantProperty("a", "b", "c")])], + [VariantFeature("not_a", "VariantProperty")], + ), + ("not a list", VariantProperty("a", "b", "c")), + (["not a `VariantDescription`"], VariantProperty("a", "b", "c")), + ], +) def test_sort_and_filter_supported_variants_validation_errors( vdescs: list[VariantDescription], vprops: list[VariantProperty] ): - with pytest.raises(ValidationError): - sort_and_filter_supported_variants( - vdescs="not a list", # type: ignore [arg-type] - supported_vprops=vprops, - ) + feature_priorities = [] + with contextlib.suppress(TypeError, AttributeError): + feature_priorities = list({vprop.feature_object for vprop in vprops}) with pytest.raises(ValidationError): sort_and_filter_supported_variants( - vdescs=["not a VariantDescription"], # type: ignore [list-item] + vdescs=vdescs, supported_vprops=vprops, + feature_priorities=feature_priorities, ) - with pytest.raises(ValidationError): - sort_and_filter_supported_variants( - vdescs=vdescs, - supported_vprops="not a list", # type: ignore [arg-type] - ) - - with pytest.raises(ValidationError): - sort_and_filter_supported_variants( - vdescs=vdescs, - supported_vprops=["not a VariantProperty"], # type: ignore [list-item] - ) +def test_sort_and_filter_supported_variants_validation_errors_with_no_priority( + vdescs: list[VariantDescription], vprops: list[VariantProperty] +): # This one specifies no ordering/priority => can't sort with pytest.raises(ValidationError, match="has no priority"): sort_and_filter_supported_variants( diff --git a/tests/resolver/test_sorting.py b/tests/resolver/test_sorting.py index 95afaf9..bb75f2e 100644 --- a/tests/resolver/test_sorting.py +++ b/tests/resolver/test_sorting.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import pytest @@ -35,20 +37,20 @@ def test_negative_get_property_priority(): assert get_property_priority(vprop1, [vprop2]) == sys.maxsize -def test_get_property_priority_validation_error(): - with pytest.raises(ValidationError): - get_property_priority(vprop="not a `VariantProperty`", property_priorities=None) # type: ignore[arg-type] - - vprop = VariantProperty(namespace="OmniCorp", feature="feature", value="value") - - with pytest.raises(ValidationError): - get_property_priority(vprop=vprop, property_priorities="not a list or None") # type: ignore[arg-type] - +@pytest.mark.parametrize( + ("vprop", "property_priorities"), + [ + ("not a `VariantProperty`", None), + (VariantProperty("a", "b", "c"), "not a list or None"), + (VariantProperty("a", "b", "c"), VariantProperty("not", "a", "list")), + (VariantProperty("a", "b", "c"), [{"not a VariantProperty": True}]), + ], +) +def test_get_property_priority_validation_error( + vprop: VariantProperty, property_priorities: list[VariantProperty] | None +): with pytest.raises(ValidationError): - get_property_priority( - vprop=vprop, - property_priorities=[{"not a VariantProperty": True}], # type: ignore[list-item] - ) + get_property_priority(vprop=vprop, property_priorities=property_priorities) # ========================== get_feature_priority =========================== # @@ -74,20 +76,20 @@ def test_negative_get_feature_priority(): assert get_feature_priority(vprop, [vfeat]) == sys.maxsize -def test_get_feature_priority_validation_error(): +@pytest.mark.parametrize( + ("vprop", "feature_priorities"), + [ + ("not a `VariantProperty`", None), + (VariantProperty("a", "b", "c"), "not a list or None"), + (VariantProperty("a", "b", "c"), VariantFeature("not_a", "list")), + (VariantProperty("a", "b", "c"), [{"not a VariantFeature": True}]), + ], +) +def test_get_feature_priority_validation_error( + vprop: VariantProperty, feature_priorities: list[VariantFeature] | None +): with pytest.raises(ValidationError): - get_feature_priority(vprop="not a `VariantProperty`", feature_priorities=None) # type: ignore[arg-type] - - vprop = VariantProperty(namespace="OmniCorp", feature="feature", value="value") - - with pytest.raises(ValidationError): - get_feature_priority(vprop=vprop, feature_priorities="not a list or None") # type: ignore[arg-type] - - with pytest.raises(ValidationError): - get_feature_priority( - vprop=vprop, - feature_priorities=[{"not a VariantFeature": True}], # type: ignore[list-item] - ) + get_feature_priority(vprop=vprop, feature_priorities=feature_priorities) # ========================= get_namespace_priority ========================== # @@ -111,20 +113,19 @@ def test_negative_get_namespace_priority(): assert get_namespace_priority(vprop, ["OtherCorp"]) == sys.maxsize -def test_get_namespace_priority_validation_error(): +@pytest.mark.parametrize( + ("vprop", "namespace_priorities"), + [ + ("not a `VariantProperty`", None), + (VariantProperty("a", "b", "c"), "not a list or None"), + (VariantProperty("a", "b", "c"), [{"not a str": True}]), + ], +) +def test_get_namespace_priority_validation_error( + vprop: VariantProperty, namespace_priorities: list[str] | None +): with pytest.raises(ValidationError): - get_namespace_priority( - vprop="not a `VariantProperty`", # type: ignore[arg-type] - namespace_priorities=None, - ) - - vprop = VariantProperty(namespace="OmniCorp", feature="feature", value="value") - - with pytest.raises(ValidationError): - get_namespace_priority(vprop=vprop, namespace_priorities="not a list or None") # type: ignore[arg-type] - - with pytest.raises(ValidationError): - get_namespace_priority(vprop=vprop, namespace_priorities=[{"not a str": True}]) # type: ignore[list-item] + get_namespace_priority(vprop=vprop, namespace_priorities=namespace_priorities) # =================== get_variant_property_priority_tuple =================== # @@ -142,17 +143,39 @@ def test_get_variant_property_priority_tuple(): ] namespace_priorities = ["OtherCorp"] assert get_variant_property_priority_tuple( - vprop, property_priorities, feature_priorities, namespace_priorities + vprop, namespace_priorities, feature_priorities, property_priorities ) == (1, 0, sys.maxsize) -def test_get_variant_property_priority_tuple_validation_error(): +@pytest.mark.parametrize( + ("vprop", "namespace_priorities", "feature_priorities", "property_priorities"), + [ + ("not a `VariantProperty`", None, None, None), + (VariantProperty("a", "b", "c"), "not a list or None", None, None), + ( + VariantProperty("a", "b", "c"), + [VariantProperty("not", "a", "str")], + None, + None, + ), + (VariantProperty("a", "b", "c"), None, "not a list or None", None), + (VariantProperty("a", "b", "c"), None, ["not a VariantFeature"], None), + (VariantProperty("a", "b", "c"), None, None, "not a list or None"), + (VariantProperty("a", "b", "c"), None, None, ["not a VariantProperty"]), + ], +) +def test_get_variant_property_priority_tuple_validation_error( + vprop: VariantProperty, + namespace_priorities: list[str] | None, + feature_priorities: list[VariantFeature] | None, + property_priorities: list[VariantProperty] | None, +): with pytest.raises(ValidationError): get_variant_property_priority_tuple( - vprop="not a VariantProperty", # type: ignore[arg-type] - property_priorities=None, - feature_priorities=None, - namespace_priorities=None, + vprop=vprop, + namespace_priorities=namespace_priorities, + feature_priorities=feature_priorities, + property_priorities=property_priorities, ) @@ -184,7 +207,7 @@ def test_sort_variant_properties(): ] namespace_priorities = ["OmniCorp", "OtherCorp"] sorted_vprops = sort_variant_properties( - vprop_list, property_priorities, feature_priorities, namespace_priorities + vprop_list, namespace_priorities, feature_priorities, property_priorities ) assert sorted_vprops == [ # sorted by property priorities @@ -203,79 +226,39 @@ def test_sort_variant_properties(): ] -def test_sort_variant_properties_validation_error(): - vprops = [VariantProperty(namespace="OmniCorp", feature="feat", value="value")] - with pytest.raises(ValidationError): - sort_variant_properties( - vprops="not a list", # type: ignore[arg-type] - property_priorities=None, - feature_priorities=None, - namespace_priorities=None, - ) - - with pytest.raises(ValidationError): - sort_variant_properties( - vprops=["not a VariantProperty"], # type: ignore[list-item] - property_priorities=None, - feature_priorities=None, - namespace_priorities=None, - ) - +@pytest.mark.parametrize( + ("vprops", "namespace_priorities", "feature_priorities", "property_priorities"), + [ + ("not a list of `VariantProperty`", None, None, None), + (VariantProperty("not", "a", "list"), None, None, None), + (["not a `VariantProperty`"], None, None, None), + ([VariantProperty("a", "b", "c")], "not a list or None", None, None), + ( + [VariantProperty("a", "b", "c")], + [VariantProperty("not", "a", "str")], + None, + None, + ), + ([VariantProperty("a", "b", "c")], None, "not a list or None", None), + ([VariantProperty("a", "b", "c")], None, ["not a VariantFeature"], None), + ([VariantProperty("a", "b", "c")], None, None, "not a list or None"), + ([VariantProperty("a", "b", "c")], None, None, ["not a VariantProperty"]), + # Can't sort without any ordering priorities + ([VariantProperty("a", "b", "c")], None, None, None), + ], +) +def test_sort_variant_properties_validation_error( + vprops: list[VariantProperty], + namespace_priorities: list[str] | None, + feature_priorities: list[VariantFeature] | None, + property_priorities: list[VariantProperty] | None, +): with pytest.raises(ValidationError): sort_variant_properties( vprops=vprops, - property_priorities="not a list", # type: ignore[arg-type] - feature_priorities=None, - namespace_priorities=None, - ) - - with pytest.raises(ValidationError): - sort_variant_properties( - vprops=vprops, - property_priorities=["not a VariantProperty"], # type: ignore[list-item] - feature_priorities=None, - namespace_priorities=None, - ) - - with pytest.raises(ValidationError): - sort_variant_properties( - vprops=vprops, - property_priorities=None, - feature_priorities="not a list", # type: ignore[arg-type] - namespace_priorities=None, - ) - - with pytest.raises(ValidationError): - sort_variant_properties( - vprops=vprops, - property_priorities=None, - feature_priorities=["not a VariantProperty"], # type: ignore[list-item] - namespace_priorities=None, - ) - - with pytest.raises(ValidationError): - sort_variant_properties( - vprops=vprops, - property_priorities=None, - feature_priorities=None, - namespace_priorities="not a list", # type: ignore[arg-type] - ) - - with pytest.raises(ValidationError): - sort_variant_properties( - vprops=vprops, - property_priorities=None, - feature_priorities=None, - namespace_priorities=[{"not a str": True}], # type: ignore[list-item] - ) - - # Can't sort without ordering priorities - with pytest.raises(ValidationError, match="has no priority"): - sort_variant_properties( - vprops=vprops, - property_priorities=None, - feature_priorities=None, - namespace_priorities=None, # type: ignore[list-item] + namespace_priorities=namespace_priorities, + feature_priorities=feature_priorities, + property_priorities=property_priorities, ) @@ -373,68 +356,58 @@ def test_sort_variants_descriptions(): ) == [vdesc3, vdesc1, vdesc2] -def test_sort_variants_descriptions_ranking_validation_error(): - vprops = [VariantProperty(namespace="OmniCorp", feature="feat", value="value")] - vdesc1 = [ +@pytest.mark.parametrize( + "vdesc", + [ VariantDescription( properties=[ VariantProperty( namespace="OmniCorp", feature="feat", value="other_value" ) ] - ) - ] - - # Test with a completely different property (same feature, different value) - with pytest.raises(ValidationError, match="Filtering should be applied first."): - sort_variants_descriptions( - vdescs=vdesc1, - property_priorities=vprops, - ) - - vdescs2 = [ + ), VariantDescription( properties=[ - *vprops, + VariantProperty(namespace="OmniCorp", feature="feat", value="value"), VariantProperty( namespace="OmniCorp", feature="other_feat", value="other_value" ), ], - ) - ] - - # Test with an extra property not included in the property priorities - with pytest.raises(ValidationError, match="Filtering should be applied first."): - sort_variants_descriptions( - vdescs=vdescs2, - property_priorities=vprops, - ) - - -def test_sort_variants_descriptions_validation_error(): + ), + ], +) +def test_sort_variants_descriptions_ranking_validation_error(vdesc: VariantDescription): vprops = [VariantProperty(namespace="OmniCorp", feature="feat", value="value")] - vdescs = [VariantDescription(properties=vprops)] - - with pytest.raises(ValidationError): - sort_variants_descriptions( - vdescs="not a list", # type: ignore[arg-type] - property_priorities=vprops, - ) - with pytest.raises(ValidationError): + # Test with a completely different property (same feature, different value) + with pytest.raises(ValidationError, match="Filtering should be applied first."): sort_variants_descriptions( - vdescs=vprops, # type: ignore[arg-type] + vdescs=[vdesc], property_priorities=vprops, ) - with pytest.raises(ValidationError): - sort_variants_descriptions( - vdescs=vdescs, - property_priorities="not a list", # type: ignore[arg-type] - ) +@pytest.mark.parametrize( + ("vdescs", "property_priorities"), + [ + ("not a list", [VariantProperty("a", "b", "c")]), + (["not a VariantDescription"], [VariantProperty("a", "b", "c")]), + (VariantDescription([VariantProperty("a", "b", "c")]), "not a list or None"), + ( + VariantDescription([VariantProperty("a", "b", "c")]), + VariantProperty("not", "a", "list"), + ), + ( + VariantDescription([VariantProperty("a", "b", "c")]), + [{"not a VariantProperty": True}], + ), + ], +) +def test_sort_variants_descriptions_validation_error( + vdescs: list[VariantDescription], property_priorities: list[VariantProperty] +): with pytest.raises(ValidationError): sort_variants_descriptions( vdescs=vdescs, - property_priorities=vdescs, # type: ignore[arg-type] + property_priorities=property_priorities, ) diff --git a/variantlib/resolver/filtering.py b/variantlib/resolver/filtering.py index ae6ae81..6e97cc1 100644 --- a/variantlib/resolver/filtering.py +++ b/variantlib/resolver/filtering.py @@ -44,21 +44,15 @@ def _should_include(vdesc: VariantDescription) -> bool: def filter_variants_by_namespaces( vdescs: Iterable[VariantDescription], - allowed_namespaces: list[str], - forbidden_namespaces: list[str] | None = None, + forbidden_namespaces: list[str], ) -> Generator[VariantDescription]: """ Filters out `VariantDescription` that contain any unsupported variant namespace. ** Implementation Note:** - - Installer will provide the list of allowed namespaces from variant provider - plugins. - - User can [optionally] provide a list of forbidden namespaces to be excluded. - Forbidden namespaces take precedence of "allowed namespaces" and will be excluded. + - Installer will provide a list of forbidden namespaces by the user. :param vdescs: list of `VariantDescription` to filter. - :param allowed_namespaces: List of allowed variant namespaces as `str`. - Any variant namespace not explicitly allowed is forbidden :param forbidden_namespaces: List of forbidden variant namespaces as `str`. :return: Filtered list of `VariantDescription`. """ @@ -68,11 +62,10 @@ def filter_variants_by_namespaces( # Input validation validate_type(vdescs, Iterable) - validate_type(allowed_namespaces, list[str]) validate_type(forbidden_namespaces, list[str]) # Note: for performance reasons we convert the list to a set to avoid O(n) lookups - _allowed_namespaces = set(allowed_namespaces) + _forbidden_namespaces = set(forbidden_namespaces) def _should_include(vdesc: VariantDescription) -> bool: """ @@ -80,19 +73,17 @@ def _should_include(vdesc: VariantDescription) -> bool: """ validate_type(vdesc, VariantDescription) - if any( - vprop.namespace not in _allowed_namespaces for vprop in vdesc.properties - ): + if forbidden_vprops := [ + vprop + for vprop in vdesc.properties + if vprop.namespace in _forbidden_namespaces + ]: logger.info( - "Variant `%(vhash)s` has been rejected because one of variant " - "namespaces `[%(namespaces)s]` is not allowed. Allowed: " - "`[%(allowed_namespaces)s]`.", + "Variant `%(vhash)s` has been rejected because one or many of the " + "variant namespaces `[%(vprops)s]` have been explicitly rejected.", { "vhash": vdesc.hexdigest, - "namespaces": ", ".join( - [vprop.namespace for vprop in vdesc.properties] - ), - "allowed_namespaces": ", ".join(_allowed_namespaces), + "vprops": ", ".join([vprop.to_str() for vprop in forbidden_vprops]), }, ) return False @@ -104,19 +95,15 @@ def _should_include(vdesc: VariantDescription) -> bool: def filter_variants_by_features( vdescs: Iterable[VariantDescription], - allowed_features: list[VariantFeature], - forbidden_features: list[VariantFeature] | None = None, + forbidden_features: list[VariantFeature], ) -> Generator[VariantDescription]: """ Filters out `VariantDescription` that contain any unsupported variant feature. ** Implementation Note:** - - Installer will provide the list of allowed features from variant provider plugins. - - User can [optionally] provide a list of forbidden features to be excluded. - Forbidden features take precedence of "allowed features" and will be excluded. + - Installer will provide a list of forbidden VariantFeature by the user. :param vdescs: list of `VariantDescription` to filter. - :param allowed_features: List of allowed `VariantFeature`. :param forbidden_features: List of forbidden `VariantFeature`. :return: Filtered list of `VariantDescription`. """ @@ -126,11 +113,9 @@ def filter_variants_by_features( # Input validation validate_type(vdescs, Iterable) - validate_type(allowed_features, list[VariantFeature]) validate_type(forbidden_features, list[VariantFeature]) # for performance reasons we convert the list to a set to avoid O(n) lookups - allowed_feature_hexs = {vfeat.feature_hash for vfeat in allowed_features} forbidden_feature_hexs = {vfeat.feature_hash for vfeat in forbidden_features} def _should_include(vdesc: VariantDescription) -> bool: @@ -142,14 +127,11 @@ def _should_include(vdesc: VariantDescription) -> bool: if forbidden_vprops := [ vprop for vprop in vdesc.properties - if ( - vprop.feature_hash not in allowed_feature_hexs - or vprop.feature_hash in forbidden_feature_hexs - ) + if vprop.feature_hash in forbidden_feature_hexs ]: logger.info( "Variant `%(vhash)s` has been rejected because one or many of the " - "variant features `[%(vprops)s]` is not supported on this platform.", + "variant features `[%(vprops)s]` have been explicitly rejected.", { "vhash": vdesc.hexdigest, "vprops": ", ".join([vprop.to_str() for vprop in forbidden_vprops]), @@ -209,8 +191,9 @@ def _should_include(vdesc: VariantDescription) -> bool: ) ]: logger.info( - "Variant `%(vhash)s` has been rejected because one of the variant " - "features `[%(vprops)s]` is not supported on this platform.", + "Variant `%(vhash)s` has been rejected because one or many of the " + "variant properties `[%(vprops)s]` are not supported or have been " + "explicitly rejected.", { "vhash": vdesc.hexdigest, "vprops": ", ".join([vprop.to_str() for vprop in forbidden_vprops]), diff --git a/variantlib/resolver/lib.py b/variantlib/resolver/lib.py index d65df55..a9b041d 100644 --- a/variantlib/resolver/lib.py +++ b/variantlib/resolver/lib.py @@ -4,6 +4,7 @@ from variantlib.models.validators import validate_type from variantlib.models.variant import VariantDescription +from variantlib.models.variant import VariantFeature from variantlib.models.variant import VariantProperty from variantlib.resolver.filtering import filter_variants_by_features from variantlib.resolver.filtering import filter_variants_by_namespaces @@ -15,106 +16,92 @@ if TYPE_CHECKING: from collections.abc import Generator - from variantlib.models.variant import VariantFeature - def filter_variants( vdescs: list[VariantDescription], - allowed_namespaces: list[str] | None = None, + allowed_properties: list[VariantProperty], forbidden_namespaces: list[str] | None = None, - allowed_features: list[VariantFeature] | None = None, forbidden_features: list[VariantFeature] | None = None, - allowed_properties: list[VariantProperty] | None = None, forbidden_properties: list[VariantProperty] | None = None, ) -> Generator[VariantDescription]: """ Filters out a `list` of `VariantDescription` with the following filters: - Duplicates removed - - Unsupported `variant namespaces` removed - if `allowed_namespaces` is not None - - Unsupported `variant features` removed - if `allowed_features` is not None - - Unsupported `variant feature values` removed - if `allowed_properties` is not None + - Only allowed `variant properties` kept + + # Optionally: + - Forbidden `variant namespaces` removed - if `forbidden_namespaces` is not None + - Forbidden `variant features` removed - if `forbidden_features` is not None + - Forbidden `variant properties` removed - if `forbidden_properties` is not None :param vdescs: list of `VariantDescription` to filter. - :param allowed_namespaces: List of allowed variant namespaces as `str`. + :param allowed_properties: List of allowed `VariantProperty`. :param forbidden_namespaces: List of forbidden variant namespaces as `str`. - :param allowed_features: List of allowed `VariantFeature`. :param forbidden_features: List of forbidden `VariantFeature`. - :param allowed_properties: List of allowed `VariantProperty`. :param forbidden_properties: List of forbidden `VariantProperty`. :return: Filtered list of `VariantDescription`. """ - # ========================= IMPLEMENTATION NOTE ========================= # - # Technically, only step 4 is absolutely necessary to filter out the - # `VariantDescription` that are not supported on this platform. - # 1. There should never be any duplicates on the index + # Input validation + validate_type(vdescs, list[VariantDescription]) + validate_type(allowed_properties, list[VariantProperty]) + + if forbidden_namespaces is not None: + validate_type(forbidden_namespaces, list[str]) + + if forbidden_features is not None: + validate_type(forbidden_features, list[VariantFeature]) + + if forbidden_properties is not None: + validate_type(forbidden_properties, list[VariantProperty]) + + # Step 1 + # Remove duplicates - There should never be any duplicates on the index # - filename collision (same filename & same hash) # - hash collision inside `variants.json` # => Added for safety and to avoid any potential bugs # (Note: In all fairness, even if it was to happen, it would most - # likely not be a problem given that we just pick the best match - # 2. namespaces are included inside the `VariantProperty` of step 4 - # 3. features are included inside the `VariantProperty` of step 4 - # - # However, I (Jonathan Dekhtiar) strongly recommend to keep all of them: - # - Easier to read and understand - # - Easier to debug - # - Easier to maintain - # - Easier to extend - # - Easier to test - # - Allows `tooling` to provide CLI/configuration flags to explicitly - # reject a `namespace` or `namespace::feature` without complex - # processing. - # ======================================================================= # - - # Step 1: Remove duplicates - should never happen, but just in case + # likely not be a problem given that we just pick the best match) result = remove_duplicates(vdescs) - # Step 2: Remove any `VariantDescription` which declare any `VariantProperty` with - # a variant namespace unsupported on this platform. - if allowed_namespaces is not None: + # Step 2 [Optional] + # Remove any `VariantDescription` which declares any `VariantProperty` with + # a variant namespace explicitly forbidden by the user. + if forbidden_namespaces is not None: result = filter_variants_by_namespaces( vdescs=result, - allowed_namespaces=allowed_namespaces, forbidden_namespaces=forbidden_namespaces, ) - elif forbidden_namespaces is not None: - raise ValueError( - "`forbidden_namespaces` without `allowed_namespaces` is not supported." - ) - # Step 3: Remove any `VariantDescription` which declare any `VariantProperty` with - # `namespace :: feature` (aka. `VariantFeature`) unsupported on this platform. - if allowed_features is not None: + # Step 3 [Optional] + # Remove any `VariantDescription` which declares any `VariantProperty` with + # `namespace :: feature` (aka. `VariantFeature`) explicitly forbidden by the user. + if forbidden_features is not None: result = filter_variants_by_features( vdescs=result, - allowed_features=allowed_features, forbidden_features=forbidden_features, ) - elif forbidden_features is not None: - raise ValueError( - "`forbidden_features` without `allowed_features` is not supported." - ) - # Step 4: Remove any `VariantDescription` which declare any `VariantProperty` - # `namespace :: feature :: value` unsupported on this platform. + # Step 4 [Optional] + # Remove any `VariantDescription` which declare any `VariantProperty` + # `namespace :: feature :: value` unsupported on this platform or explicitly + # forbidden by the user. if allowed_properties is not None: result = filter_variants_by_property( vdescs=result, allowed_properties=allowed_properties, forbidden_properties=forbidden_properties, ) - elif forbidden_properties is not None: - raise ValueError( - "`forbidden_properties` without `allowed_properties` is not supported." - ) yield from result def sort_and_filter_supported_variants( vdescs: list[VariantDescription], - supported_vprops: list[VariantProperty] | None = None, + supported_vprops: list[VariantProperty], + forbidden_namespaces: list[str] | None = None, + forbidden_features: list[VariantFeature] | None = None, + forbidden_properties: list[VariantProperty] | None = None, namespace_priorities: list[str] | None = None, feature_priorities: list[VariantFeature] | None = None, property_priorities: list[VariantProperty] | None = None, @@ -133,7 +120,7 @@ def sort_and_filter_supported_variants( validate_type(vdescs, list[VariantDescription]) if supported_vprops is None: - """No sdupported properties provided, return no variants.""" + """No supported properties provided, return no variants.""" return [] validate_type(supported_vprops, list[VariantProperty]) @@ -143,9 +130,10 @@ def sort_and_filter_supported_variants( filtered_vdescs = list( filter_variants( vdescs=vdescs, - allowed_namespaces=namespace_priorities, - allowed_features=[vprop.feature_object for vprop in supported_vprops], allowed_properties=supported_vprops, + forbidden_namespaces=forbidden_namespaces, + forbidden_features=forbidden_features, + forbidden_properties=forbidden_properties, ) ) diff --git a/variantlib/resolver/sorting.py b/variantlib/resolver/sorting.py index 856478b..a4606c3 100644 --- a/variantlib/resolver/sorting.py +++ b/variantlib/resolver/sorting.py @@ -90,17 +90,17 @@ def get_namespace_priority( def get_variant_property_priority_tuple( vprop: VariantProperty, - property_priorities: list[VariantProperty] | None, - feature_priorities: list[VariantFeature] | None, namespace_priorities: list[str] | None, + feature_priorities: list[VariantFeature] | None, + property_priorities: list[VariantProperty] | None, ) -> tuple[int, int, int]: """ Get the variant property priority of a `VariantProperty` object. :param vprop: `VariantProperty` object. - :param property_priorities: ordered list of `VariantProperty` objects. - :param feature_priorities: ordered list of `VariantFeature` objects. :param namespace_priorities: ordered list of `str` objects. + :param feature_priorities: ordered list of `VariantFeature` objects. + :param property_priorities: ordered list of `VariantProperty` objects. :return: Variant property priority of the `VariantProperty` object. """ validate_type(vprop, VariantProperty) @@ -124,17 +124,17 @@ def get_variant_property_priority_tuple( def sort_variant_properties( vprops: list[VariantProperty], - property_priorities: list[VariantProperty] | None, - feature_priorities: list[VariantFeature] | None, namespace_priorities: list[str] | None, + feature_priorities: list[VariantFeature] | None, + property_priorities: list[VariantProperty] | None, ) -> list[VariantProperty]: """ Sort a list of `VariantProperty` objects based on their priority. :param vprops: List of `VariantProperty` objects. - :param property_priorities: ordered list of `VariantProperty` objects. - :param feature_priorities: ordered list of `VariantFeature` objects. :param namespace_priorities: ordered list of `str` objects. + :param feature_priorities: ordered list of `VariantFeature` objects. + :param property_priorities: ordered list of `VariantProperty` objects. :return: Sorted list of `VariantProperty` objects. """ validate_type(vprops, list[VariantProperty]) @@ -142,7 +142,7 @@ def sort_variant_properties( return sorted( vprops, key=lambda x: get_variant_property_priority_tuple( - x, property_priorities, feature_priorities, namespace_priorities + x, namespace_priorities, feature_priorities, property_priorities ), )