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
=== added directory 'lib/lp/services/features'
=== added file 'lib/lp/services/features/__init__.py'
--- lib/lp/services/features/__init__.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/__init__.py 2010-07-25 09:39:52 +0000
@@ -0,0 +1,10 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""lp.services.features provide dynamically configurable feature flags.
5
6These can be turned on and off by admins, and can affect particular
7defined scopes such as "beta users" or "production servers."
8
9See <https://dev.launchpad.net/LEP/FeatureFlags>
10"""
011
=== added file 'lib/lp/services/features/flags.py'
--- lib/lp/services/features/flags.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/flags.py 2010-07-25 09:39:52 +0000
@@ -0,0 +1,85 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__all__ = ['FeatureController']
5
6__metaclass__ = type
7
8
9from lp.services.features.model import (
10 getFeatureStore,
11 FeatureFlag,
12 )
13
14
15class FeatureController(object):
16 """A FeatureController tells application code what features are active.
17
18 It does this by meshing together two sources of data:
19 - feature flags, typically set by an administrator into the database
20 - feature scopes, which would typically be looked up based on attributes
21 of the current web request, or the user for whom a job is being run, or
22 something similar.
23
24 FeatureController presents a high level interface for application code to
25 query flag values, without it needing to know that they are stored in the
26 database.
27
28 At this level flag names and scope names are presented as strings for
29 easier use in Python code, though the values remain unicode. They
30 should always be ascii like Python identifiers.
31
32 One instance of FeatureController should be constructed for the lifetime
33 of code that has consistent configuration values. For instance there will
34 be one per web app request.
35
36 Intended performance: when this object is first constructed, it will read
37 the whole current feature flags from the database. This will take a few
38 ms. The controller is then supposed to be held in a thread-local for the
39 duration of the request.
40
41 See <https://dev.launchpad.net/LEP/FeatureFlags>
42 """
43
44 def __init__(self, scopes):
45 """Construct a new view of the features for a set of scopes.
46 """
47 self._store = getFeatureStore()
48 self._scopes = self._preenScopes(scopes)
49 self._cached_flags = self._queryAllFlags()
50
51 def getScopes(self):
52 return frozenset(self._scopes)
53
54 def getFlag(self, flag_name):
55 return self._cached_flags.get(flag_name)
56
57 def getAllFlags(self):
58 """Get the feature flags active for the current scopes.
59
60 :returns: dict from flag_name (unicode) to value (unicode).
61 """
62 return dict(self._cached_flags)
63
64 def _queryAllFlags(self):
65 d = {}
66 rs = (self._store
67 .find(FeatureFlag,
68 FeatureFlag.scope.is_in(self._scopes))
69 .order_by(FeatureFlag.priority)
70 .values(FeatureFlag.flag, FeatureFlag.value))
71 for flag, value in rs:
72 d[str(flag)] = value
73 return d
74
75 def _preenScopes(self, scopes):
76 # for convenience turn strings to unicode
77 us = []
78 for s in scopes:
79 if isinstance(s, unicode):
80 us.append(s)
81 elif isinstance(s, str):
82 us.append(unicode(s))
83 else:
84 raise TypeError("invalid scope: %r" % s)
85 return us
086
=== added file 'lib/lp/services/features/model.py'
--- lib/lp/services/features/model.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/model.py 2010-07-25 09:39:52 +0000
@@ -0,0 +1,42 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__all__ = [
5 'FeatureFlag',
6 'getFeatureStore',
7 ]
8
9__metaclass__ = type
10
11from zope.component import getUtility
12from storm.locals import Int, Storm, Unicode, DateTime
13
14from canonical.launchpad.webapp.interfaces import (
15 DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
16
17
18def getFeatureStore():
19 """Get Storm store to access feature definitions."""
20 # TODO: This is copied so many times in Launchpad; maybe it should be more
21 # general?
22 return getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
23
24
25class FeatureFlag(Storm):
26 """Database setting of a particular flag in a scope"""
27
28 __storm_table__ = 'FeatureFlag'
29 __storm_primary__ = "scope", "flag"
30
31 scope = Unicode(allow_none=False)
32 flag = Unicode(allow_none=False)
33 priority = Int(allow_none=False)
34 value = Unicode(allow_none=False)
35 date_modified = DateTime()
36
37 def __init__(self, scope, priority, flag, value):
38 super(FeatureFlag, self).__init__()
39 self.scope = scope
40 self.priority = priority
41 self.flag = flag
42 self.value = value
043
=== added file 'lib/lp/services/features/scopes.py'
--- lib/lp/services/features/scopes.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/scopes.py 2010-07-25 09:39:52 +0000
@@ -0,0 +1,5 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Look up current feature flags.
5"""
06
=== added directory 'lib/lp/services/features/tests'
=== added file 'lib/lp/services/features/tests/__init__.py'
--- lib/lp/services/features/tests/__init__.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/tests/__init__.py 2010-07-25 09:39:52 +0000
@@ -0,0 +1,2 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
03
=== added file 'lib/lp/services/features/tests/test_db_settings.py'
--- lib/lp/services/features/tests/test_db_settings.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/tests/test_db_settings.py 2010-07-25 09:39:52 +0000
@@ -0,0 +1,25 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for feature settings coming from the database"""
5
6
7from __future__ import with_statement
8__metaclass__ = type
9
10from canonical.testing import layers
11from lp.testing import TestCase
12from lp.services.features.model import (
13 getFeatureStore,
14 FeatureFlag,
15 )
16
17
18class TestFeatureModel(TestCase):
19
20 layer = layers.DatabaseFunctionalLayer
21
22 def test_defaultEmptyCollection(self):
23 # there are no settings in the sampledata
24 store = getFeatureStore()
25 self.assertTrue(store.find(FeatureFlag).is_empty())
026
=== added file 'lib/lp/services/features/tests/test_flags.py'
--- lib/lp/services/features/tests/test_flags.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/tests/test_flags.py 2010-07-25 09:39:52 +0000
@@ -0,0 +1,95 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for feature flags."""
5
6
7from __future__ import with_statement
8__metaclass__ = type
9
10import os
11
12from canonical.testing import layers
13from lp.testing import TestCase
14
15from lp.services.features import model
16from lp.services.features.flags import (
17 FeatureController,
18 )
19
20
21notification_name = 'notification.global.text'
22notification_value = u'\N{SNOWMAN} stormy Launchpad weather ahead'
23example_scope = 'beta_user'
24
25
26testdata = [
27 (example_scope, notification_name, notification_value, 100),
28 ('default', 'ui.icing', u'3.0', 100),
29 ('beta_user', 'ui.icing', u'4.0', 300),
30 ]
31
32
33class TestFeatureFlags(TestCase):
34
35 layer = layers.DatabaseFunctionalLayer
36
37 def setUp(self):
38 super(TestFeatureFlags, self).setUp()
39 if os.environ.get("STORM_TRACE", None):
40 from storm.tracer import debug
41 debug(True)
42
43 def populateStore(self):
44 store = model.getFeatureStore()
45 for (scope, flag, value, priority) in testdata:
46 store.add(model.FeatureFlag(
47 scope=unicode(scope),
48 flag=unicode(flag),
49 value=value,
50 priority=priority))
51
52 def test_defaultFlags(self):
53 # the sample db has no flags set
54 control = FeatureController([])
55 self.assertEqual({},
56 control.getAllFlags())
57
58 def test_getFlag(self):
59 self.populateStore()
60 control = FeatureController(['default'])
61 self.assertEqual(u'3.0',
62 control.getFlag('ui.icing'))
63
64 def test_getAllFlags(self):
65 # can fetch all the active flags, and it gives back only the
66 # highest-priority settings
67 self.populateStore()
68 control = FeatureController(['default', 'beta_user'])
69 self.assertEqual(
70 {'ui.icing': '4.0',
71 notification_name: notification_value},
72 control.getAllFlags())
73
74 def test_overrideFlag(self):
75 # if there are multiple settings for a flag, and they match multiple
76 # scopes, the priorities determine which is matched
77 self.populateStore()
78 default_control = FeatureController(['default'])
79 self.assertEqual(
80 u'3.0',
81 default_control.getFlag('ui.icing'))
82 beta_control = FeatureController(['default', 'beta_user'])
83 self.assertEqual(
84 u'4.0',
85 beta_control.getFlag('ui.icing'))
86
87 def test_undefinedFlag(self):
88 # if the flag is not defined, we get None
89 self.populateStore()
90 control = FeatureController(['default', 'beta_user'])
91 self.assertIs(None,
92 control.getFlag('unknown_flag'))
93 no_scope_flags = FeatureController([])
94 self.assertIs(None,
95 no_scope_flags.getFlag('ui.icing'))

Subscribers

People subscribed via source and target branches

to status/vote changes: