Skip to content

Commit 28a601b

Browse files
authored
prepare 6.6.0 release (#101)
1 parent ec72b26 commit 28a601b

File tree

4 files changed

+518
-1
lines changed

4 files changed

+518
-1
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ Supported Python versions
7878
----------
7979
The SDK is tested with the most recent patch releases of Python 2.7, 3.3, 3.4, 3.5, and 3.6. Python 2.6 is no longer supported.
8080

81+
Using flag data from a file
82+
---------------------------
83+
For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See [`file_data_source.py`](https://github.com/launchdarkly/python-client/blob/master/ldclient/file_data_source.py) for more details.
84+
8185
Learn more
8286
-----------
8387

ldclient/file_data_source.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import json
2+
import os
3+
import six
4+
import traceback
5+
6+
have_yaml = False
7+
try:
8+
import yaml
9+
have_yaml = True
10+
except ImportError:
11+
pass
12+
13+
have_watchdog = False
14+
try:
15+
import watchdog
16+
import watchdog.events
17+
import watchdog.observers
18+
have_watchdog = True
19+
except ImportError:
20+
pass
21+
22+
from ldclient.interfaces import UpdateProcessor
23+
from ldclient.repeating_timer import RepeatingTimer
24+
from ldclient.util import log
25+
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
26+
27+
28+
class FileDataSource(UpdateProcessor):
29+
@classmethod
30+
def factory(cls, **kwargs):
31+
"""Provides a way to use local files as a source of feature flag state. This would typically be
32+
used in a test environment, to operate using a predetermined feature flag state without an
33+
actual LaunchDarkly connection.
34+
35+
To use this component, call `FileDataSource.factory`, and store its return value in the
36+
`update_processor_class` property of your LaunchDarkly client configuration. In the options
37+
to `factory`, set `paths` to the file path(s) of your data file(s):
38+
::
39+
40+
factory = FileDataSource.factory(paths = [ myFilePath ])
41+
config = Config(update_processor_class = factory)
42+
43+
This will cause the client not to connect to LaunchDarkly to get feature flags. The
44+
client may still make network connections to send analytics events, unless you have disabled
45+
this with Config.send_events or Config.offline.
46+
47+
Flag data files can be either JSON or YAML (in order to use YAML, you must install the 'pyyaml'
48+
package). They contain an object with three possible properties:
49+
50+
* "flags": Feature flag definitions.
51+
* "flagValues": Simplified feature flags that contain only a value.
52+
* "segments": User segment definitions.
53+
54+
The format of the data in "flags" and "segments" is defined by the LaunchDarkly application
55+
and is subject to change. Rather than trying to construct these objects yourself, it is simpler
56+
to request existing flags directly from the LaunchDarkly server in JSON format, and use this
57+
output as the starting point for your file. In Linux you would do this:
58+
::
59+
60+
curl -H "Authorization: {your sdk key}" https://app.launchdarkly.com/sdk/latest-all
61+
62+
The output will look something like this (but with many more properties):
63+
::
64+
65+
{
66+
"flags": {
67+
"flag-key-1": {
68+
"key": "flag-key-1",
69+
"on": true,
70+
"variations": [ "a", "b" ]
71+
}
72+
},
73+
"segments": {
74+
"segment-key-1": {
75+
"key": "segment-key-1",
76+
"includes": [ "user-key-1" ]
77+
}
78+
}
79+
}
80+
81+
Data in this format allows the SDK to exactly duplicate all the kinds of flag behavior supported
82+
by LaunchDarkly. However, in many cases you will not need this complexity, but will just want to
83+
set specific flag keys to specific values. For that, you can use a much simpler format:
84+
::
85+
86+
{
87+
"flagValues": {
88+
"my-string-flag-key": "value-1",
89+
"my-boolean-flag-key": true,
90+
"my-integer-flag-key": 3
91+
}
92+
}
93+
94+
Or, in YAML:
95+
::
96+
97+
flagValues:
98+
my-string-flag-key: "value-1"
99+
my-boolean-flag-key: true
100+
my-integer-flag-key: 1
101+
102+
It is also possible to specify both "flags" and "flagValues", if you want some flags
103+
to have simple values and others to have complex behavior. However, it is an error to use the
104+
same flag key or segment key more than once, either in a single file or across multiple files.
105+
106+
If the data source encounters any error in any file-- malformed content, a missing file, or a
107+
duplicate key-- it will not load flags from any of the files.
108+
109+
:param kwargs:
110+
See below
111+
112+
:Keyword arguments:
113+
* **paths** (array): The paths of the source files for loading flag data. These may be absolute paths
114+
or relative to the current working directory. Files will be parsed as JSON unless the 'pyyaml'
115+
package is installed, in which case YAML is also allowed.
116+
* **auto_update** (boolean): True if the data source should watch for changes to the source file(s)
117+
and reload flags whenever there is a change. The default implementation of this feature is based on
118+
polling the filesystem, which may not perform well; if you install the 'watchdog' package (not
119+
included by default, to avoid adding unwanted dependencies to the SDK), its native file watching
120+
mechanism will be used instead. Note that auto-updating will only work if all of the files you
121+
specified have valid directory paths at startup time.
122+
* **poll_interval** (float): The minimum interval, in seconds, between checks for file modifications -
123+
used only if auto_update is true, and if the native file-watching mechanism from 'watchdog' is not
124+
being used. The default value is 1 second.
125+
"""
126+
return lambda config, store, ready : FileDataSource(store, kwargs, ready)
127+
128+
def __init__(self, store, options, ready):
129+
self._store = store
130+
self._ready = ready
131+
self._inited = False
132+
self._paths = options.get('paths', [])
133+
if isinstance(self._paths, six.string_types):
134+
self._paths = [ self._paths ]
135+
self._auto_update = options.get('auto_update', False)
136+
self._auto_updater = None
137+
self._poll_interval = options.get('poll_interval', 1)
138+
self._force_polling = options.get('force_polling', False) # used only in tests
139+
140+
def start(self):
141+
self._load_all()
142+
143+
if self._auto_update:
144+
self._auto_updater = self._start_auto_updater()
145+
146+
# We will signal readiness immediately regardless of whether the file load succeeded or failed -
147+
# the difference can be detected by checking initialized()
148+
self._ready.set()
149+
150+
def stop(self):
151+
if self._auto_updater:
152+
self._auto_updater.stop()
153+
154+
def initialized(self):
155+
return self._inited
156+
157+
def _load_all(self):
158+
all_data = { FEATURES: {}, SEGMENTS: {} }
159+
for path in self._paths:
160+
try:
161+
self._load_file(path, all_data)
162+
except Exception as e:
163+
log.error('Unable to load flag data from "%s": %s' % (path, repr(e)))
164+
traceback.print_exc()
165+
return
166+
self._store.init(all_data)
167+
self._inited = True
168+
169+
def _load_file(self, path, all_data):
170+
content = None
171+
with open(path, 'r') as f:
172+
content = f.read()
173+
parsed = self._parse_content(content)
174+
for key, flag in six.iteritems(parsed.get('flags', {})):
175+
self._add_item(all_data, FEATURES, flag)
176+
for key, value in six.iteritems(parsed.get('flagValues', {})):
177+
self._add_item(all_data, FEATURES, self._make_flag_with_value(key, value))
178+
for key, segment in six.iteritems(parsed.get('segments', {})):
179+
self._add_item(all_data, SEGMENTS, segment)
180+
181+
def _parse_content(self, content):
182+
if have_yaml:
183+
return yaml.load(content) # pyyaml correctly parses JSON too
184+
return json.loads(content)
185+
186+
def _add_item(self, all_data, kind, item):
187+
items = all_data[kind]
188+
key = item.get('key')
189+
if items.get(key) is None:
190+
items[key] = item
191+
else:
192+
raise Exception('In %s, key "%s" was used more than once' % (kind.namespace, key))
193+
194+
def _make_flag_with_value(self, key, value):
195+
return {
196+
'key': key,
197+
'on': True,
198+
'fallthrough': {
199+
'variation': 0
200+
},
201+
'variations': [ value ]
202+
}
203+
204+
def _start_auto_updater(self):
205+
resolved_paths = []
206+
for path in self._paths:
207+
try:
208+
resolved_paths.append(os.path.realpath(path))
209+
except:
210+
log.warn('Cannot watch for changes to data file "%s" because it is an invalid path' % path)
211+
if have_watchdog and not self._force_polling:
212+
return FileDataSource.WatchdogAutoUpdater(resolved_paths, self._load_all)
213+
else:
214+
return FileDataSource.PollingAutoUpdater(resolved_paths, self._load_all, self._poll_interval)
215+
216+
# Watch for changes to data files using the watchdog package. This uses native OS filesystem notifications
217+
# if available for the current platform.
218+
class WatchdogAutoUpdater(object):
219+
def __init__(self, resolved_paths, reloader):
220+
watched_files = set(resolved_paths)
221+
222+
class LDWatchdogHandler(watchdog.events.FileSystemEventHandler):
223+
def on_any_event(self, event):
224+
if event.src_path in watched_files:
225+
reloader()
226+
227+
dir_paths = set()
228+
for path in resolved_paths:
229+
dir_paths.add(os.path.dirname(path))
230+
231+
self._observer = watchdog.observers.Observer()
232+
handler = LDWatchdogHandler()
233+
for path in dir_paths:
234+
self._observer.schedule(handler, path)
235+
self._observer.start()
236+
237+
def stop(self):
238+
self._observer.stop()
239+
self._observer.join()
240+
241+
# Watch for changes to data files by polling their modification times. This is used if auto-update is
242+
# on but the watchdog package is not installed.
243+
class PollingAutoUpdater(object):
244+
def __init__(self, resolved_paths, reloader, interval):
245+
self._paths = resolved_paths
246+
self._reloader = reloader
247+
self._file_times = self._check_file_times()
248+
self._timer = RepeatingTimer(interval, self._poll)
249+
self._timer.start()
250+
251+
def stop(self):
252+
self._timer.stop()
253+
254+
def _poll(self):
255+
new_times = self._check_file_times()
256+
changed = False
257+
for file_path, file_time in six.iteritems(self._file_times):
258+
if new_times.get(file_path) is not None and new_times.get(file_path) != file_time:
259+
changed = True
260+
break
261+
self._file_times = new_times
262+
if changed:
263+
self._reloader()
264+
265+
def _check_file_times(self):
266+
ret = {}
267+
for path in self._paths:
268+
try:
269+
ret[path] = os.path.getmtime(path)
270+
except:
271+
ret[path] = None
272+
return ret

test-requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ redis>=2.10.5
44
coverage>=4.4
55
pytest-capturelog>=0.7
66
pytest-cov>=2.4.0
7-
codeclimate-test-reporter>=0.2.1
7+
codeclimate-test-reporter>=0.2.1
8+
pyyaml>=3.0
9+
watchdog>=0.9

0 commit comments

Comments
 (0)