-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
service: Add feature toggle support (#421)
* service: Add dependency on python-decouple * Add python-decouple to poetry.lock * service: Add feature toggle implementation * tests: Add feature toggle tests * service: Change feature decorator into a free function * tests: Add use_code_readiness * service: Remove runtime typing_extensions import * service: Rename a constant and remove an extra import * service: Rename READINESS_LEVEL and hide it behind an accessor function * service: Make CodeReadiness an ordered enum * tests: Fix incorrect feature toggle name * service: Remove MEASUREMENTLINK_CODE_READINESS_LEVEL override * tests: Change default code readiness level to PROTOTYPE * tests: Add one more test
- Loading branch information
Showing
6 changed files
with
305 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
from __future__ import annotations | ||
|
||
import functools | ||
import sys | ||
from enum import Enum | ||
from typing import TYPE_CHECKING, Callable, TypeVar | ||
|
||
from decouple import config | ||
|
||
if TYPE_CHECKING: | ||
if sys.version_info >= (3, 10): | ||
from typing import ParamSpec | ||
else: | ||
from typing_extensions import ParamSpec | ||
|
||
if sys.version_info >= (3, 11): | ||
from typing import Self | ||
else: | ||
from typing_extensions import Self | ||
|
||
_P = ParamSpec("_P") | ||
_T = TypeVar("_T") | ||
|
||
_PREFIX = "MEASUREMENTLINK" | ||
|
||
|
||
# Based on the recipe at https://docs.python.org/3/howto/enum.html | ||
class _OrderedEnum(Enum): | ||
def __ge__(self, other: Self) -> bool: | ||
if self.__class__ is other.__class__: | ||
return self.value >= other.value | ||
return NotImplemented | ||
|
||
def __gt__(self, other: Self) -> bool: | ||
if self.__class__ is other.__class__: | ||
return self.value > other.value | ||
return NotImplemented | ||
|
||
def __le__(self, other: Self) -> bool: | ||
if self.__class__ is other.__class__: | ||
return self.value <= other.value | ||
return NotImplemented | ||
|
||
def __lt__(self, other: Self) -> bool: | ||
if self.__class__ is other.__class__: | ||
return self.value < other.value | ||
return NotImplemented | ||
|
||
|
||
class CodeReadiness(_OrderedEnum): | ||
"""Indicates whether code is ready to be supported.""" | ||
|
||
RELEASE = 0 | ||
NEXT_RELEASE = 1 | ||
INCOMPLETE = 2 | ||
PROTOTYPE = 3 | ||
|
||
|
||
def _init_code_readiness_level() -> CodeReadiness: | ||
if config(f"{_PREFIX}_ALLOW_INCOMPLETE", default=False, cast=bool): | ||
return CodeReadiness.INCOMPLETE | ||
elif config(f"{_PREFIX}_ALLOW_NEXT_RELEASE", default=False, cast=bool): | ||
return CodeReadiness.NEXT_RELEASE | ||
else: | ||
return CodeReadiness.RELEASE | ||
|
||
|
||
# This is not public because `from _featuretoggles import CODE_READINESS_LEVEL` | ||
# is incompatible with the patching performed by the use_code_readiness mark. | ||
_CODE_READINESS_LEVEL = _init_code_readiness_level() | ||
|
||
|
||
def get_code_readiness_level() -> CodeReadiness: | ||
"""Get the current code readiness level. | ||
You can override this in tests by specifying the ``use_code_readiness`` | ||
mark. | ||
""" | ||
return _CODE_READINESS_LEVEL | ||
|
||
|
||
class FeatureNotSupportedError(Exception): | ||
"""The feature is not supported at the current code readiness level.""" | ||
|
||
|
||
class FeatureToggle: | ||
"""A run-time feature toggle.""" | ||
|
||
name: str | ||
"""The name of the feature.""" | ||
|
||
readiness: CodeReadiness | ||
"""The code readiness at which this feature is enabled.""" | ||
|
||
def __init__(self, name: str, readiness: CodeReadiness) -> None: | ||
"""Initialize the feature toggle.""" | ||
assert name == name.upper() | ||
self.name = name | ||
self.readiness = readiness | ||
self._is_enabled_override = None | ||
# Only read the env var at initialization time. | ||
if config(f"{_PREFIX}_ENABLE_{name}", default=False, cast=bool): | ||
self._is_enabled_override = True | ||
|
||
@property | ||
def is_enabled(self) -> bool: | ||
"""Indicates whether the feature is currently enabled. | ||
You can enable/disable features in tests by specifying the | ||
``enable_feature_toggle`` or ``disable_feature_toggle`` marks. | ||
""" | ||
if self._is_enabled_override is not None: | ||
return self._is_enabled_override | ||
return self.readiness <= get_code_readiness_level() | ||
|
||
def _raise_if_disabled(self) -> None: | ||
if self.is_enabled: | ||
return | ||
|
||
env_vars = f"{_PREFIX}_ENABLE_{self.name}" | ||
if self.readiness in [CodeReadiness.NEXT_RELEASE, CodeReadiness.INCOMPLETE]: | ||
env_vars += f" or {_PREFIX}_ALLOW_{self.readiness.name}" | ||
message = ( | ||
f"The {self.name} feature is not supported at the current code readiness level. " | ||
f" To enable it, set {env_vars}." | ||
) | ||
raise FeatureNotSupportedError(message) | ||
|
||
|
||
def requires_feature( | ||
feature_toggle: FeatureToggle, | ||
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]: | ||
"""Decorator specifying that the function requires the specified feature toggle.""" | ||
|
||
def decorator(func: Callable[_P, _T]) -> Callable[_P, _T]: | ||
@functools.wraps(func) | ||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: | ||
feature_toggle._raise_if_disabled() | ||
return func(*args, **kwargs) | ||
|
||
return wrapper | ||
|
||
return decorator |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""Unit tests.""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
from typing import List | ||
|
||
import pytest | ||
from pytest_mock import MockerFixture | ||
|
||
from ni_measurementlink_service._featuretoggles import ( | ||
CodeReadiness, | ||
FeatureNotSupportedError, | ||
FeatureToggle, | ||
get_code_readiness_level, | ||
requires_feature, | ||
) | ||
|
||
RELEASE_FEATURE = FeatureToggle("RELEASE_FEATURE", CodeReadiness.RELEASE) | ||
NEXT_RELEASE_FEATURE = FeatureToggle("NEXT_RELEASE_FEATURE", CodeReadiness.NEXT_RELEASE) | ||
INCOMPLETE_FEATURE = FeatureToggle("INCOMPLETE_FEATURE", CodeReadiness.INCOMPLETE) | ||
PROTOTYPE_FEATURE = FeatureToggle("PROTOTYPE_FEATURE", CodeReadiness.PROTOTYPE) | ||
|
||
|
||
@requires_feature(PROTOTYPE_FEATURE) | ||
def _prototype_function(x: int, y: str, z: List[int]) -> str: | ||
return _prototype_function_impl(x, y, z) | ||
|
||
|
||
def _prototype_function_impl(x: int, y: str, z: List[int]) -> str: | ||
return "" | ||
|
||
|
||
def test___default_code_readiness_level___get_code_readiness_level___equals_prototype() -> None: | ||
assert get_code_readiness_level() == CodeReadiness.PROTOTYPE | ||
|
||
|
||
@pytest.mark.use_code_readiness(CodeReadiness.RELEASE) | ||
def test___use_release_readiness___get_code_readiness_level___equals_release() -> None: | ||
assert get_code_readiness_level() == CodeReadiness.RELEASE | ||
|
||
|
||
@pytest.mark.use_code_readiness(CodeReadiness.NEXT_RELEASE) | ||
def test___use_next_release_readiness___get_code_readiness_level___equals_next_release() -> None: | ||
assert get_code_readiness_level() == CodeReadiness.NEXT_RELEASE | ||
|
||
|
||
def test___default_code_readiness_level___is_enabled___returns_true() -> None: | ||
assert RELEASE_FEATURE.is_enabled | ||
assert NEXT_RELEASE_FEATURE.is_enabled | ||
assert INCOMPLETE_FEATURE.is_enabled | ||
assert PROTOTYPE_FEATURE.is_enabled | ||
|
||
|
||
@pytest.mark.use_code_readiness(CodeReadiness.INCOMPLETE) | ||
def test___use_incomplete_readiness___is_enabled___reflects_code_readiness_level() -> None: | ||
assert RELEASE_FEATURE.is_enabled | ||
assert NEXT_RELEASE_FEATURE.is_enabled | ||
assert INCOMPLETE_FEATURE.is_enabled | ||
assert not PROTOTYPE_FEATURE.is_enabled | ||
|
||
|
||
@pytest.mark.use_code_readiness(CodeReadiness.NEXT_RELEASE) | ||
def test___use_next_release_readiness___is_enabled___reflects_code_readiness_level() -> None: | ||
assert RELEASE_FEATURE.is_enabled | ||
assert NEXT_RELEASE_FEATURE.is_enabled | ||
assert not INCOMPLETE_FEATURE.is_enabled | ||
assert not PROTOTYPE_FEATURE.is_enabled | ||
|
||
|
||
@pytest.mark.use_code_readiness(CodeReadiness.RELEASE) | ||
def test___release_readiness_level___is_enabled___reflects_code_readiness_level() -> None: | ||
assert RELEASE_FEATURE.is_enabled | ||
assert not NEXT_RELEASE_FEATURE.is_enabled | ||
assert not INCOMPLETE_FEATURE.is_enabled | ||
assert not PROTOTYPE_FEATURE.is_enabled | ||
|
||
|
||
@pytest.mark.enable_feature_toggle(PROTOTYPE_FEATURE) | ||
def test___feature_toggle_enabled___is_enabled___returns_true() -> None: | ||
assert PROTOTYPE_FEATURE.is_enabled | ||
|
||
|
||
@pytest.mark.disable_feature_toggle(PROTOTYPE_FEATURE) | ||
def test___feature_toggle_disabled___is_enabled___returns_false() -> None: | ||
assert not PROTOTYPE_FEATURE.is_enabled | ||
|
||
|
||
@pytest.mark.enable_feature_toggle(PROTOTYPE_FEATURE) | ||
def test___feature_toggle_enabled___call_decorated_function___impl_called( | ||
mocker: MockerFixture, | ||
) -> None: | ||
impl = mocker.patch("tests.unit.test_featuretoggles._prototype_function_impl") | ||
impl.return_value = "def" | ||
|
||
result = _prototype_function(123, "abc", [4, 5, 6]) | ||
|
||
impl.assert_called_once_with(123, "abc", [4, 5, 6]) | ||
assert result == "def" | ||
|
||
|
||
@pytest.mark.disable_feature_toggle(PROTOTYPE_FEATURE) | ||
def test___feature_toggle_disabled___call_decorated_function___error_raised( | ||
mocker: MockerFixture, | ||
) -> None: | ||
impl = mocker.patch("tests.unit.test_featuretoggles._prototype_function_impl") | ||
impl.return_value = "def" | ||
|
||
with pytest.raises(FeatureNotSupportedError) as exc_info: | ||
_ = _prototype_function(123, "abc", [4, 5, 6]) | ||
|
||
impl.assert_not_called() | ||
assert "set MEASUREMENTLINK_ENABLE_PROTOTYPE_FEATURE" in exc_info.value.args[0] |