From f15bfce134a3537a744683ecbf76eafc15486860 Mon Sep 17 00:00:00 2001 From: "d.maximchuk" Date: Thu, 29 Aug 2024 15:02:06 +0300 Subject: [PATCH 1/4] added feature values --- featureflags_client/http/client.py | 22 ++++- featureflags_client/http/conditions.py | 67 ++++++++++++++- featureflags_client/http/managers/aiohttp.py | 2 + featureflags_client/http/managers/base.py | 65 +++++++++++--- featureflags_client/http/managers/dummy.py | 10 ++- featureflags_client/http/managers/httpx.py | 2 + featureflags_client/http/managers/requests.py | 4 +- featureflags_client/http/state.py | 31 +++++-- featureflags_client/http/types.py | 23 ++++- featureflags_client/http/utils.py | 28 +++++++ 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 ++++ 16 files changed, 523 insertions(+), 30 deletions(-) create mode 100644 featureflags_client/http/values.py 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..cc63153 100644 --- a/featureflags_client/http/conditions.py +++ b/featureflags_client/http/conditions.py @@ -2,7 +2,7 @@ import re from typing import Any, Callable, Dict, List, Optional, Set -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: int | str) -> int | str: + try: + return int(value) + except ValueError: + return value + + +def value_proc(value: Value) -> 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]) -> 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]) -> int | str: + return str_to_int(value.value_override) + + return proc + + +def update_values_state( + values: List[Value], +) -> dict[str, Callable[..., 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/managers/aiohttp.py b/featureflags_client/http/managers/aiohttp.py index 313e8a9..4eaa3ec 100644 --- a/featureflags_client/http/managers/aiohttp.py +++ b/featureflags_client/http/managers/aiohttp.py @@ -30,6 +30,7 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -38,6 +39,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..1194c39 100644 --- a/featureflags_client/http/managers/base.py +++ b/featureflags_client/http/managers/base.py @@ -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, int | str] +) -> list[tuple[str, str | int]]: + 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,24 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: EnumMeta | Type | dict[str, int | str] | None = 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,7 +107,9 @@ def _check_sync(self) -> None: self._next_sync, ) - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: + def get( + self, name: str + ) -> Optional[Callable[[Dict], Union[bool, int, str]]]: self._check_sync() return self._state.get(name) @@ -93,13 +118,18 @@ def preload(self) -> None: 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 +140,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 +165,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 +179,7 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -155,6 +188,7 @@ def __init__( # noqa: PLR0913 project, variables, defaults, + values_defaults, request_timeout, refresh_interval, ) @@ -173,25 +207,32 @@ async def _post( # type: ignore async def close(self) -> None: pass - def get(self, name: str) -> Optional[Callable[[Dict], bool]]: + def get( + self, name: str + ) -> Optional[Callable[[Dict], Union[bool, int, str]]]: return self._state.get(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 +243,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 +268,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..34a2503 100644 --- a/featureflags_client/http/managers/httpx.py +++ b/featureflags_client/http/managers/httpx.py @@ -30,6 +30,7 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -38,6 +39,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..8c4903a 100644 --- a/featureflags_client/http/managers/requests.py +++ b/featureflags_client/http/managers/requests.py @@ -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,7 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], + values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: @@ -40,6 +41,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..72a5b67 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,39 @@ class BaseState(ABC): variables: List[Variable] flags: List[str] + values: List[str] project: str version: int - _state: Dict[str, Callable[..., bool]] + _state: Dict[str, Callable[..., Union[bool, 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._state = {} - def get(self, flag_name: str) -> Optional[Callable[[Dict], bool]]: - return self._state.get(flag_name) + def get( + self, name: str + ) -> Optional[Callable[[Dict], Union[bool, int, str]]]: + return self._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 +55,11 @@ 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) + flags_state = update_flags_state(flags) + values_state = update_values_state(values) + self._state = {**flags_state, **values_state} 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..edc453a 100644 --- a/featureflags_client/http/utils.py +++ b/featureflags_client/http/utils.py @@ -41,6 +41,34 @@ 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..6ed0934 --- /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(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 + ) From fd0f6fade9445d342e72700e7e5854182d757c87 Mon Sep 17 00:00:00 2001 From: "d.maximchuk" Date: Thu, 29 Aug 2024 15:03:29 +0300 Subject: [PATCH 2/4] upd version --- featureflags_client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 370833bddb73185e0c17d8fa7bc666076c9ff976 Mon Sep 17 00:00:00 2001 From: "d.maximchuk" Date: Wed, 4 Sep 2024 13:12:02 +0300 Subject: [PATCH 3/4] fix types for python 3.9 --- featureflags_client/http/conditions.py | 12 ++++++------ featureflags_client/http/managers/aiohttp.py | 6 ++++-- featureflags_client/http/managers/base.py | 14 +++++++++----- featureflags_client/http/managers/httpx.py | 6 ++++-- featureflags_client/http/managers/requests.py | 6 ++++-- featureflags_client/http/utils.py | 3 +-- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/featureflags_client/http/conditions.py b/featureflags_client/http/conditions.py index cc63153..10edd64 100644 --- a/featureflags_client/http/conditions.py +++ b/featureflags_client/http/conditions.py @@ -1,6 +1,6 @@ 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, Value from featureflags_client.http.utils import hash_flag_value @@ -208,14 +208,14 @@ def update_flags_state(flags: List[Flag]) -> Dict[str, Callable[..., bool]]: return procs -def str_to_int(value: int | str) -> int | str: +def str_to_int(value: Union[int, str]) -> Union[int, str]: try: return int(value) except ValueError: return value -def value_proc(value: Value) -> Callable | int | str: +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( @@ -239,7 +239,7 @@ def value_proc(value: Value) -> Callable | int | str: if value.enabled and conditions: - def proc(ctx: Dict[str, Any]) -> int | str: + 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) @@ -250,7 +250,7 @@ def proc(ctx: Dict[str, Any]) -> int | str: f"Value[{value.name}] is disabled or do not have any conditions" ) - def proc(ctx: Dict[str, Any]) -> int | str: + def proc(ctx: Dict[str, Any]) -> Union[int, str]: return str_to_int(value.value_override) return proc @@ -258,7 +258,7 @@ def proc(ctx: Dict[str, Any]) -> int | str: def update_values_state( values: List[Value], -) -> dict[str, Callable[..., int | str]]: +) -> Dict[str, Callable[..., Union[int, str]]]: """ Assign a proc to each values which has to be computed. """ diff --git a/featureflags_client/http/managers/aiohttp.py b/featureflags_client/http/managers/aiohttp.py index 4eaa3ec..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,7 +30,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], - values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: diff --git a/featureflags_client/http/managers/base.py b/featureflags_client/http/managers/base.py index 1194c39..195197b 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 @@ -26,8 +26,8 @@ def _values_defaults_to_tuple( - values: list[str], values_defaults: dict[str, int | str] -) -> list[tuple[str, str | int]]: + 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, "") @@ -51,7 +51,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], - values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 60, # 1 minute. ) -> None: @@ -179,7 +181,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], - values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: diff --git a/featureflags_client/http/managers/httpx.py b/featureflags_client/http/managers/httpx.py index 34a2503..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,7 +30,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], - values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: diff --git a/featureflags_client/http/managers/requests.py b/featureflags_client/http/managers/requests.py index 8c4903a..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 ( @@ -32,7 +32,9 @@ def __init__( # noqa: PLR0913 project: str, variables: List[Variable], defaults: Union[EnumMeta, Type, Dict[str, bool]], - values_defaults: EnumMeta | Type | dict[str, int | str] | None = None, + values_defaults: Optional[ + Union[EnumMeta, Type, Dict[str, Union[int, str]]] + ] = None, request_timeout: int = 5, refresh_interval: int = 10, ) -> None: diff --git a/featureflags_client/http/utils.py b/featureflags_client/http/utils.py index edc453a..235c3fd 100644 --- a/featureflags_client/http/utils.py +++ b/featureflags_client/http/utils.py @@ -58,8 +58,7 @@ def coerce_values_defaults( invalid = [ k for k, v in defaults.items() - if not isinstance(k, str) - or not (isinstance(v, (int, str))) + if not isinstance(k, str) or not (isinstance(v, (int, str))) ] if invalid: raise TypeError( From 128467b045c6c07ea6c60cb5e62e2392ad439888 Mon Sep 17 00:00:00 2001 From: "d.maximchuk" Date: Wed, 4 Sep 2024 13:21:09 +0300 Subject: [PATCH 4/4] split flags and value states --- featureflags_client/http/flags.py | 2 +- featureflags_client/http/managers/base.py | 19 +++++++++++++------ featureflags_client/http/state.py | 20 ++++++++++++-------- featureflags_client/http/values.py | 2 +- 4 files changed, 27 insertions(+), 16 deletions(-) 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/base.py b/featureflags_client/http/managers/base.py index 195197b..281be27 100644 --- a/featureflags_client/http/managers/base.py +++ b/featureflags_client/http/managers/base.py @@ -109,11 +109,15 @@ def _check_sync(self) -> None: self._next_sync, ) - def get( + 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[bool, int, 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( @@ -211,10 +215,13 @@ async def _post( # type: ignore async def close(self) -> None: pass - def get( + 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[bool, int, str]]]: - return self._state.get(name) + ) -> Optional[Callable[[Dict], Union[int, str]]]: + return self._state.get_value(name) async def preload(self) -> None: # type: ignore """ diff --git a/featureflags_client/http/state.py b/featureflags_client/http/state.py index 72a5b67..76f2de3 100644 --- a/featureflags_client/http/state.py +++ b/featureflags_client/http/state.py @@ -19,7 +19,8 @@ class BaseState(ABC): project: str version: int - _state: Dict[str, Callable[..., Union[bool, int, str]]] + _flags_state: Dict[str, Callable[..., bool]] + _values_state: Dict[str, Callable[..., Union[int, str]]] def __init__( self, @@ -34,12 +35,16 @@ def __init__( self.flags = flags self.values = values - self._state = {} + self._flags_state = {} + self._values_state = {} - def get( + def get_flag(self, name: str) -> Optional[Callable[[Dict], bool]]: + return self._flags_state.get(name) + + def get_value( self, name: str - ) -> Optional[Callable[[Dict], Union[bool, int, str]]]: - return self._state.get(name) + ) -> Optional[Callable[[Dict], Union[int, str]]]: + return self._values_state.get(name) @abstractmethod def update( @@ -59,7 +64,6 @@ def update( version: int, ) -> None: if self.version != version: - flags_state = update_flags_state(flags) - values_state = update_values_state(values) - self._state = {**flags_state, **values_state} + self._flags_state = update_flags_state(flags) + self._values_state = update_values_state(values) self.version = version diff --git a/featureflags_client/http/values.py b/featureflags_client/http/values.py index 6ed0934..b326d86 100644 --- a/featureflags_client/http/values.py +++ b/featureflags_client/http/values.py @@ -26,7 +26,7 @@ def __getattr__(self, name: str) -> Union[int, str]: value = self._overrides.get(name) if value is None: - check = self._manager.get(name) + check = self._manager.get_value(name) value = check(self._ctx) if check is not None else default # caching/snapshotting