Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions sunbeam-python/sunbeam/features/interface/v1/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,32 @@ def klass(self) -> Type["EnableDisableFeature"]:
)
return klass

@property
def group(self) -> typing.Optional[Type["BaseFeatureGroup"]]:
"""Return the feature group class if the requirement names a group."""
return groups().get(self.name)

@property
def feature_klasses(self) -> list[Type["EnableDisableFeature"]]:
"""Return the feature classes satisfying this requirement.

A requirement naming a group is satisfied by any one of the group's
features, which are mutually exclusive providers of the same
functionality.
"""
if group := self.group:
klasses = [
klass
for klass in features().values()
if klass.group is group and issubclass(klass, EnableDisableFeature)
]
if not klasses:
raise InvalidRequirementError(
f"Feature group {self.name} has no enable/disable features"
)
return klasses
return [self.klass]


@typing.runtime_checkable
class NamedEnabledDisableFeatureProtocol(typing.Protocol):
Expand Down Expand Up @@ -714,11 +740,19 @@ def check_enablement_requirements(
if not feature.is_enabled(deployment.get_client()):
continue
for requirement in feature.get_requirements(deployment):
if requirement.name != self.name:
"""
For future:
This check looks for one of the features being enabled
It does not consider the case of both features being enabled.
e.g observability.embedded or observability.external.
"""
if requirement.name != self.name and not (
Comment thread
mv-2112 marked this conversation as resolved.
requirement.group is not None and self.group is requirement.group
):
continue
if state == "disable":
raise HasRequirersFeaturesError(
f"{feature.name} is enabled and requires {self.name}"
f"{feature.name} is enabled and requires {requirement.name}"
)
message = (
f"Feature {feature.name} is enabled and "
Expand All @@ -733,6 +767,33 @@ def check_enablement_requirements(
def enable_requirements(self, deployment: Deployment, show_hints: bool):
"""Iterate through requirements, enable features if possible."""
for requirement in self.get_requirements(deployment):
if group := requirement.group:
# A group requirement is satisfied by any enabled member and
# is never auto-enabled because the user chooses the provider.
klasses = requirement.feature_klasses
enabled_klass = next(
(
klass
for klass in klasses
if klass().is_enabled(deployment.get_client())
),
None,
)
if enabled_klass is not None:
self.check_enabled_requirement_is_compatible(
deployment,
FeatureRequirement(
f"{enabled_klass().name}{requirement.specifier}"
),
)
continue
if requirement.optional:
continue
members = ", ".join(f"'{klass().name}'" for klass in klasses)
raise FeatureError(
f"Feature {self.name} requires the {group.name} feature"
f" to be enabled. Enable one of {members} and retry."
)
if not issubclass(requirement.klass, EnableDisableFeature):
LOG.debug(
"Skipping %s as it is not of type EnableDisableFeature",
Expand Down
124 changes: 123 additions & 1 deletion sunbeam-python/tests/unit/sunbeam/features/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
from sunbeam.feature_manager import FeatureManager
from sunbeam.features.interface.v1.base import (
BaseFeature,
BaseFeatureGroup,
EnableDisableFeature,
FeatureError,
FeatureRequirement,
HasRequirersFeaturesError,
IncompatibleVersionError,
MissingFeatureError,
MissingVersionInfoError,
Expand Down Expand Up @@ -310,6 +312,34 @@ def run_disable_plans(self, deployment) -> None:
return CompatibleFeature


class TestFeatureGroup(BaseFeatureGroup):
name = "test_requirement_group"


def group_member_klass(name_: str, enabled: bool) -> type[EnableDisableFeature]:
class GroupMember(EnableDisableFeature):
name = name_
group = TestFeatureGroup

def is_enabled(self, client) -> bool:
return enabled

return GroupMember


def test_requirement_feature_group():
member_a = group_member_klass("test_requirement_group.a", enabled=False)
member_b = group_member_klass("test_requirement_group.b", enabled=False)

requirement = FeatureRequirement("test_requirement_group")

assert requirement.group is TestFeatureGroup
assert {klass.name for klass in requirement.feature_klasses} == {
member_a.name,
member_b.name,
}


class TestEnableDisableFeature:
def test_check_enabled_feature_is_compatible_with_compatible_requirement(
self, deployment, mocker
Expand Down Expand Up @@ -446,7 +476,8 @@ def test_core_features_requirements(self, deployment, klass):
feature = klass()

for requirement in feature.requires:
feature.check_feature_class_is_compatible(requirement.klass(), requirement)
for required_klass in requirement.feature_klasses:
feature.check_feature_class_is_compatible(required_klass(), requirement)

def test_check_enablement_requirements_with_enabled_compatible_requirement(
self, deployment, mocker
Expand Down Expand Up @@ -591,6 +622,97 @@ def test_pre_disable_runs_juju_login_preflight_check(self, deployment, mocker):
assert len(checks) == 1
assert checks[0].step.juju_account == juju_account

def test_enable_requirements_with_enabled_group_requirement(self, deployment):
group_member_klass("test_requirement_group.a", enabled=True)
group_member_klass("test_requirement_group.b", enabled=False)

feature = DummyFeature()
feature.requires = {FeatureRequirement("test_requirement_group")}
feature.enable_requirements(deployment, show_hints=False)

def test_enable_requirements_with_disabled_group_requirement(self, deployment):
group_member_klass("test_requirement_group.a", enabled=False)
group_member_klass("test_requirement_group.b", enabled=False)

feature = DummyFeature()
feature.requires = {FeatureRequirement("test_requirement_group")}
with pytest.raises(FeatureError, match="test_requirement_group"):
feature.enable_requirements(deployment, show_hints=False)

def test_enable_requirements_with_optional_group_requirement(self, deployment):
group_member_klass("test_requirement_group.a", enabled=False)
group_member_klass("test_requirement_group.b", enabled=False)

feature = DummyFeature()
feature.requires = {FeatureRequirement("test_requirement_group", optional=True)}
feature.enable_requirements(deployment, show_hints=False)

def test_enable_requirements_with_group_requirement_version_compatible(
self, deployment, mocker
):
group_member_klass("test_requirement_group.a", enabled=True)
group_member_klass("test_requirement_group.b", enabled=False)

feature = DummyFeature()
feature.requires = {FeatureRequirement("test_requirement_group>=1.0.0")}
mocker.patch.object(
feature, "fetch_feature_version", return_value=Version("1.0.1")
)
feature.enable_requirements(deployment, show_hints=False)

def test_enable_requirements_with_group_requirement_version_incompatible(
self, deployment, mocker
):
group_member_klass("test_requirement_group.a", enabled=True)
group_member_klass("test_requirement_group.b", enabled=False)

feature = DummyFeature()
feature.requires = {FeatureRequirement("test_requirement_group>=2.0.0")}
mocker.patch.object(
feature, "fetch_feature_version", return_value=Version("1.0.1")
)
with pytest.raises(IncompatibleVersionError):
feature.enable_requirements(deployment, show_hints=False)

def test_check_enablement_requirements_with_enabled_dependant_group(
self, deployment, mocker
):
member_klass = group_member_klass("test_requirement_group.a", enabled=False)

class Requirer(EnableDisableFeature):
name = "test_group_requirer"
requires = {FeatureRequirement("test_requirement_group")}

def is_enabled(self, client) -> bool:
return True

mocker.patch(
"sunbeam.features.interface.v1.base.features",
Mock(return_value={Requirer.name: Requirer}),
)
with pytest.raises(HasRequirersFeaturesError):
member_klass().check_enablement_requirements(deployment, "disable")

def test_check_enablement_requirements_with_group_requirement(
self, deployment, mocker
):
member_klass = group_member_klass("test_requirement_group.a", enabled=False)

class Requirer(EnableDisableFeature):
name = "test_group_requirer"
requires = {FeatureRequirement("test_requirement_group>=1.0.0")}

def is_enabled(self, client) -> bool:
return True

mocker.patch(
"sunbeam.features.interface.v1.base.features",
Mock(return_value={Requirer.name: Requirer}),
)
member = member_klass()
member.version = Version("1.0.1")
member.check_enablement_requirements(deployment)


class TestFeatureManager:
"""Test FeatureManager methods."""
Expand Down
Loading