Skip to content

Commit

Permalink
added feature values (#10)
Browse files Browse the repository at this point in the history
* added feature values

* upd version

* fix types for python 3.9

* split flags and value states

---------

Co-authored-by: d.maximchuk <[email protected]>
  • Loading branch information
briefausde and d.maximchuk committed Sep 4, 2024
1 parent a8f4c4d commit e5da79a
Show file tree
Hide file tree
Showing 18 changed files with 553 additions and 40 deletions.
2 changes: 1 addition & 1 deletion featureflags_client/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.5.3"
__version__ = "0.6.0"
22 changes: 18 additions & 4 deletions featureflags_client/http/client.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down
69 changes: 67 additions & 2 deletions featureflags_client/http/conditions.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion featureflags_client/http/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion featureflags_client/http/managers/aiohttp.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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:
Expand All @@ -38,6 +41,7 @@ def __init__( # noqa: PLR0913
project,
variables,
defaults,
values_defaults,
request_timeout,
refresh_interval,
)
Expand Down
82 changes: 68 additions & 14 deletions featureflags_client/http/managers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,13 +17,29 @@
)
from featureflags_client.http.utils import (
coerce_defaults,
coerce_values_defaults,
custom_asdict_factory,
intervals_gen,
)

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.
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -155,6 +196,7 @@ def __init__( # noqa: PLR0913
project,
variables,
defaults,
values_defaults,
request_timeout,
refresh_interval,
)
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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:
Expand Down
Loading

0 comments on commit e5da79a

Please sign in to comment.