Skip to content

Commit 55f01c2

Browse files
authored
prepare 6.3.0 release (#94)
1 parent bd2f17b commit 55f01c2

File tree

8 files changed

+393
-42
lines changed

8 files changed

+393
-42
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to the LaunchDarkly Python SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org).
44

5+
## [6.3.0] - 2018-08-27
6+
### Added:
7+
- The new `LDClient` method `all_flags_state()` should be used instead of `all_flags()` if you are passing flag data to the front end for use with the JavaScript SDK. It preserves some flag metadata that the front end requires in order to send analytics events correctly. Versions 2.5.0 and above of the JavaScript SDK are able to use this metadata, but the output of `all_flags_state()` will still work with older versions.
8+
- The `all_flags_state()` method also allows you to select only client-side-enabled flags to pass to the front end, by using the option `client_side_only=True`.
9+
10+
### Deprecated:
11+
- `LDClient.all_flags()`
12+
513
## [6.2.0] - 2018-08-03
614
### Changed:
715
- In streaming mode, each connection failure or unsuccessful reconnection attempt logs a message at `ERROR` level. Previously, this message included the amount of time before the next retry; since that interval is different for each attempt, that meant the `ERROR`-level messages were all unique, which could cause problems for monitors. This has been changed so the `ERROR`-level message is always the same, and is followed by an `INFO`-level message about the time delay. (Note that in order to suppress the default message, the LaunchDarkly client modifies the logger used by the `backoff` package; if you are using `backoff` for some other purpose and _do_ want to see the default message, set `logging.getLogger('backoff').propagate` to `True`.) ([#88](https://github.com/launchdarkly/python-client/issues/88))

ldclient/client.py

Lines changed: 112 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from ldclient.event_processor import NullEventProcessor
1111
from ldclient.feature_requester import FeatureRequesterImpl
1212
from ldclient.flag import evaluate
13+
from ldclient.flags_state import FeatureFlagsState
1314
from ldclient.polling import PollingUpdateProcessor
1415
from ldclient.streaming import StreamingUpdateProcessor
1516
from ldclient.util import check_uwsgi, log
@@ -27,6 +28,16 @@
2728

2829
class LDClient(object):
2930
def __init__(self, sdk_key=None, config=None, start_wait=5):
31+
"""Constructs a new LDClient instance.
32+
33+
Rather than calling this constructor directly, you can call the `ldclient.set_sdk_key`,
34+
`ldclient.set_config`, and `ldclient.get` functions to configure and use a singleton
35+
client instance.
36+
37+
:param string sdk_key: the SDK key for your LaunchDarkly environment
38+
:param Config config: optional custom configuration
39+
:param float start_wait: the number of seconds to wait for a successful connection to LaunchDarkly
40+
"""
3041
check_uwsgi()
3142

3243
if config is not None and config.sdk_key is not None and sdk_key is not None:
@@ -93,9 +104,17 @@ def __init__(self, sdk_key=None, config=None, start_wait=5):
93104
"Feature Flags may not yet be available.")
94105

95106
def get_sdk_key(self):
107+
"""Returns the configured SDK key.
108+
109+
:rtype: string
110+
"""
96111
return self._config.sdk_key
97112

98113
def close(self):
114+
"""Releases all threads and network connections used by the LaunchDarkly client.
115+
116+
Do not attempt to use the client after calling this method.
117+
"""
99118
log.info("Closing LaunchDarkly client..")
100119
if self.is_offline():
101120
return
@@ -108,33 +127,63 @@ def _send_event(self, event):
108127
self._event_processor.send_event(event)
109128

110129
def track(self, event_name, user, data=None):
130+
"""Tracks that a user performed an event.
131+
132+
:param string event_name: The name of the event.
133+
:param dict user: The attributes of the user.
134+
:param data: Optional additional data associated with the event.
135+
"""
111136
self._sanitize_user(user)
112137
if user is None or user.get('key') is None:
113138
log.warn("Missing user or user key when calling track().")
114139
self._send_event({'kind': 'custom', 'key': event_name, 'user': user, 'data': data})
115140

116141
def identify(self, user):
142+
"""Registers the user.
143+
144+
:param dict user: attributes of the user to register
145+
"""
117146
self._sanitize_user(user)
118147
if user is None or user.get('key') is None:
119148
log.warn("Missing user or user key when calling identify().")
120149
self._send_event({'kind': 'identify', 'key': user.get('key'), 'user': user})
121150

122151
def is_offline(self):
152+
"""Returns true if the client is in offline mode.
153+
154+
:rtype: bool
155+
"""
123156
return self._config.offline
124157

125158
def is_initialized(self):
159+
"""Returns true if the client has successfully connected to LaunchDarkly.
160+
161+
:rype: bool
162+
"""
126163
return self.is_offline() or self._config.use_ldd or self._update_processor.initialized()
127164

128165
def flush(self):
166+
"""Flushes all pending events.
167+
"""
129168
if self._config.offline:
130169
return
131170
return self._event_processor.flush()
132171

133172
def toggle(self, key, user, default):
173+
"""Deprecated synonym for `variation`.
174+
"""
134175
log.warn("Deprecated method: toggle() called. Use variation() instead.")
135176
return self.variation(key, user, default)
136177

137178
def variation(self, key, user, default):
179+
"""Determines the variation of a feature flag for a user.
180+
181+
:param string key: the unique key for the feature flag
182+
:param dict user: a dictionary containing parameters for the end user requesting the flag
183+
:param object default: the default value of the flag, to be used if the value is not
184+
available from LaunchDarkly
185+
:return: one of the flag's variation values, or the default value
186+
"""
138187
default = self._config.get_default(key, default)
139188
if user is not None:
140189
self._sanitize_user(user)
@@ -199,34 +248,79 @@ def _evaluate_and_send_events(self, flag, user, default):
199248
return value
200249

201250
def all_flags(self, user):
202-
if self._config.offline:
203-
log.warn("all_flags() called, but client is in offline mode. Returning None")
251+
"""Returns all feature flag values for the given user.
252+
253+
This method is deprecated - please use `all_flags_state` instead. Current versions of the
254+
client-side SDK will not generate analytics events correctly if you pass the result of `all_flags`.
255+
256+
:param dict user: the end user requesting the feature flags
257+
:return: a dictionary of feature flag keys to values; returns None if the client is offline,
258+
has not been initialized, or the user is None or has no key
259+
:rtype: dict
260+
"""
261+
state = self.all_flags_state(user)
262+
if not state.valid:
204263
return None
264+
return state.to_values_map()
265+
266+
def all_flags_state(self, user, **kwargs):
267+
"""Returns an object that encapsulates the state of all feature flags for a given user,
268+
including the flag values and also metadata that can be used on the front end.
269+
270+
This method does not send analytics events back to LaunchDarkly.
271+
272+
:param dict user: the end user requesting the feature flags
273+
:param kwargs: optional parameters affecting how the state is computed: set
274+
`client_side_only=True` to limit it to only flags that are marked for use with the
275+
client-side SDK (by default, all flags are included)
276+
:return: a FeatureFlagsState object (will never be None; its 'valid' property will be False
277+
if the client is offline, has not been initialized, or the user is None or has no key)
278+
:rtype: FeatureFlagsState
279+
"""
280+
if self._config.offline:
281+
log.warn("all_flags_state() called, but client is in offline mode. Returning empty state")
282+
return FeatureFlagsState(False)
205283

206284
if not self.is_initialized():
207285
if self._store.initialized:
208-
log.warn("all_flags() called before client has finished initializing! Using last known values from feature store")
286+
log.warn("all_flags_state() called before client has finished initializing! Using last known values from feature store")
209287
else:
210-
log.warn("all_flags() called before client has finished initializing! Feature store unavailable - returning None")
211-
return None
288+
log.warn("all_flags_state() called before client has finished initializing! Feature store unavailable - returning empty state")
289+
return FeatureFlagsState(False)
212290

213291
if user is None or user.get('key') is None:
214-
log.warn("User or user key is None when calling all_flags(). Returning None.")
215-
return None
216-
217-
def cb(all_flags):
292+
log.warn("User or user key is None when calling all_flags_state(). Returning empty state.")
293+
return FeatureFlagsState(False)
294+
295+
state = FeatureFlagsState(True)
296+
client_only = kwargs.get('client_side_only', False)
297+
try:
298+
flags_map = self._store.all(FEATURES, lambda x: x)
299+
except Exception as e:
300+
log.error("Unable to read flags for all_flag_state: %s" % e)
301+
return FeatureFlagsState(False)
302+
303+
for key, flag in flags_map.items():
304+
if client_only and not flag.get('clientSide', False):
305+
continue
218306
try:
219-
return self._evaluate_multi(user, all_flags)
307+
result = self._evaluate(flag, user)
308+
state.add_flag(flag, result.value, result.variation)
220309
except Exception as e:
221-
log.error("Exception caught in all_flags: " + e.message + " for user: " + str(user))
222-
return {}
223-
224-
return self._store.all(FEATURES, cb)
225-
226-
def _evaluate_multi(self, user, flags):
227-
return dict([(k, self._evaluate(v, user).value) for k, v in flags.items() or {}])
228-
310+
log.error("Error evaluating flag \"%s\" in all_flags_state: %s" % (key, e))
311+
state.add_flag(flag, None, None)
312+
313+
return state
314+
229315
def secure_mode_hash(self, user):
316+
"""Generates a hash value for a user.
317+
318+
For more info: <a href="https://github.com/launchdarkly/js-client#secure-mode">https://github.com/launchdarkly/js-client#secure-mode</a>
319+
320+
:param dict user: the attributes of the user
321+
:return: a hash string that can be passed to the front end
322+
:rtype: string
323+
"""
230324
if user.get('key') is None or self._config.sdk_key is None:
231325
return ""
232326
return hmac.new(self._config.sdk_key.encode(), user.get('key').encode(), hashlib.sha256).hexdigest()

ldclient/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __init__(self,
4646
:param int events_max_pending: The capacity of the events buffer. The client buffers up to this many
4747
events in memory before flushing. If the capacity is exceeded before the buffer is flushed, events
4848
will be discarded.
49-
: param float flush_interval: The number of seconds in between flushes of the events buffer. Decreasing
49+
:param float flush_interval: The number of seconds in between flushes of the events buffer. Decreasing
5050
the flush interval means that the event buffer is less likely to reach capacity.
5151
:param string stream_uri: The URL for the LaunchDarkly streaming events server. Most users should
5252
use the default value.

ldclient/flags_state.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import json
2+
3+
class FeatureFlagsState(object):
4+
"""
5+
A snapshot of the state of all feature flags with regard to a specific user, generated by
6+
calling the client's all_flags_state method. Serializing this object to JSON, using the
7+
to_json_dict method or jsonpickle, will produce the appropriate data structure for
8+
bootstrapping the LaunchDarkly JavaScript client.
9+
"""
10+
def __init__(self, valid):
11+
self.__flag_values = {}
12+
self.__flag_metadata = {}
13+
self.__valid = valid
14+
15+
def add_flag(self, flag, value, variation):
16+
"""Used internally to build the state map."""
17+
key = flag['key']
18+
self.__flag_values[key] = value
19+
meta = { 'version': flag.get('version'), 'trackEvents': flag.get('trackEvents') }
20+
if variation is not None:
21+
meta['variation'] = variation
22+
if flag.get('debugEventsUntilDate') is not None:
23+
meta['debugEventsUntilDate'] = flag.get('debugEventsUntilDate')
24+
self.__flag_metadata[key] = meta
25+
26+
@property
27+
def valid(self):
28+
"""True if this object contains a valid snapshot of feature flag state, or False if the
29+
state could not be computed (for instance, because the client was offline or there was no user).
30+
"""
31+
return self.__valid
32+
33+
def get_flag_value(self, key):
34+
"""Returns the value of an individual feature flag at the time the state was recorded.
35+
:param string key: the feature flag key
36+
:return: the flag's value; None if the flag returned the default value, or if there was no such flag
37+
"""
38+
return self.__flag_values.get(key)
39+
40+
def to_values_map(self):
41+
"""Returns a dictionary of flag keys to flag values. If the flag would have evaluated to the
42+
default value, its value will be None.
43+
44+
Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client.
45+
Instead, use to_json_dict.
46+
"""
47+
return self.__flag_values
48+
49+
def to_json_dict(self):
50+
"""Returns a dictionary suitable for passing as JSON, in the format used by the LaunchDarkly
51+
JavaScript SDK. Use this method if you are passing data to the front end in order to
52+
"bootstrap" the JavaScript client.
53+
"""
54+
ret = self.__flag_values.copy()
55+
ret['$flagsState'] = self.__flag_metadata
56+
ret['$valid'] = self.__valid
57+
return ret
58+
59+
def to_json_string(self):
60+
"""Same as to_json_dict, but serializes the JSON structure into a string.
61+
"""
62+
return json.dumps(self.to_json_dict())
63+
64+
def __getstate__(self):
65+
"""Equivalent to to_json_dict() - used if you are serializing the object with jsonpickle.
66+
"""
67+
return self.to_json_dict()

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def parse_requirements(filename):
1313
return [line for line in lineiter if line and not line.startswith("#")]
1414

1515

16-
ldclient_version='6.2.0'
16+
ldclient_version='6.3.0'
1717

1818
# parse_requirements() returns generator of pip.req.InstallRequirement objects
1919
install_reqs = parse_requirements('requirements.txt')

testing/test_flags_state.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import pytest
2+
import json
3+
import jsonpickle
4+
from ldclient.flags_state import FeatureFlagsState
5+
6+
def test_can_get_flag_value():
7+
state = FeatureFlagsState(True)
8+
flag = { 'key': 'key' }
9+
state.add_flag(flag, 'value', 1)
10+
assert state.get_flag_value('key') == 'value'
11+
12+
def test_returns_none_for_unknown_flag():
13+
state = FeatureFlagsState(True)
14+
assert state.get_flag_value('key') is None
15+
16+
def test_can_convert_to_values_map():
17+
state = FeatureFlagsState(True)
18+
flag1 = { 'key': 'key1' }
19+
flag2 = { 'key': 'key2' }
20+
state.add_flag(flag1, 'value1', 0)
21+
state.add_flag(flag2, 'value2', 1)
22+
assert state.to_values_map() == { 'key1': 'value1', 'key2': 'value2' }
23+
24+
def test_can_convert_to_json_dict():
25+
state = FeatureFlagsState(True)
26+
flag1 = { 'key': 'key1', 'version': 100, 'offVariation': 0, 'variations': [ 'value1' ], 'trackEvents': False }
27+
flag2 = { 'key': 'key2', 'version': 200, 'offVariation': 1, 'variations': [ 'x', 'value2' ], 'trackEvents': True, 'debugEventsUntilDate': 1000 }
28+
state.add_flag(flag1, 'value1', 0)
29+
state.add_flag(flag2, 'value2', 1)
30+
31+
result = state.to_json_dict()
32+
assert result == {
33+
'key1': 'value1',
34+
'key2': 'value2',
35+
'$flagsState': {
36+
'key1': {
37+
'variation': 0,
38+
'version': 100,
39+
'trackEvents': False
40+
},
41+
'key2': {
42+
'variation': 1,
43+
'version': 200,
44+
'trackEvents': True,
45+
'debugEventsUntilDate': 1000
46+
}
47+
},
48+
'$valid': True
49+
}
50+
51+
def test_can_convert_to_json_string():
52+
state = FeatureFlagsState(True)
53+
flag1 = { 'key': 'key1', 'version': 100, 'offVariation': 0, 'variations': [ 'value1' ], 'trackEvents': False }
54+
flag2 = { 'key': 'key2', 'version': 200, 'offVariation': 1, 'variations': [ 'x', 'value2' ], 'trackEvents': True, 'debugEventsUntilDate': 1000 }
55+
state.add_flag(flag1, 'value1', 0)
56+
state.add_flag(flag2, 'value2', 1)
57+
58+
obj = state.to_json_dict()
59+
str = state.to_json_string()
60+
assert json.loads(str) == obj
61+
62+
def test_can_serialize_with_jsonpickle():
63+
state = FeatureFlagsState(True)
64+
flag1 = { 'key': 'key1', 'version': 100, 'offVariation': 0, 'variations': [ 'value1' ], 'trackEvents': False }
65+
flag2 = { 'key': 'key2', 'version': 200, 'offVariation': 1, 'variations': [ 'x', 'value2' ], 'trackEvents': True, 'debugEventsUntilDate': 1000 }
66+
state.add_flag(flag1, 'value1', 0)
67+
state.add_flag(flag2, 'value2', 1)
68+
69+
obj = state.to_json_dict()
70+
str = jsonpickle.encode(state, unpicklable=False)
71+
assert json.loads(str) == obj

0 commit comments

Comments
 (0)