Martin Pool has proposed merging lp:~mbp/launchpad/flags into lp:launchpad.
Requested reviews: Launchpad code reviewers (launchpad-reviewers) 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. -- https://code.launchpad.net/~mbp/launchpad/flags/+merge/30581 Your team Launchpad code reviewers is requested to review the proposed merge of lp:~mbp/launchpad/flags into lp:launchpad.
=== 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-21 21:16:48 +0000 @@ -0,0 +1,10 @@ +# Copyright 2010 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""lp.services.features provide dynamically configurable feature flags. + +These can be turned on and off by admins, and can affect particular +defined scopes such as "beta users" or "production servers." + +See <https://dev.launchpad.net/LEP/FeatureFlags> +""" === 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-21 21:16:48 +0000 @@ -0,0 +1,99 @@ +# Copyright 2010 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +__all__ = ['FeatureController'] + +__metaclass__ = type + + +from lp.services.features import model + +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. + + It does this by meshing together two sources of data: + - feature flags, typically set by an administrator into the database + - feature scopes, which would typically be looked up based on attributes + of the current web request, or the user for whom a job is being run, or + something similar. + + This presents a higher-level interface over the storm model objects, + oriented only towards readers. + + 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. + + See <https://dev.launchpad.net/LEP/FeatureFlags> + """ + + def __init__(self, scopes): + """Construct a new view of the features for a set of scopes. + """ + self._collection = model.FeatureFlagCollection() + self.scopes = self._preenScopes(scopes) + + def setScopes(self, scopes): + self.scopes = self._preenScopes(scopes) + + def getFlag(self, flag_name): + rs = (self._collection + .refine(model.FeatureFlag.scope.is_in(self.scopes), + model.FeatureFlag.flag == unicode(flag_name)) + .select() + .order_by(Desc(model.FeatureFlag.priority))) + rs.config(limit=1) + if rs.is_empty(): + return None + else: + f = rs.one() + return f.value + + def getAllFlags(self): + """Get the feature flags active for the current scopes. + + :returns: dict from flag_name (unicode) to value (unicode). + """ + rs = (self._collection + .refine(model.FeatureFlag.scope.is_in(self.scopes)) + .select() + .order_by(model.FeatureFlag.priority)) + return dict((str(f.flag), f.value) for f in rs) + + def _preenScopes(self, scopes): + # for convenience turn strings to unicode + us = [] + for s in scopes: + if isinstance(s, unicode): + us.append(s) + elif isinstance(s, str): + us.append(unicode(s)) + else: + raise TypeError("invalid scope: %r" % s) + return us + + def addSetting(self, scope, flag, value, priority): + """Add a setting for a flag. + + Note that flag settings are global in the database: they affect all + FeatureControllers connected to this database, and they will persist + if the database transaction is committed. + """ + flag_obj = model.FeatureFlag(scope=unicode(scope), + flag=unicode(flag), + value=value, + priority=priority) + self._collection.store.add(flag_obj) === 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-21 21:16:48 +0000 @@ -0,0 +1,43 @@ +# Copyright 2010 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +__all__ = ['FeatureFlag' + ] + +__metaclass__ = type + +from zope.component import getUtility +from storm.locals import Int, Storm, Unicode, DateTime + +from canonical.launchpad.webapp.interfaces import ( + DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE) + +from lp.services.database import collection + + +class FeatureFlag(Storm): + """Database setting of a particular flag in a scope""" + + __storm_table__ = 'FeatureFlag' + __storm_primary__ = "scope", "flag" + + scope = Unicode(allow_none=False) + flag = Unicode(allow_none=False) + priority = Int(allow_none=False) + value = Unicode(allow_none=False) + date_modified = DateTime() + + def __init__(self, scope, priority, flag, value): + super(FeatureFlag, self).__init__() + self.scope = scope + self.priority = priority + self.flag = flag + self.value = value + + +class FeatureFlagCollection(collection.Collection): + """Provides easy access to sets of flags.""" + + # the Launchpad Collection knows how to find a good store to start from, + + starting_table = FeatureFlag === 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-21 21:16:48 +0000 @@ -0,0 +1,5 @@ +# Copyright 2010 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Look up current feature flags. +""" === 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-21 21:16:48 +0000 @@ -0,0 +1,4 @@ +# Copyright 2010 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +pass === 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-21 21:16:48 +0000 @@ -0,0 +1,26 @@ +# Copyright 2010 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Tests for feature settings coming from the database""" + + +from __future__ import with_statement +__metaclass__ = type + +import testtools + +from canonical.testing import layers +from lp.services.features.model import ( + FeatureFlag, + FeatureFlagCollection, + ) + + +class TestFeatureModel(testtools.TestCase): + + layer = layers.DatabaseFunctionalLayer + + def test_defaultEmptyCollection(self): + # there are no settings in the sampledata + coll = FeatureFlagCollection() + self.assertTrue(coll.select().is_empty()) === 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-21 21:16:48 +0000 @@ -0,0 +1,103 @@ +# Copyright 2010 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Tests for feature flags. + +""" + + +from __future__ import with_statement +__metaclass__ = type + +import testtools + +from canonical.testing import layers + +from lp.services.features import flags, model + + +notification_name = 'notification.global.text' +notification_value = u'\N{SNOWMAN} stormy Launchpad weather ahead' +example_scope = 'beta_user' + +class TestFeatureFlags(testtools.TestCase): + + layer = layers.DatabaseFunctionalLayer + + def test_defaultFlags(self): + # the sample db has no flags set + control = flags.FeatureController([]) + self.assertEqual({}, + control.getAllFlags()) + + def test_simpleFlags(self): + # with some flags set in the db, you can query them through the + # FeatureController + flag = model.FeatureFlag( + scope=unicode(example_scope), + flag=unicode(notification_name), + value=notification_value, + priority=100) + model.FeatureFlagCollection().store.add(flag) + control = flags.FeatureController(['beta_user']) + self.assertEqual(notification_value, + control.getFlag(notification_name)) + + def test_setFlags(self): + # you can also set flags through a facade + control = self.makePopulatedController() + self.assertEqual(notification_value, + control.getFlag(notification_name)) + + def test_getAllFlags(self): + # can fetch all the active flags, and it gives back only the + # highest-priority settings + control = self.makeControllerWithOverrides() + self.assertEqual( + {'ui.icing': '4.0', + notification_name: notification_value}, + control.getAllFlags()) + + def test_overrideFlag(self): + # if there are multiple settings for a flag, and they match multiple + # scopes, the priorities determine which is matched + control = self.makeControllerWithOverrides() + control.setScopes(['default']) + self.assertEqual( + u'3.0', + control.getFlag('ui.icing')) + control.setScopes(['default', 'beta_user']) + self.assertEqual( + u'4.0', + control.getFlag('ui.icing')) + + def test_undefinedFlag(self): + # if the flag is not defined, we get None + control = self.makeControllerWithOverrides() + self.assertIs(None, + control.getFlag('unknown_flag')) + control.setScopes([]) + self.assertIs(None, + control.getFlag('ui.icing')) + + def makePopulatedController(self): + # make a controller with some test flags + control = flags.FeatureController(['beta_user']) + control.addSetting( + scope=example_scope, flag=notification_name, + value=notification_value, priority=100) + return control + + def makeControllerWithOverrides(self): + control = self.makePopulatedController() + control.addSetting( + scope='default', + flag='ui.icing', + value=u'3.0', + priority=100) + control.addSetting( + scope='beta_user', + flag='ui.icing', + value=u'4.0', + priority=300) + return control
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : [email protected] Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp

