Merge lp:~mbp/launchpad/flags into lp:launchpad/db-devel

Proposed by Martin Pool
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 9573
Proposed branch: lp:~mbp/launchpad/flags
Merge into: lp:launchpad/db-devel
Diff against target: 300 lines (+264/-0)
7 files modified
lib/lp/services/features/__init__.py (+10/-0)
lib/lp/services/features/flags.py (+85/-0)
lib/lp/services/features/model.py (+42/-0)
lib/lp/services/features/scopes.py (+5/-0)
lib/lp/services/features/tests/__init__.py (+2/-0)
lib/lp/services/features/tests/test_db_settings.py (+25/-0)
lib/lp/services/features/tests/test_flags.py (+95/-0)
To merge this branch: bzr merge lp:~mbp/launchpad/flags
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Review via email: mp+30581@code.launchpad.net

Commit message

Add FeatureController to read feature flags from the db

Description of the change

Second instalment of https://dev.launchpad.net/LEP/FeatureFlags

This adds a class that tells you about active features in a particular scope. It would be usable as is but I will do more to automatically give you the right context in a web app request, etc.

To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :
Download full text (5.4 KiB)

I think referencing the LEP in the docstring is ok but not a substitute for including the docs in the tree - better to include the information in the module so that people don't need a web connection to understand whats going on.

I like the intended performance notes - please put them in the docstring so the API docs will show them.

I would perhaps say '
53 + This presents a higher-level interface over the storm model objects,
54 + oriented only towards readers.
55 +
56 + At this level flag names and scope names are presented as strings for
57 + easier use in Python code, though the values remain unicode. They
58 + should always be ascii like Python identifiers.
'

As
This presents an interface optimised for use in code making conditional choices based on the feature flags. At this level ....

This -
165 + # the Launchpad Collection knows how to find a good store to start from,
166 +

is a bit weird to mention.

169 === added file 'lib/lp/services/features/scopes.py'
170 --- lib/lp/services/features/scopes.py 1970-01-01 00:00:00 +0000
171 +++ lib/lp/services/features/scopes.py 2010-07-21 21:16:48 +0000
172 @@ -0,0 +1,5 @@
173 +# Copyright 2010 Canonical Ltd. This software is licensed under the
174 +# GNU Affero General Public License version 3 (see the file LICENSE).
175 +
176 +"""Look up current feature flags.
177 +"""

This seems unused - you might want to trim it for now.

179 === added directory 'lib/lp/services/features/tests'
180 === added file 'lib/lp/services/features/tests/__init__.py'
181 --- lib/lp/services/features/tests/__init__.py 1970-01-01 00:00:00 +0000
182 +++ lib/lp/services/features/tests/__init__.py 2010-07-21 21:16:48 +0000
183 @@ -0,0 +1,4 @@
184 +# Copyright 2010 Canonical Ltd. This software is licensed under the
185 +# GNU Affero General Public License version 3 (see the file LICENSE).
186 +
187 +pass

The pass isn't needed :)

189 === added file 'lib/lp/services/features/tests/test_db_settings.py'
190 --- lib/lp/services/features/tests/test_db_settings.py 1970-01-01 00:00:00 +0000
191 +++ lib/lp/services/features/tests/test_db_settings.py 2010-07-21 21:16:48 +0000
192 @@ -0,0 +1,26 @@
193 +# Copyright 2010 Canonical Ltd. This software is licensed under the
194 +# GNU Affero General Public License version 3 (see the file LICENSE).
195 +
196 +"""Tests for feature settings coming from the database"""
197 +
198 +
199 +from __future__ import with_statement
200 +__metaclass__ = type
201 +
202 +import testtools

Launchpad has a TestCase of its own you should use.

204 +from canonical.testing import layers
205 +from lp.services.features.model import (
206 + FeatureFlag,
207 + FeatureFlagCollection,
208 + )
209 +
210 +
211 +class TestFeatureModel(testtools.TestCase):
212 +
213 + layer = layers.DatabaseFunctionalLayer
214 +
215 + def test_defaultEmptyCollection(self):
216 + # there are no settings in the sampledata
217 + coll = FeatureFlagCollection()
218 + self.assertTrue(coll.select().is_empty())

I'd make that statement stronger: there should be no settings...

220 === added file 'lib/lp/services/features/tests/test_flags.py'
221 --- lib/lp/services/fea...

Read more...

review: Approve
Revision history for this message
Martin Pool (mbp) wrote :
Download full text (11.4 KiB)

Incremental diff:

 * facade loads everything at construction time
 * remove write facade methods

=== modified file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py 2010-07-22 10:48:14 +0000
+++ lib/lp/services/features/flags.py 2010-07-22 12:28:32 +0000
@@ -6,21 +6,16 @@
 __metaclass__ = type

-from lp.services.features import model
+from lp.services.features.model import (
+ getFeatureStore,
+ FeatureFlag,
+ )

 from storm.locals import (
     Desc,
     )

-# Intended performance: when this object is first constructed, it will read
-# the whole current feature flags from the database. This will take a few ms.
-# The controller is then supposed to be held in a thread-local for the
-# duration of the request. The scopes can be changed over the lifetime of the
-# controller, because we might not know enough to determine all the active
-# scopes when the object's first created. This isn't validated to work yet.
-
-
 class FeatureController(object):
     """A FeatureController tells application code what features are active.

@@ -30,18 +25,21 @@
       of the current web request, or the user for whom a job is being run, or
       something similar.

- This presents a higher-level facade that is independent of how the flags
- are stored. At the moment they are stored in the database but callers
- shouldn't need to know about that: they could go into memcachedb or
- elsewhere in future.
+ FeatureController presents a high level interface for application code to
+ query flag values, without it needing to know that they are stored in the database.

     At this level flag names and scope names are presented as strings for
     easier use in Python code, though the values remain unicode. They
     should always be ascii like Python identifiers.

- One instance of this should be constructed for the lifetime of code that
- has consistent configuration values. For instance there will be one per web
- app request.
+ One instance of FeatureController should be constructed for the lifetime
+ of code that has consistent configuration values. For instance there will
+ be one per web app request.
+
+ Intended performance: when this object is first constructed, it will read
+ the whole current feature flags from the database. This will take a few ms.
+ The controller is then supposed to be held in a thread-local for the
+ duration of the request.

     See <https://dev.launchpad.net/LEP/FeatureFlags>
     """
@@ -49,34 +47,33 @@
     def __init__(self, scopes):
         """Construct a new view of the features for a set of scopes.
         """
- self._store = model.getFeatureStore()
- self.scopes = self._preenScopes(scopes)
+ self._store = getFeatureStore()
+ self._scopes = self._preenScopes(scopes)
+ self._cached_flags = self._queryAllFlags()

- def setScopes(self, scopes):
- self.scopes = self._preenScopes(scopes)
+ def getScopes(self):
+ return frozenset(self._scopes)

     def getFlag(self, flag_name):
