← Back to team overview

launchpad-reviewers team mailing list archive

[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