From 6db3e2211eaa98c3246297d2ba345662df355d43 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Mon, 30 Oct 2023 00:21:28 +0100 Subject: [PATCH 1/6] Add support for basic feature flags (DIS-1356) --- dissect/util/feature.py | 66 +++++++++++++++++++++++++++++++++++++++++ tests/test_feature.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 dissect/util/feature.py create mode 100644 tests/test_feature.py diff --git a/dissect/util/feature.py b/dissect/util/feature.py new file mode 100644 index 0000000..153f8a7 --- /dev/null +++ b/dissect/util/feature.py @@ -0,0 +1,66 @@ +import functools +import os +from enum import Enum +from typing import Callable, Optional + + +# Register feature flags in a central place to avoid chaos +class Feature(Enum): + NOVICE = "novice" + LATEST = "latest" + BETA = "beta" + + +# This set defines the valid flags +DISSECT_FEATURE_SET = set(item for item in Feature) + +# Defines the default flags (as strings) +DISSECT_FEATURES_DEFAULT = "novice/latest" + +# Defines the environment variable to read the flags from +DISSECT_FEATURES_ENV = "DISSECT_FEATURES" + + +class FeatureException(RuntimeError): + pass + + +def check_flags(flags: list[Feature]) -> list[Feature]: + for flag in flags: + if flag not in DISSECT_FEATURE_SET: + raise FeatureException(f"Invalid feature flag: {flag} choose from: {DISSECT_FEATURE_SET}") + return flags + + +@functools.cache +def feature_flags() -> list[Feature]: + return check_flags([Feature(name) for name in os.getenv(DISSECT_FEATURES_ENV, DISSECT_FEATURES_DEFAULT).split("/")]) + + +@functools.cache +def feature_enabled(feature: Feature) -> bool: + return feature in feature_flags() + + +def feature_disabled_stub() -> None: + raise FeatureException("This feature has been disabled.") + + +def feature(flag: Feature, alternative: Optional[Callable] = feature_disabled_stub) -> Callable: + """Usage: + + @feature(F_SOME_FLAG, altfunc) + def my_func( ... ) -> ... + + Where F_SOME_FLAG is the feature you want to check for and + altfunc is the alternative function to serve + if the feature flag is NOT set. + """ + + def decorator(func): + if feature_enabled(flag): + return func + else: + return alternative + + return decorator diff --git a/tests/test_feature.py b/tests/test_feature.py new file mode 100644 index 0000000..8def8ab --- /dev/null +++ b/tests/test_feature.py @@ -0,0 +1,46 @@ +import pytest + +from dissect.util.feature import ( + Feature, + FeatureException, + check_flags, + feature, + feature_enabled, +) + + +def test_feature_flags() -> None: + def fallback(): + return False + + @feature(Feature.BETA, fallback) + def experimental(): + return True + + @feature(Feature.NOVICE, fallback) + def novice(): + return True + + @feature(Feature.LATEST) + def latest(): + return True + + @feature("expert") + def expert(): + return True + + assert experimental() is False + assert novice() is True + assert latest() is True + with pytest.raises(FeatureException): + assert expert() is True + + +def test_feature_flag_verification() -> None: + with pytest.raises(FeatureException): + check_flags(["chaotic"]) + + +def test_feature_flag_inline() -> None: + assert feature_enabled(Feature.BETA) is False + assert feature_enabled(Feature.LATEST) is True From 498bbe766c91657c7c5c471f67e2f683d39a7ecb Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Tue, 14 Nov 2023 16:15:41 +0100 Subject: [PATCH 2/6] Implement suggestions --- dissect/util/feature.py | 30 +++++++++--------------------- tests/test_feature.py | 19 ++++--------------- 2 files changed, 13 insertions(+), 36 deletions(-) diff --git a/dissect/util/feature.py b/dissect/util/feature.py index 153f8a7..4d4f2ec 100644 --- a/dissect/util/feature.py +++ b/dissect/util/feature.py @@ -6,16 +6,13 @@ # Register feature flags in a central place to avoid chaos class Feature(Enum): - NOVICE = "novice" + ADVANCED = "advanced" LATEST = "latest" BETA = "beta" -# This set defines the valid flags -DISSECT_FEATURE_SET = set(item for item in Feature) - # Defines the default flags (as strings) -DISSECT_FEATURES_DEFAULT = "novice/latest" +DISSECT_FEATURES_DEFAULT = "latest" # Defines the environment variable to read the flags from DISSECT_FEATURES_ENV = "DISSECT_FEATURES" @@ -25,16 +22,9 @@ class FeatureException(RuntimeError): pass -def check_flags(flags: list[Feature]) -> list[Feature]: - for flag in flags: - if flag not in DISSECT_FEATURE_SET: - raise FeatureException(f"Invalid feature flag: {flag} choose from: {DISSECT_FEATURE_SET}") - return flags - - @functools.cache def feature_flags() -> list[Feature]: - return check_flags([Feature(name) for name in os.getenv(DISSECT_FEATURES_ENV, DISSECT_FEATURES_DEFAULT).split("/")]) + return [Feature(name) for name in os.getenv(DISSECT_FEATURES_ENV, DISSECT_FEATURES_DEFAULT).split("/")] @functools.cache @@ -47,20 +37,18 @@ def feature_disabled_stub() -> None: def feature(flag: Feature, alternative: Optional[Callable] = feature_disabled_stub) -> Callable: - """Usage: + """ + Usage:: - @feature(F_SOME_FLAG, altfunc) - def my_func( ... ) -> ... + @feature(Feature.SOME_FLAG, fallback) + def my_func( ... ) -> ... - Where F_SOME_FLAG is the feature you want to check for and + Where SOME_FLAG is the feature you want to check for and altfunc is the alternative function to serve if the feature flag is NOT set. """ def decorator(func): - if feature_enabled(flag): - return func - else: - return alternative + return func if feature_enabled(flag) else alternative return decorator diff --git a/tests/test_feature.py b/tests/test_feature.py index 8def8ab..d55d0a7 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -1,12 +1,6 @@ import pytest -from dissect.util.feature import ( - Feature, - FeatureException, - check_flags, - feature, - feature_enabled, -) +from dissect.util.feature import Feature, FeatureException, feature, feature_enabled def test_feature_flags() -> None: @@ -17,8 +11,8 @@ def fallback(): def experimental(): return True - @feature(Feature.NOVICE, fallback) - def novice(): + @feature(Feature.ADVANCED, fallback) + def advanced(): return True @feature(Feature.LATEST) @@ -30,17 +24,12 @@ def expert(): return True assert experimental() is False - assert novice() is True + assert advanced() is False assert latest() is True with pytest.raises(FeatureException): assert expert() is True -def test_feature_flag_verification() -> None: - with pytest.raises(FeatureException): - check_flags(["chaotic"]) - - def test_feature_flag_inline() -> None: assert feature_enabled(Feature.BETA) is False assert feature_enabled(Feature.LATEST) is True From 5416c989d9403f963a3eb6df192257a8099393fb Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Thu, 16 Nov 2023 09:49:05 +0100 Subject: [PATCH 3/6] Update dissect/util/feature.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/util/feature.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dissect/util/feature.py b/dissect/util/feature.py index 4d4f2ec..9626fb7 100644 --- a/dissect/util/feature.py +++ b/dissect/util/feature.py @@ -43,8 +43,7 @@ def feature(flag: Feature, alternative: Optional[Callable] = feature_disabled_st @feature(Feature.SOME_FLAG, fallback) def my_func( ... ) -> ... - Where SOME_FLAG is the feature you want to check for and - altfunc is the alternative function to serve + Where ``SOME_FLAG`` is the feature you want to check for and ``fallback`` is the alternative function to serve if the feature flag is NOT set. """ From 92f625e738fe6649cd3eb0527881a75023f8ab1c Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Thu, 16 Nov 2023 09:49:19 +0100 Subject: [PATCH 4/6] Update dissect/util/feature.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/util/feature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/util/feature.py b/dissect/util/feature.py index 9626fb7..729db60 100644 --- a/dissect/util/feature.py +++ b/dissect/util/feature.py @@ -6,8 +6,8 @@ # Register feature flags in a central place to avoid chaos class Feature(Enum): - ADVANCED = "advanced" LATEST = "latest" + ADVANCED = "advanced" BETA = "beta" From 38ccabfa9828b1e74a5358cf38e413262d5d73b1 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Thu, 16 Nov 2023 09:49:29 +0100 Subject: [PATCH 5/6] Update dissect/util/feature.py Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/util/feature.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dissect/util/feature.py b/dissect/util/feature.py index 729db60..1c179fd 100644 --- a/dissect/util/feature.py +++ b/dissect/util/feature.py @@ -37,7 +37,8 @@ def feature_disabled_stub() -> None: def feature(flag: Feature, alternative: Optional[Callable] = feature_disabled_stub) -> Callable: - """ + """Feature flag decorator allowing you to guard a function behind a feature flag. + Usage:: @feature(Feature.SOME_FLAG, fallback) From 83edacfacd35d72af339289d61ceb7faf616ba95 Mon Sep 17 00:00:00 2001 From: cecinestpasunepipe <110607403+cecinestpasunepipe@users.noreply.github.com> Date: Thu, 16 Nov 2023 12:42:42 +0100 Subject: [PATCH 6/6] Implement feedback --- dissect/util/feature.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/dissect/util/feature.py b/dissect/util/feature.py index 1c179fd..70cec0f 100644 --- a/dissect/util/feature.py +++ b/dissect/util/feature.py @@ -29,14 +29,21 @@ def feature_flags() -> list[Feature]: @functools.cache def feature_enabled(feature: Feature) -> bool: - return feature in feature_flags() + """Use this function for block-level feature flag control. + + Usage:: + def parse_blob(): + if feature_enabled(Feature.BETA): + self._parse_fast_experimental() + else: + self._parse_normal() -def feature_disabled_stub() -> None: - raise FeatureException("This feature has been disabled.") + """ + return feature in feature_flags() -def feature(flag: Feature, alternative: Optional[Callable] = feature_disabled_stub) -> Callable: +def feature(flag: Feature, alternative: Optional[Callable] = None) -> Callable: """Feature flag decorator allowing you to guard a function behind a feature flag. Usage:: @@ -48,6 +55,19 @@ def my_func( ... ) -> ... if the feature flag is NOT set. """ + if alternative is None: + + def alternative(): + raise FeatureException( + "\n".join( + [ + "Feature disabled.", + f"Set FLAG '{flag}' in {DISSECT_FEATURES_ENV} to enable.", + "See https://docs.dissect.tools/en/latest/advanced/flags.html", + ] + ) + ) + def decorator(func): return func if feature_enabled(flag) else alternative