launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #00209
[Merge] lp:~mbp/launchpad/flags into lp:launchpad
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