Skip to content

Commit 5b49607

Browse files
Refactor default flag logic (#22)
* Refactor default flags to use a handler function instead of list * Use default handler if API request fails * Add more docstring * Added gitignore for direnv Co-authored-by: Ben Rometsch <[email protected]>
1 parent b6a62ed commit 5b49607

File tree

6 files changed

+182
-75
lines changed

6 files changed

+182
-75
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.idea/
22
.venv
3+
.direnv/
34

45
*.pyc
56

.isort.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ use_parentheses=true
33
multi_line_output=3
44
include_trailing_comma=true
55
line_length=79
6-
known_third_party = flag_engine,flask,pytest,requests,requests_futures,responses
6+
known_third_party = flag_engine,flask,pytest,requests,requests_futures,responses,urllib3

example/app.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,25 @@
88

99
app = Flask(__name__)
1010

11-
flagsmith = Flagsmith(
12-
environment_key=os.environ.get("FLAGSMITH_ENVIRONMENT_KEY"),
13-
defaults=[
14-
# Set a default flag which will be used if the "secret_button"
15-
# feature is not returned by the API
16-
DefaultFlag(
11+
12+
def default_flag_handler(feature_name: str) -> DefaultFlag:
13+
"""
14+
Function that will be used if the API doesn't respond, or an unknown
15+
feature is requested
16+
"""
17+
18+
if feature_name == "secret_button":
19+
return DefaultFlag(
1720
enabled=False,
1821
value=json.dumps({"colour": "#b8b8b8"}),
19-
feature_name="secret_button",
2022
)
21-
],
23+
24+
return DefaultFlag(False, None)
25+
26+
27+
flagsmith = Flagsmith(
28+
environment_key=os.environ.get("FLAGSMITH_ENVIRONMENT_KEY"),
29+
default_flag_handler=default_flag_handler,
2230
)
2331

2432

flagsmith/flagsmith.py

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from flag_engine.environments.builders import build_environment_model
88
from flag_engine.environments.models import EnvironmentModel
99
from flag_engine.identities.models import IdentityModel, TraitModel
10-
from requests.adapters import HTTPAdapter, Retry
10+
from requests.adapters import HTTPAdapter
11+
from urllib3 import Retry
1112

1213
from flagsmith.analytics import AnalyticsProcessor
1314
from flagsmith.exceptions import FlagsmithAPIError, FlagsmithClientError
@@ -21,6 +22,20 @@
2122

2223

2324
class Flagsmith:
25+
"""A Flagsmith client.
26+
27+
Provides an interface for interacting with the Flagsmith http API.
28+
29+
Basic Usage::
30+
31+
>>> from flagsmith import Flagsmith
32+
>>> flagsmith = Flagsmith(environment_key="<your API key>")
33+
>>> environment_flags = flagsmith.get_environment_flags()
34+
>>> feature_enabled = environment_flags.is_feature_enabled("foo")
35+
>>> identity_flags = flagsmith.get_identity_flags("identifier", {"foo": "bar"})
36+
>>> feature_enabled_for_identity = identity_flags.is_feature_enabled("foo")
37+
"""
38+
2439
def __init__(
2540
self,
2641
environment_key: str,
@@ -31,8 +46,26 @@ def __init__(
3146
environment_refresh_interval_seconds: int = 60,
3247
retries: Retry = None,
3348
enable_analytics: bool = False,
34-
defaults: typing.List[DefaultFlag] = None,
49+
default_flag_handler: typing.Callable[[str], DefaultFlag] = None,
3550
):
51+
"""
52+
:param environment_key: The environment key obtained from Flagsmith interface
53+
:param api_url: Override the URL of the Flagsmith API to communicate with
54+
:param custom_headers: Additional headers to add to requests made to the
55+
Flagsmith API
56+
:param request_timeout_seconds: Number of seconds to wait for a request to
57+
complete before terminating the request
58+
:param enable_local_evaluation: Enables local evaluation of flags
59+
:param environment_refresh_interval_seconds: If using local evaluation,
60+
specify the interval period between refreshes of local environment data
61+
:param retries: a urllib3.Retry object to use on all http requests to the
62+
Flagsmith API
63+
:param enable_analytics: if enabled, sends additional requests to the Flagsmith
64+
API to power flag analytics charts
65+
:param default_flag_handler: callable which will be used in the case where
66+
flags cannot be retrieved from the API or a non existent feature is
67+
requested
68+
"""
3669
self.session = requests.Session()
3770
self.session.headers.update(
3871
**{"X-Environment-Key": environment_key}, **(custom_headers or {})
@@ -65,7 +98,7 @@ def __init__(
6598
else None
6699
)
67100

68-
self.defaults = defaults or []
101+
self.default_flag_handler = default_flag_handler
69102

70103
def get_environment_flags(self) -> Flags:
71104
"""
@@ -107,7 +140,7 @@ def _get_environment_flags_from_document(self) -> Flags:
107140
return Flags.from_feature_state_models(
108141
feature_states=engine.get_environment_feature_states(self._environment),
109142
analytics_processor=self._analytics_processor,
110-
defaults=self.defaults,
143+
default_flag_handler=self.default_flag_handler,
111144
)
112145

113146
def _get_identity_flags_from_document(
@@ -121,32 +154,41 @@ def _get_identity_flags_from_document(
121154
feature_states=feature_states,
122155
analytics_processor=self._analytics_processor,
123156
identity_id=identity_model.composite_key,
124-
defaults=self.defaults,
157+
default_flag_handler=self.default_flag_handler,
125158
)
126159

127160
def _get_environment_flags_from_api(self) -> Flags:
128-
api_flags = self._get_json_response(
129-
url=self.environment_flags_url, method="GET"
130-
)
131-
132-
return Flags.from_api_flags(
133-
api_flags=api_flags,
134-
analytics_processor=self._analytics_processor,
135-
defaults=self.defaults,
136-
)
161+
try:
162+
api_flags = self._get_json_response(
163+
url=self.environment_flags_url, method="GET"
164+
)
165+
return Flags.from_api_flags(
166+
api_flags=api_flags,
167+
analytics_processor=self._analytics_processor,
168+
default_flag_handler=self.default_flag_handler,
169+
)
170+
except FlagsmithAPIError:
171+
if self.default_flag_handler:
172+
return Flags(default_flag_handler=self.default_flag_handler)
173+
raise
137174

138175
def _get_identity_flags_from_api(
139176
self, identifier: str, traits: typing.Dict[str, typing.Any]
140177
) -> Flags:
141-
data = generate_identities_data(identifier, traits)
142-
json_response = self._get_json_response(
143-
url=self.identities_url, method="POST", body=data
144-
)
145-
return Flags.from_api_flags(
146-
api_flags=json_response["flags"],
147-
analytics_processor=self._analytics_processor,
148-
defaults=self.defaults,
149-
)
178+
try:
179+
data = generate_identities_data(identifier, traits)
180+
json_response = self._get_json_response(
181+
url=self.identities_url, method="POST", body=data
182+
)
183+
return Flags.from_api_flags(
184+
api_flags=json_response["flags"],
185+
analytics_processor=self._analytics_processor,
186+
default_flag_handler=self.default_flag_handler,
187+
)
188+
except FlagsmithAPIError:
189+
if self.default_flag_handler:
190+
return Flags(default_flag_handler=self.default_flag_handler)
191+
raise
150192

151193
def _get_json_response(self, url: str, method: str, body: dict = None):
152194
try:

flagsmith/models.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import typing
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, field
33

44
from flag_engine.features.models import FeatureStateModel
55

@@ -10,19 +10,20 @@
1010
@dataclass
1111
class BaseFlag:
1212
enabled: bool
13-
value: typing.Union[str, int, float, bool]
14-
feature_name: str
13+
value: typing.Union[str, int, float, bool, type(None)]
14+
is_default: bool
1515

1616

17-
@dataclass
1817
class DefaultFlag(BaseFlag):
19-
is_default = True
18+
def __init__(self, *args, **kwargs):
19+
super().__init__(*args, is_default=True, **kwargs)
2020

2121

22-
@dataclass
2322
class Flag(BaseFlag):
24-
feature_id: int
25-
is_default = False
23+
def __init__(self, *args, feature_id: int, feature_name: str, **kwargs):
24+
super().__init__(*args, is_default=False, **kwargs)
25+
self.feature_id = feature_id
26+
self.feature_name = feature_name
2627

2728
@classmethod
2829
def from_feature_state_model(
@@ -49,16 +50,17 @@ def from_api_flag(cls, flag_data: dict) -> "Flag":
4950

5051
@dataclass
5152
class Flags:
52-
flags: typing.Dict[str, BaseFlag]
53+
flags: typing.Dict[str, Flag] = field(default_factory=dict)
54+
default_flag_handler: typing.Callable[[str], DefaultFlag] = None
5355
_analytics_processor: AnalyticsProcessor = None
5456

5557
@classmethod
5658
def from_feature_state_models(
5759
cls,
5860
feature_states: typing.List[FeatureStateModel],
5961
analytics_processor: AnalyticsProcessor,
62+
default_flag_handler: typing.Callable,
6063
identity_id: typing.Union[str, int] = None,
61-
defaults: typing.List[DefaultFlag] = None,
6264
) -> "Flags":
6365
flags = {
6466
feature_state.feature.name: Flag.from_feature_state_model(
@@ -67,29 +69,31 @@ def from_feature_state_models(
6769
for feature_state in feature_states
6870
}
6971

70-
for default in defaults or []:
71-
flags.setdefault(default.feature_name, default)
72-
73-
return cls(flags=flags, _analytics_processor=analytics_processor)
72+
return cls(
73+
flags=flags,
74+
default_flag_handler=default_flag_handler,
75+
_analytics_processor=analytics_processor,
76+
)
7477

7578
@classmethod
7679
def from_api_flags(
7780
cls,
7881
api_flags: typing.List[dict],
7982
analytics_processor: AnalyticsProcessor,
80-
defaults: typing.List[DefaultFlag] = None,
83+
default_flag_handler: typing.Callable,
8184
) -> "Flags":
8285
flags = {
8386
flag_data["feature"]["name"]: Flag.from_api_flag(flag_data)
8487
for flag_data in api_flags
8588
}
8689

87-
for default in defaults or []:
88-
flags.setdefault(default.feature_name, default)
89-
90-
return cls(flags=flags, _analytics_processor=analytics_processor)
90+
return cls(
91+
flags=flags,
92+
default_flag_handler=default_flag_handler,
93+
_analytics_processor=analytics_processor,
94+
)
9195

92-
def all_flags(self) -> typing.List[BaseFlag]:
96+
def all_flags(self) -> typing.List[Flag]:
9397
"""
9498
Get a list of all Flag objects.
9599
@@ -122,12 +126,14 @@ def get_flag(self, feature_name: str) -> BaseFlag:
122126
Get a specific flag given the feature name.
123127
124128
:param feature_name: the name of the feature to retrieve the flag for.
125-
:return: Flag object.
129+
:return: BaseFlag object.
126130
:raises FlagsmithClientError: if feature doesn't exist
127131
"""
128132
try:
129133
flag = self.flags[feature_name]
130134
except KeyError:
135+
if self.default_flag_handler:
136+
return self.default_flag_handler(feature_name)
131137
raise FlagsmithClientError("Feature does not exist: %s" % feature_name)
132138

133139
if self._analytics_processor and hasattr(flag, "feature_id"):

0 commit comments

Comments
 (0)