From e5da79a7ae54ede8c2216a538ef2096338bd0dd3 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 4 Sep 2024 13:42:32 +0300 Subject: [PATCH] added feature values (#10) * added feature values * upd version * fix types for python 3.9 * split flags and value states --------- Co-authored-by: d.maximchuk --- featureflags_client/__init__.py | 2 +- featureflags_client/http/client.py | 22 ++++- featureflags_client/http/conditions.py | 69 ++++++++++++++- featureflags_client/http/flags.py | 2 +- featureflags_client/http/managers/aiohttp.py | 6 +- featureflags_client/http/managers/base.py | 82 ++++++++++++++---- featureflags_client/http/managers/dummy.py | 10 ++- featureflags_client/http/managers/httpx.py | 6 +- featureflags_client/http/managers/requests.py | 8 +- featureflags_client/http/state.py | 37 ++++++-- featureflags_client/http/types.py | 23 ++++- featureflags_client/http/utils.py | 27 ++++++ featureflags_client/http/values.py | 34 ++++++++ featureflags_client/tests/conftest.py | 36 ++++++++ .../tests/http/managers/test_async.py | 84 ++++++++++++++++++- .../tests/http/managers/test_dummy.py | 46 ++++++++++ .../tests/http/managers/test_sync.py | 83 +++++++++++++++++- .../tests/http/test_conditions.py | 16 ++++ 18 files changed, 553 insertions(+), 40 deletions(-) create mode 100644 featureflags_client/http/values.py diff --git a/featureflags_client/__init__.py b/featureflags_client/__init__.py index 43a1e95..906d362 100644 --- a/featureflags_client/__init__.py +++ b/featureflags_client/__init__.py @@ -1 +1 @@ -__version__ = "0.5.3" +__version__ = "0.6.0" diff --git a/featureflags_client/http/client.py b/featureflags_client/http/client.py index de1ad3d..6c5eb7b 100644 --- a/featureflags_client/http/client.py +++ b/featureflags_client/http/client.py @@ -1,16 +1,17 @@ from contextlib import contextmanager -from typing import Any, Dict, Generator, Optional, cast +from typing import Any, Dict, Generator, Optional, Union, cast from featureflags_client.http.flags import Flags from featureflags_client.http.managers.base import ( AsyncBaseManager, BaseManager, ) +from featureflags_client.http.values import Values class FeatureFlagsClient: """ - Feature flags http based client. + Feature flags and values http based client. """ def __init__(self, manager: BaseManager) -> None: @@ -29,9 +30,22 @@ def flags( """ yield Flags(self._manager, ctx, overrides) + @contextmanager + def values( + self, + ctx: Optional[Dict[str, Any]] = None, + *, + overrides: Optional[Dict[str, Union[int, str]]] = None, + ) -> Generator[Values, None, None]: + """ + Context manager to wrap your request handling code and get actual + feature values. + """ + yield Values(self._manager, ctx, overrides) + def preload(self) -> None: - """Preload flags from featureflags server. - This method syncs all flags with server""" + """Preload flags and values from featureflags server. + This method syncs all flags and values with server""" self._manager.preload() async def preload_async(self) -> None: diff --git a/featureflags_client/http/conditions.py b/featureflags_client/http/conditions.py index 8926ba4..10edd64 100644 --- a/featureflags_client/http/conditions.py +++ b/featureflags_client/http/conditions.py @@ -1,8 +1,8 @@ import logging import re -from typing import Any, Callable, Dict, List, Optional, Set +from typing import Any, Callable, Dict, List, Optional, Set, Union -from featureflags_client.http.types import Check, Flag, Operator +from featureflags_client.http.types import Check, Flag, Operator, Value from featureflags_client.http.utils import hash_flag_value log = logging.getLogger(__name__) @@ -206,3 +206,68 @@ def update_flags_state(flags: List[Flag]) -> Dict[str, Callable[..., bool]]: procs[flag.name] = proc return procs + + +def str_to_int(value: Union[int, str]) -> Union[int, str]: + try: + return int(value) + except ValueError: + return value + + +def value_proc(value: Value) -> Union[Callable, int, str]: + if not value.overridden: + # Value was not overridden on server, use value from defaults. + log.debug( + f"Value[{value.name}] is not override yet, using default value" + ) + return str_to_int(value.value_default) + + conditions = [] + for condition in value.conditions: + checks_procs = [check_proc(check) for check in condition.checks] + + # in case of invalid condition it would be safe to replace it + # with a falsish condition + if not checks_procs: + log.debug("Condition has empty checks") + checks_procs = [false] + + conditions.append( + (condition.value_override, checks_procs), + ) + + if value.enabled and conditions: + + def proc(ctx: Dict[str, Any]) -> Union[int, str]: + for condition_value_override, checks in conditions: + if all(check(ctx) for check in checks): + return str_to_int(condition_value_override) + return str_to_int(value.value_override) + + else: + log.debug( + f"Value[{value.name}] is disabled or do not have any conditions" + ) + + def proc(ctx: Dict[str, Any]) -> Union[int, str]: + return str_to_int(value.value_override) + + return proc + + +def update_values_state( + values: List[Value], +) -> Dict[str, Callable[..., Union[int, str]]]: + """ + Assign a proc to each values which has to be computed. + """ + + procs = {} + + for value in values: + proc = value_proc(value) + if proc is not None: + procs[value.name] = proc + + return procs diff --git a/featureflags_client/http/flags.py b/featureflags_client/http/flags.py index cfb46b1..1e09447 100644 --- a/featureflags_client/http/flags.py +++ b/featureflags_client/http/flags.py @@ -26,7 +26,7 @@ def __getattr__(self, name: str) -> bool: value = self._overrides.get(name) if value is None: - check = self._manager.get(name) + check = self._manager.get_flag(name) value = check(self._ctx) if check is not None else default # caching/snapshotting diff --git a/featureflags_client/http/managers/aiohttp.py b/featureflags_client/http/managers/aiohttp.py index 313e8a9..53e0822 100644 --- a/featureflags_client/http/managers/aiohttp.py +++ b/featureflags_client/http/managers/aiohttp.py @@ -1,6 +1,6 @@ import logging from enum import EnumMeta -from typing import Any, Dict, List, Type, Union +from typing import Any, Dict, List, Optional, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( @@ -30,6 +30,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -38,6 +41,7 @@ def __init__( # noqa: PLR0913 project, variables, defaults, + values_defaults, request_timeout, refresh_interval, ) diff --git a/featureflags_client/http/managers/base.py b/featureflags_client/http/managers/base.py index f995100..281be27 100644 --- a/featureflags_client/http/managers/base.py +++ b/featureflags_client/http/managers/base.py @@ -4,7 +4,7 @@ from dataclasses import asdict from datetime import datetime, timedelta from enum import EnumMeta -from typing import Any, Callable, Dict, List, Optional, Type, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.state import HttpState @@ -17,6 +17,7 @@ ) from featureflags_client.http.utils import ( coerce_defaults, + coerce_values_defaults, custom_asdict_factory, intervals_gen, ) @@ -24,6 +25,21 @@ log = logging.getLogger(__name__) +def _values_defaults_to_tuple( + values: List[str], values_defaults: Dict[str, Union[int, str]] +) -> List[Tuple[str, Union[int, str]]]: + result = [] + for value in values: + value_default = values_defaults.get(value, "") + result.append( + ( + value, + value_default, + ) + ) + return result + + class BaseManager(ABC): """ Base manager for using with sync http clients. @@ -35,17 +51,26 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 60, # 1 minute. ) -> None: self.url = url self.defaults = coerce_defaults(defaults) + if values_defaults is None: + values_defaults = {} + + self.values_defaults = coerce_values_defaults(values_defaults) + self._request_timeout = request_timeout self._state = HttpState( project=project, variables=variables, flags=list(self.defaults.keys()), + values=list(self.values_defaults.keys()), ) self._int_gen = intervals_gen(interval=refresh_interval) @@ -84,22 +109,33 @@ def _check_sync(self) -> None: self._next_sync, ) - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: + def get_flag(self, name: str) -> Optional[Callable[[Dict], bool]]: + self._check_sync() + return self._state.get_flag(name) + + def get_value( + self, name: str + ) -> Optional[Callable[[Dict], Union[int, str]]]: self._check_sync() - return self._state.get(name) + return self._state.get_value(name) def preload(self) -> None: payload = PreloadFlagsRequest( project=self._state.project, variables=self._state.variables, flags=self._state.flags, + values=_values_defaults_to_tuple( + self._state.values, + self.values_defaults, + ), version=self._state.version, ) log.debug( - "Exchange request, project: %s, version: %s, flags: %s", + "Exchange request, project: %s, version: %s, flags: %s, values: %s", payload.project, payload.version, payload.flags, + payload.values, ) response_raw = self._post( @@ -110,19 +146,21 @@ def preload(self) -> None: log.debug("Preload response: %s", response_raw) response = PreloadFlagsResponse.from_dict(response_raw) - self._state.update(response.flags, response.version) + self._state.update(response.flags, response.values, response.version) def sync(self) -> None: payload = SyncFlagsRequest( project=self._state.project, flags=self._state.flags, + values=self._state.values, version=self._state.version, ) log.debug( - "Sync request, project: %s, version: %s, flags: %s", + "Sync request, project: %s, version: %s, flags: %s, values: %s", payload.project, payload.version, payload.flags, + payload.values, ) response_raw = self._post( @@ -133,7 +171,7 @@ def sync(self) -> None: log.debug("Sync reply: %s", response_raw) response = SyncFlagsResponse.from_dict(response_raw) - self._state.update(response.flags, response.version) + self._state.update(response.flags, response.values, response.version) class AsyncBaseManager(BaseManager): @@ -147,6 +185,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -155,6 +196,7 @@ def __init__( # noqa: PLR0913 project, variables, defaults, + values_defaults, request_timeout, refresh_interval, ) @@ -173,25 +215,35 @@ async def _post( # type: ignore async def close(self) -> None: pass - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: - return self._state.get(name) + def get_flag(self, name: str) -> Optional[Callable[[Dict], bool]]: + return self._state.get_flag(name) + + def get_value( + self, name: str + ) -> Optional[Callable[[Dict], Union[int, str]]]: + return self._state.get_value(name) async def preload(self) -> None: # type: ignore """ - Preload flags from the server. + Preload flags and values from the server. """ payload = PreloadFlagsRequest( project=self._state.project, variables=self._state.variables, flags=self._state.flags, + values=_values_defaults_to_tuple( + self._state.values, + self.values_defaults, + ), version=self._state.version, ) log.debug( - "Exchange request, project: %s, version: %s, flags: %s", + "Exchange request, project: %s, version: %s, flags: %s, values: %s", payload.project, payload.version, payload.flags, + payload.values, ) response_raw = await self._post( @@ -202,19 +254,21 @@ async def preload(self) -> None: # type: ignore log.debug("Preload response: %s", response_raw) response = PreloadFlagsResponse.from_dict(response_raw) - self._state.update(response.flags, response.version) + self._state.update(response.flags, response.values, response.version) async def sync(self) -> None: # type: ignore payload = SyncFlagsRequest( project=self._state.project, flags=self._state.flags, + values=self._state.values, version=self._state.version, ) log.debug( - "Sync request, project: %s, version: %s, flags: %s", + "Sync request, project: %s, version: %s, flags: %s, values: %s", payload.project, payload.version, payload.flags, + payload.values, ) response_raw = await self._post( @@ -225,7 +279,7 @@ async def sync(self) -> None: # type: ignore log.debug("Sync reply: %s", response_raw) response = SyncFlagsResponse.from_dict(response_raw) - self._state.update(response.flags, response.version) + self._state.update(response.flags, response.values, response.version) def start(self) -> None: if self._refresh_task is not None: diff --git a/featureflags_client/http/managers/dummy.py b/featureflags_client/http/managers/dummy.py index b3ceefc..cdad03b 100644 --- a/featureflags_client/http/managers/dummy.py +++ b/featureflags_client/http/managers/dummy.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( @@ -16,7 +16,9 @@ class DummyManager(BaseManager): It can be helpful when you want to use flags with their default values. """ - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: + def get( + self, name: str + ) -> Optional[Callable[[Dict], Union[bool, int, str]]]: """ So that `featureflags.http.flags.Flags` will use default values. """ @@ -43,7 +45,9 @@ class AsyncDummyManager(AsyncBaseManager): It can be helpful when you want to use flags with their default values. """ - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: + def get( + self, name: str + ) -> Optional[Callable[[Dict], Union[bool, int, str]]]: """ So that `featureflags.http.flags.Flags` will use default values. """ diff --git a/featureflags_client/http/managers/httpx.py b/featureflags_client/http/managers/httpx.py index 4b7ceed..eca62de 100644 --- a/featureflags_client/http/managers/httpx.py +++ b/featureflags_client/http/managers/httpx.py @@ -1,6 +1,6 @@ import logging from enum import EnumMeta -from typing import Any, Dict, List, Type, Union +from typing import Any, Dict, List, Optional, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( @@ -30,6 +30,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -38,6 +41,7 @@ def __init__( # noqa: PLR0913 project, variables, defaults, + values_defaults, request_timeout, refresh_interval, ) diff --git a/featureflags_client/http/managers/requests.py b/featureflags_client/http/managers/requests.py index 6ff7520..b285aba 100644 --- a/featureflags_client/http/managers/requests.py +++ b/featureflags_client/http/managers/requests.py @@ -1,6 +1,6 @@ import logging from enum import EnumMeta -from typing import Any, Dict, List, Type, Union +from typing import Any, Dict, List, Optional, Type, Union from featureflags_client.http.constants import Endpoints from featureflags_client.http.managers.base import ( @@ -24,7 +24,7 @@ class RequestsManager(BaseManager): - """Feature flags manager for sync apps with `requests` client.""" + """Feature flags and values manager for sync apps with `requests` client.""" def __init__( # noqa: PLR0913 self, @@ -32,6 +32,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -40,6 +43,7 @@ def __init__( # noqa: PLR0913 project, variables, defaults, + values_defaults, request_timeout, refresh_interval, ) diff --git a/featureflags_client/http/state.py b/featureflags_client/http/state.py index f71aa5a..76f2de3 100644 --- a/featureflags_client/http/state.py +++ b/featureflags_client/http/state.py @@ -1,9 +1,13 @@ from abc import ABC, abstractmethod -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, Union -from featureflags_client.http.conditions import update_flags_state +from featureflags_client.http.conditions import ( + update_flags_state, + update_values_state, +) from featureflags_client.http.types import ( Flag, + Value, Variable, ) @@ -11,29 +15,44 @@ class BaseState(ABC): variables: List[Variable] flags: List[str] + values: List[str] project: str version: int - _state: Dict[str, Callable[..., bool]] + _flags_state: Dict[str, Callable[..., bool]] + _values_state: Dict[str, Callable[..., Union[int, str]]] def __init__( self, project: str, variables: List[Variable], flags: List[str], + values: List[str], ) -> None: self.project = project self.variables = variables self.version = 0 self.flags = flags + self.values = values + + self._flags_state = {} + self._values_state = {} - self._state = {} + def get_flag(self, name: str) -> Optional[Callable[[Dict], bool]]: + return self._flags_state.get(name) - def get(self, flag_name: str) -> Optional[Callable[[Dict], bool]]: - return self._state.get(flag_name) + def get_value( + self, name: str + ) -> Optional[Callable[[Dict], Union[int, str]]]: + return self._values_state.get(name) @abstractmethod - def update(self, flags: List[Flag], version: int) -> None: + def update( + self, + flags: List[Flag], + values: List[Value], + version: int, + ) -> None: pass @@ -41,8 +60,10 @@ class HttpState(BaseState): def update( self, flags: List[Flag], + values: List[Value], version: int, ) -> None: if self.version != version: - self._state = update_flags_state(flags) + self._flags_state = update_flags_state(flags) + self._values_state = update_values_state(values) self.version = version diff --git a/featureflags_client/http/types.py b/featureflags_client/http/types.py index 933c46d..d26037f 100644 --- a/featureflags_client/http/types.py +++ b/featureflags_client/http/types.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import List, Union +from typing import List, Tuple, Union from dataclass_wizard import JSONWizard @@ -44,6 +44,12 @@ class Condition: checks: List[Check] +@dataclass +class ValueCondition: + checks: List[Check] + value_override: Union[int, str] + + @dataclass class Flag: name: str @@ -52,10 +58,21 @@ class Flag: conditions: List[Condition] +@dataclass +class Value: + name: str + enabled: bool + overridden: bool + value_default: Union[int, str] + value_override: Union[int, str] + conditions: List[ValueCondition] + + @dataclass class RequestData: project_name: str flags: List[Flag] + values: List[Value] @dataclass @@ -70,12 +87,14 @@ class PreloadFlagsRequest: version: int variables: List[Variable] = field(default_factory=list) flags: List[str] = field(default_factory=list) + values: List[Tuple[str, Union[str, int]]] = field(default_factory=list) @dataclass class PreloadFlagsResponse(JSONWizard): version: int flags: List[Flag] = field(default_factory=list) + values: List[Value] = field(default_factory=list) @dataclass @@ -83,9 +102,11 @@ class SyncFlagsRequest: project: str version: int flags: List[str] = field(default_factory=list) + values: List[str] = field(default_factory=list) @dataclass class SyncFlagsResponse(JSONWizard): version: int flags: List[Flag] = field(default_factory=list) + values: List[Value] = field(default_factory=list) diff --git a/featureflags_client/http/utils.py b/featureflags_client/http/utils.py index 059f4fa..235c3fd 100644 --- a/featureflags_client/http/utils.py +++ b/featureflags_client/http/utils.py @@ -41,6 +41,33 @@ def coerce_defaults( return defaults +def coerce_values_defaults( + defaults: Union[EnumMeta, Type, Dict[str, Union[int, str]]], +) -> Dict[str, Union[int, str]]: + if isinstance(defaults, EnumMeta): # deprecated + defaults = {k: v.value for k, v in defaults.__members__.items()} + elif inspect.isclass(defaults): + defaults = { + k: getattr(defaults, k) + for k in dir(defaults) + if k.isupper() and not k.startswith("_") + } + elif not isinstance(defaults, Mapping): + raise TypeError(f"Invalid defaults type: {type(defaults)!r}") + + invalid = [ + k + for k, v in defaults.items() + if not isinstance(k, str) or not (isinstance(v, (int, str))) + ] + if invalid: + raise TypeError( + "Invalid value definition: {}".format(", ".join(map(repr, invalid))) + ) + + return defaults + + def intervals_gen( interval: int = 10, retry_interval_min: int = 1, diff --git a/featureflags_client/http/values.py b/featureflags_client/http/values.py new file mode 100644 index 0000000..b326d86 --- /dev/null +++ b/featureflags_client/http/values.py @@ -0,0 +1,34 @@ +from typing import Any, Dict, Optional, Union + +from featureflags_client.http.managers.base import BaseManager + + +class Values: + """ + Values object to access current feature values state. + """ + + def __init__( + self, + manager: BaseManager, + ctx: Optional[Dict[str, Any]] = None, + overrides: Optional[Dict[str, Union[int, str]]] = None, + ) -> None: + self._manager = manager + self._defaults = manager.values_defaults + self._ctx = ctx or {} + self._overrides = overrides or {} + + def __getattr__(self, name: str) -> Union[int, str]: + default = self._defaults.get(name) + if default is None: + raise AttributeError(f"Feature value is not defined: {name}") + + value = self._overrides.get(name) + if value is None: + check = self._manager.get_value(name) + value = check(self._ctx) if check is not None else default + + # caching/snapshotting + setattr(self, name, value) + return value diff --git a/featureflags_client/tests/conftest.py b/featureflags_client/tests/conftest.py index b0f396c..60ddbd3 100644 --- a/featureflags_client/tests/conftest.py +++ b/featureflags_client/tests/conftest.py @@ -7,6 +7,8 @@ Condition, Flag, Operator, + Value, + ValueCondition, VariableType, ) @@ -32,6 +34,16 @@ def condition(check): return Condition(checks=[check]) +@pytest.fixture +def value_condition(check): + return ValueCondition(checks=[check], value_override=f.pystr()) + + +@pytest.fixture +def value_condition_int_value(check): + return ValueCondition(checks=[check], value_override=f.pyint()) + + @pytest.fixture def flag(condition): return Flag( @@ -40,3 +52,27 @@ def flag(condition): overridden=True, conditions=[condition], ) + + +@pytest.fixture +def value(value_condition): + return Value( + name=f.pystr(), + enabled=True, + overridden=True, + value_default=f.pystr(), + value_override=f.pystr(), + conditions=[value_condition], + ) + + +@pytest.fixture +def value_int(value_condition_int_value): + return Value( + name=f.pystr(), + enabled=True, + overridden=True, + value_default=f.pyint(), + value_override=f.pyint(), + conditions=[value_condition_int_value], + ) diff --git a/featureflags_client/tests/http/managers/test_async.py b/featureflags_client/tests/http/managers/test_async.py index 55db4e5..5b281d4 100644 --- a/featureflags_client/tests/http/managers/test_async.py +++ b/featureflags_client/tests/http/managers/test_async.py @@ -6,7 +6,12 @@ from featureflags_client.http.client import FeatureFlagsClient from featureflags_client.http.managers.aiohttp import AiohttpManager from featureflags_client.http.managers.httpx import HttpxManager -from featureflags_client.http.types import Flag, PreloadFlagsResponse, Variable +from featureflags_client.http.types import ( + Flag, + PreloadFlagsResponse, + Value, + Variable, +) f = faker.Faker() @@ -15,6 +20,11 @@ class Defaults: TEST = False +class ValuesDefaults: + TEST = "test" + TEST_INT = 1 + + @pytest.mark.asyncio @pytest.mark.parametrize( "async_manager_class", @@ -44,6 +54,7 @@ async def test_manager(async_manager_class, flag, variable, check, condition): conditions=[condition], ), ], + values=[], ) with patch.object(manager, "_post") as mock_post: mock_post.return_value = mock_preload_response.to_dict() @@ -62,3 +73,74 @@ async def test_manager(async_manager_class, flag, variable, check, condition): # close client connection. await manager.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "async_manager_class", + [ + AiohttpManager, + HttpxManager, + ], +) +async def test_values_manager( + async_manager_class, + value, + variable, + check, + value_condition, + value_condition_int_value, +): + manager = async_manager_class( + url="http://flags.server.example", + project="test", + variables=[Variable(variable.name, variable.type)], + defaults={}, + values_defaults=ValuesDefaults, + request_timeout=1, + refresh_interval=1, + ) + client = FeatureFlagsClient(manager) + + mock_preload_response = PreloadFlagsResponse( + version=1, + flags=[], + values=[ + Value( + name="TEST", + enabled=True, + overridden=True, + value_default="test", + value_override="nottest", + conditions=[value_condition], + ), + Value( + name="TEST_INT", + enabled=True, + overridden=True, + value_default=1, + value_override=2, + conditions=[value_condition_int_value], + ), + ], + ) + with patch.object(manager, "_post") as mock_post: + mock_post.return_value = mock_preload_response.to_dict() + + await client.preload_async() + mock_post.assert_called_once() + + with client.values({variable.name: check.value}) as values: + assert values.TEST is value_condition.value_override + assert values.TEST_INT is value_condition_int_value.value_override + + with client.values({variable.name: f.pystr()}) as values: + assert values.TEST == "nottest" + assert values.TEST_INT == 2 + + with client.values({variable.name: check.value}) as values: + assert values.TEST is value_condition.value_override + assert values.TEST_INT is value_condition_int_value.value_override + + # close client connection. + await manager.close() diff --git a/featureflags_client/tests/http/managers/test_dummy.py b/featureflags_client/tests/http/managers/test_dummy.py index 0515035..14abdf5 100644 --- a/featureflags_client/tests/http/managers/test_dummy.py +++ b/featureflags_client/tests/http/managers/test_dummy.py @@ -12,6 +12,11 @@ class Defaults: BAR_FEATURE = True +class ValuesDefaults: + FOO_FEATURE = "foo" + BAR_FEATURE = "bar" + + def test_sync(): manager = DummyManager( url="", @@ -49,3 +54,44 @@ async def test_async(): assert flags.BAR_FEATURE is True await manager.wait_closed() + + +def test_values_sync(): + manager = DummyManager( + url="", + project="test", + variables=[], + defaults={}, + values_defaults=ValuesDefaults, + request_timeout=1, + refresh_interval=1, + ) + client = FeatureFlagsClient(manager) + + with client.values() as values: + assert values.FOO_FEATURE == "foo" + assert values.BAR_FEATURE == "bar" + + +@pytest.mark.asyncio +async def test_values_async(): + manager = AsyncDummyManager( + url="", + project="test", + variables=[], + defaults={}, + values_defaults=ValuesDefaults, + request_timeout=1, + refresh_interval=1, + ) + client = FeatureFlagsClient(manager) + + await client.preload_async() + + manager.start() + + with client.values() as values: + assert values.FOO_FEATURE == "foo" + assert values.BAR_FEATURE == "bar" + + await manager.wait_closed() diff --git a/featureflags_client/tests/http/managers/test_sync.py b/featureflags_client/tests/http/managers/test_sync.py index bcda3e1..de53e05 100644 --- a/featureflags_client/tests/http/managers/test_sync.py +++ b/featureflags_client/tests/http/managers/test_sync.py @@ -6,7 +6,12 @@ from featureflags_client.http.client import FeatureFlagsClient from featureflags_client.http.managers.requests import RequestsManager -from featureflags_client.http.types import Flag, PreloadFlagsResponse, Variable +from featureflags_client.http.types import ( + Flag, + PreloadFlagsResponse, + Value, + Variable, +) f = faker.Faker() @@ -15,6 +20,11 @@ class Defaults: TEST = False +class ValuesDefaults: + TEST = "test" + TEST_INT = 1 + + @pytest.mark.parametrize( "manager_class", [ @@ -46,6 +56,7 @@ def test_manager(manager_class, flag, variable, check, condition): conditions=[condition], ), ], + values=[], ) with patch.object(manager, "_post") as mock_post: mock_post.return_value = mock_preload_response.to_dict() @@ -61,3 +72,73 @@ def test_manager(manager_class, flag, variable, check, condition): with client.flags({variable.name: check.value}) as flags: assert flags.TEST is True + + +@pytest.mark.parametrize( + "manager_class", + [ + RequestsManager, + ], +) +def test_values_manager( + manager_class, + value, + variable, + check, + value_condition, + value_condition_int_value, +): + manager = manager_class( + url="http://flags.server.example", + project="test", + variables=[Variable(variable.name, variable.type)], + defaults={}, + values_defaults=ValuesDefaults, + request_timeout=1, + refresh_interval=1, + ) + + # Disable auto sync. + manager._next_sync = datetime.utcnow() + timedelta(hours=1) + + client = FeatureFlagsClient(manager) + + mock_preload_response = PreloadFlagsResponse( + version=1, + flags=[], + values=[ + Value( + name="TEST", + enabled=True, + overridden=True, + value_default="test", + value_override="nottest", + conditions=[value_condition], + ), + Value( + name="TEST_INT", + enabled=True, + overridden=True, + value_default=1, + value_override=2, + conditions=[value_condition_int_value], + ), + ], + ) + with patch.object(manager, "_post") as mock_post: + mock_post.return_value = mock_preload_response.to_dict() + + client.preload() + mock_post.assert_called_once() + + with client.values({variable.name: check.value}) as values: + assert values.TEST is value_condition.value_override + assert values.TEST_INT is value_condition_int_value.value_override + + with client.values({variable.name: f.pystr()}) as values: + assert values.TEST == "nottest" + assert values.TEST_INT == 2 + + with client.values({variable.name: check.value}) as values: + assert values.TEST is value_condition.value_override + assert values.TEST_INT is value_condition_int_value.value_override diff --git a/featureflags_client/tests/http/test_conditions.py b/featureflags_client/tests/http/test_conditions.py index 5fc7c8d..bcda61c 100644 --- a/featureflags_client/tests/http/test_conditions.py +++ b/featureflags_client/tests/http/test_conditions.py @@ -16,6 +16,7 @@ regexp, subset, superset, + value_proc, wildcard, ) from featureflags_client.http.types import Operator @@ -166,3 +167,18 @@ def test_check_proc_no_value(check): def test_valid_flag_proc(flag, check, variable): proc = flag_proc(flag) assert proc({variable.name: check.value}) is True + + +def test_valid_str_value_proc(value, check, variable): + proc = value_proc(value) + assert ( + proc({variable.name: check.value}) is value.conditions[0].value_override + ) + + +def test_valid_int_value_proc(value_int, check, variable): + proc = value_proc(value_int) + assert ( + proc({variable.name: check.value}) + is value_int.conditions[0].value_override + )