- rs = (self._store
- .find(model.FeatureFlag,
- mod...

Revision history for this message
Martin Pool (mbp) wrote :

ec2 disappeared into thin air, perhaps because of smtp setup problems. Would someone please submit this on my behalf?

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'lib/lp/services/features'
2=== added file 'lib/lp/services/features/__init__.py'
3--- lib/lp/services/features/__init__.py 1970-01-01 00:00:00 +0000
4+++ lib/lp/services/features/__init__.py 2010-07-25 09:39:52 +0000
5@@ -0,0 +1,10 @@
6+# Copyright 2010 Canonical Ltd. This software is licensed under the
7+# GNU Affero General Public License version 3 (see the file LICENSE).
8+
9+"""lp.services.features provide dynamically configurable feature flags.
10+
11+These can be turned on and off by admins, and can affect particular
12+defined scopes such as "beta users" or "production servers."
13+
14+See <https://dev.launchpad.net/LEP/FeatureFlags>
15+"""
16
17=== added file 'lib/lp/services/features/flags.py'
18--- lib/lp/services/features/flags.py 1970-01-01 00:00:00 +0000
19+++ lib/lp/services/features/flags.py 2010-07-25 09:39:52 +0000
20@@ -0,0 +1,85 @@
21+# Copyright 2010 Canonical Ltd. This software is licensed under the
22+# GNU Affero General Public License version 3 (see the file LICENSE).
23+
24+__all__ = ['FeatureController']
25+
26+__metaclass__ = type
27+
28+
29+from lp.services.features.model import (
30+ getFeatureStore,
31+ FeatureFlag,
32+ )
33+
34+
35+class FeatureController(object):
36+ """A FeatureController tells application code what features are active.
37+
38+ It does this by meshing together two sources of data:
39+ - feature flags, typically set by an administrator into the database
40+ - feature scopes, which would typically be looked up based on attributes
41+ of the current web request, or the user for whom a job is being run, or
42+ something similar.
43+
44+ FeatureController presents a high level interface for application code to
45+ query flag values, without it needing to know that they are stored in the
46+ database.
47+
48+ At this level flag names and scope names are presented as strings for
49+ easier use in Python code, though the values remain unicode. They
50+ should always be ascii like Python identifiers.
51+
52+ One instance of FeatureController should be constructed for the lifetime
53+ of code that has consistent configuration values. For instance there will
54+ be one per web app request.
55+
56+ Intended performance: when this object is first constructed, it will read
57+ the whole current feature flags from the database. This will take a few
58+ ms. The controller is then supposed to be held in a thread-local for the
59+ duration of the request.
60+
61+ See <https://dev.launchpad.net/LEP/FeatureFlags>
62+ """
63+
64+ def __init__(self, scopes):
65+ """Construct a new view of the features for a set of scopes.
66+ """
67+ self._store = getFeatureStore()
68+ self._scopes = self._preenScopes(scopes)
69+ self._cached_flags = self._queryAllFlags()
70+
71+ def getScopes(self):
72+ return frozenset(self._scopes)
73+
74+ def getFlag(self, flag_name):
75+ return self._cached_flags.get(flag_name)
76+
77+ def getAllFlags(self):
78+ """Get the feature flags active for the current scopes.
79+
80+ :returns: dict from flag_name (unicode) to value (unicode).
81+ """
82+ return dict(self._cached_flags)
83+
84+ def _queryAllFlags(self):
85+ d = {}
86+ rs = (self._store
87+ .find(FeatureFlag,
88+ FeatureFlag.scope.is_in(self._scopes))
89+ .order_by(FeatureFlag.priority)
90+ .values(FeatureFlag.flag, FeatureFlag.value))
91+ for flag, value in rs:
92+ d[str(flag)] = value
93+ return d
94+
95+ def _preenScopes(self, scopes):
96+ # for convenience turn strings to unicode
97+ us = []
98+ for s in scopes:
99+ if isinstance(s, unicode):
100+ us.append(s)
101+ elif isinstance(s, str):
102+ us.append(unicode(s))
103+ else:
104+ raise TypeError("invalid scope: %r" % s)
105+ return us
106
107=== added file 'lib/lp/services/features/model.py'
108--- lib/lp/services/features/model.py 1970-01-01 00:00:00 +0000
109+++ lib/lp/services/features/model.py 2010-07-25 09:39:52 +0000
110@@ -0,0 +1,42 @@
111+# Copyright 2010 Canonical Ltd. This software is licensed under the
112+# GNU Affero General Public License version 3 (see the file LICENSE).
113+
114+__all__ = [
115+ 'FeatureFlag',
116+ 'getFeatureStore',
117+ ]
118+
119+__metaclass__ = type
120+
121+from zope.component import getUtility
122+from storm.locals import Int, Storm, Unicode, DateTime
123+
124+from canonical.launchpad.webapp.interfaces import (
125+ DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
126+
127+
128+def getFeatureStore():
129+ """Get Storm store to access feature definitions."""
130+ # TODO: This is copied so many times in Launchpad; maybe it should be more
131+ # general?
132+ return getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
133+
134+
135+class FeatureFlag(Storm):
136+ """Database setting of a particular flag in a scope"""
137+
138+ __storm_table__ = 'FeatureFlag'
139+ __storm_primary__ = "scope", "flag"
140+
141+ scope = Unicode(allow_none=False)
142+ flag = Unicode(allow_none=False)
143+ priority = Int(allow_none=False)
144+ value = Unicode(allow_none=False)
145+ date_modified = DateTime()
146+
147+ def __init__(self, scope, priority, flag, value):
148+ super(FeatureFlag, self).__init__()
149+ self.scope = scope
150+ self.priority = priority
151+ self.flag = flag
152+ self.value = value
153
154=== added file 'lib/lp/services/features/scopes.py'
155--- lib/lp/services/features/scopes.py 1970-01-01 00:00:00 +0000
156+++ lib/lp/services/features/scopes.py 2010-07-25 09:39:52 +0000
157@@ -0,0 +1,5 @@
158+# Copyright 2010 Canonical Ltd. This software is licensed under the
159+# GNU Affero General Public License version 3 (see the file LICENSE).
160+
161+"""Look up current feature flags.
162+"""
163
164=== added directory 'lib/lp/services/features/tests'
165=== added file 'lib/lp/services/features/tests/__init__.py'
166--- lib/lp/services/features/tests/__init__.py 1970-01-01 00:00:00 +0000
167+++ lib/lp/services/features/tests/__init__.py 2010-07-25 09:39:52 +0000
168@@ -0,0 +1,2 @@
169+# Copyright 2010 Canonical Ltd. This software is licensed under the
170+# GNU Affero General Public License version 3 (see the file LICENSE).
171
172=== added file 'lib/lp/services/features/tests/test_db_settings.py'
173--- lib/lp/services/features/tests/test_db_settings.py 1970-01-01 00:00:00 +0000
174+++ lib/lp/services/features/tests/test_db_settings.py 2010-07-25 09:39:52 +0000
175@@ -0,0 +1,25 @@
176+# Copyright 2010 Canonical Ltd. This software is licensed under the
177+# GNU Affero General Public License version 3 (see the file LICENSE).
178+
179+"""Tests for feature settings coming from the database"""
180+
181+
182+from __future__ import with_statement
183+__metaclass__ = type
184+
185+from canonical.testing import layers
186+from lp.testing import TestCase
187+from lp.services.features.model import (
188+ getFeatureStore,
189+ FeatureFlag,
190+ )
191+
192+
193+class TestFeatureModel(TestCase):
194+
195+ layer = layers.DatabaseFunctionalLayer
196+
197+ def test_defaultEmptyCollection(self):
198+ # there are no settings in the sampledata
199+ store = getFeatureStore()
200+ self.assertTrue(store.find(FeatureFlag).is_empty())
201
202=== added file 'lib/lp/services/features/tests/test_flags.py'
203--- lib/lp/services/features/tests/test_flags.py 1970-01-01 00:00:00 +0000
204+++ lib/lp/services/features/tests/test_flags.py 2010-07-25 09:39:52 +0000
205@@ -0,0 +1,95 @@
206+# Copyright 2010 Canonical Ltd. This software is licensed under the
207+# GNU Affero General Public License version 3 (see the file LICENSE).
208+
209+"""Tests for feature flags."""
210+
211+
212+from __future__ import with_statement
213+__metaclass__ = type
214+
215+import os
216+
217+from canonical.testing import layers
218+from lp.testing import TestCase
219+
220+from lp.services.features import model
221+from lp.services.features.flags import (
222+ FeatureController,
223+ )
224+
225+
226+notification_name = 'notification.global.text'
227+notification_value = u'\N{SNOWMAN} stormy Launchpad weather ahead'
228+example_scope = 'beta_user'
229+
230+
231+testdata = [
232+ (example_scope, notification_name, notification_value, 100),
233+ ('default', 'ui.icing', u'3.0', 100),
234+ ('beta_user', 'ui.icing', u'4.0', 300),
235+ ]
236+
237+
238+class TestFeatureFlags(TestCase):
239+
240+ layer = layers.DatabaseFunctionalLayer
241+
242+ def setUp(self):
243+ super(TestFeatureFlags, self).setUp()
244+ if os.environ.get("STORM_TRACE", None):
245+ from storm.tracer import debug
246+ debug(True)
247+
248+ def populateStore(self):
249+ store = model.getFeatureStore()
250+ for (scope, flag, value, priority) in testdata:
251+ store.add(model.FeatureFlag(
252+ scope=unicode(scope),
253+ flag=unicode(flag),
254+ value=value,
255+ priority=priority))
256+
257+ def test_defaultFlags(self):
258+ # the sample db has no flags set
259+ control = FeatureController([])
260+ self.assertEqual({},
261+ control.getAllFlags())
262+
263+ def test_getFlag(self):
264+ self.populateStore()
265+ control = FeatureController(['default'])
266+ self.assertEqual(u'3.0',
267+ control.getFlag('ui.icing'))
268+
269+ def test_getAllFlags(self):
270+ # can fetch all the active flags, and it gives back only the
271+ # highest-priority settings
272+ self.populateStore()
273+ control = FeatureController(['default', 'beta_user'])
274+ self.assertEqual(
275+ {'ui.icing': '4.0',
276+ notification_name: notification_value},
277+ control.getAllFlags())
278+
279+ def test_overrideFlag(self):
280+ # if there are multiple settings for a flag, and they match multiple
281+ # scopes, the priorities determine which is matched
282+ self.populateStore()
283+ default_control = FeatureController(['default'])
284+ self.assertEqual(
285+ u'3.0',
286+ default_control.getFlag('ui.icing'))
287+ beta_control = FeatureController(['default', 'beta_user'])
288+ self.assertEqual(
289+ u'4.0',
290+ beta_control.getFlag('ui.icing'))
291+
292+ def test_undefinedFlag(self):
293+ # if the flag is not defined, we get None
294+ self.populateStore()
295+ control = FeatureController(['default', 'beta_user'])
296+ self.assertIs(None,
297+ control.getFlag('unknown_flag'))
298+ no_scope_flags = FeatureController([])
299+ self.assertIs(None,
300+ no_scope_flags.getFlag('ui.icing'))

Subscribers

People subscribed via source and target branches

to status/vote changes: