Merge lp:~mbp/launchpad/flags-webapp into lp:launchpad

Proposed by Martin Pool
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 11370
Proposed branch: lp:~mbp/launchpad/flags-webapp
Merge into: lp:launchpad
Diff against target: 683 lines (+428/-60)
10 files modified
lib/canonical/configure.zcml (+2/-1)
lib/canonical/launchpad/webapp/servers.py (+7/-0)
lib/lp/app/browser/tests/base-layout.txt (+2/-1)
lib/lp/app/templates/base-layout.pt (+6/-1)
lib/lp/services/features/__init__.py (+22/-3)
lib/lp/services/features/configure.zcml (+19/-0)
lib/lp/services/features/doc/features.txt (+127/-0)
lib/lp/services/features/flags.py (+117/-38)
lib/lp/services/features/tests/test_flags.py (+85/-16)
lib/lp/services/features/webapp.py (+41/-0)
To merge this branch: bzr merge lp:~mbp/launchpad/flags-webapp
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Review via email: mp+32833@code.launchpad.net

Commit message

add flags webapp infrastructure

Description of the change

Since <https://code.edge.launchpad.net/~mbp/launchpad/flags-webapp/+merge/31122> was accepted into db-devel, I'd like to also land it into devel, since the db dependencies are now there.

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

naturally, please do so.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/configure.zcml'
2--- lib/canonical/configure.zcml 2010-07-24 00:07:54 +0000
3+++ lib/canonical/configure.zcml 2010-08-17 02:31:17 +0000
4@@ -1,4 +1,4 @@
5-<!-- Copyright 2009 Canonical Ltd. This software is licensed under the
6+<!-- Copyright 2009, 2010 Canonical Ltd. This software is licensed under the
7 GNU Affero General Public License version 3 (see the file LICENSE).
8 -->
9
10@@ -21,6 +21,7 @@
11 <include package="lp.services.job" />
12 <include package="lp.services.memcache" />
13 <include package="lp.services.profile" />
14+ <include package="lp.services.features" />
15 <include package="lp.services.scripts" />
16 <include package="lp.services.worlddata" />
17 <include package="lp.services.salesforce" />
18
19=== modified file 'lib/canonical/launchpad/webapp/servers.py'
20--- lib/canonical/launchpad/webapp/servers.py 2010-08-02 02:23:26 +0000
21+++ lib/canonical/launchpad/webapp/servers.py 2010-08-17 02:31:17 +0000
22@@ -79,6 +79,10 @@
23
24 from canonical.lazr.timeout import set_default_timeout_function
25
26+from lp.services.features.flags import (
27+ NullFeatureController,
28+ )
29+
30
31 class StepsToGo:
32 """
33@@ -838,6 +842,9 @@
34 self.needs_datetimepicker_iframe = False
35 self.needs_json = False
36 self.needs_gmap2 = False
37+ # stub out the FeatureController that would normally be provided by
38+ # the publication mechanism
39+ self.features = NullFeatureController()
40
41 @property
42 def uuid(self):
43
44=== modified file 'lib/lp/app/browser/tests/base-layout.txt'
45--- lib/lp/app/browser/tests/base-layout.txt 2010-08-04 13:38:22 +0000
46+++ lib/lp/app/browser/tests/base-layout.txt 2010-08-17 02:31:17 +0000
47@@ -135,10 +135,11 @@
48 Has application tabs: False
49 Has side portlets: False
50 At least ... queries issued in ... seconds
51+ Features: {}
52+ in scopes {}
53 r...
54 -->
55
56-
57 Page Headings
58 -------------
59
60
61=== modified file 'lib/lp/app/templates/base-layout.pt'
62--- lib/lp/app/templates/base-layout.pt 2010-08-06 15:40:39 +0000
63+++ lib/lp/app/templates/base-layout.pt 2010-08-17 02:31:17 +0000
64@@ -14,6 +14,8 @@
65 site_message modules/canonical.config/config/launchpad/site_message;
66 icingroot string:${rooturl}+icing/rev${revno};
67 icingroot_contrib string:${rooturl}+icing-contrib/rev${revno};
68+ features request/features;
69+ feature_scopes request/features/scopes;
70 CONTEXTS python:{'template':template, 'context': context, 'view':view};
71 "
72 ><metal:doctype define-slot="doctype"><tal:doctype tal:replace="structure string:&lt;!DOCTYPE html PUBLIC &quot;-//W3C//DTD XHTML 1.0 Transitional//EN&quot; &quot;http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd&quot;&gt;" /></metal:doctype>
73@@ -171,7 +173,7 @@
74
75 <tal:template>
76 <tal:comment
77- define="log modules/canonical.launchpad.webapp.adapter/summarize_requests"
78+ define="log modules/canonical.launchpad.webapp.adapter/summarize_requests;"
79 replace="structure string:&lt;!--
80 Facet name: ${view/menu:selectedfacetname}
81 Page type: ${view/macro:pagetype}
82@@ -181,6 +183,9 @@
83
84 At least ${log}
85
86+ Features: ${request/features/usedFlags}
87+ in scopes ${request/features/usedScopes}
88+
89 r${revno}
90 --&gt;" />
91 </tal:template>
92
93=== modified file 'lib/lp/services/features/__init__.py'
94--- lib/lp/services/features/__init__.py 2010-07-21 11:05:14 +0000
95+++ lib/lp/services/features/__init__.py 2010-08-17 02:31:17 +0000
96@@ -5,6 +5,25 @@
97
98 These can be turned on and off by admins, and can affect particular
99 defined scopes such as "beta users" or "production servers."
100-
101-See <https://dev.launchpad.net/LEP/FeatureFlags>
102-"""
103+"""
104+
105+import threading
106+
107+
108+__all__ = [
109+ 'getFeatureFlag',
110+ 'per_thread',
111+ ]
112+
113+
114+per_thread = threading.local()
115+"""Holds the default per-thread feature controller in its .features attribute.
116+
117+Framework code is responsible for setting this in the appropriate context, eg
118+when starting a web request.
119+"""
120+
121+
122+def getFeatureFlag(flag):
123+ """Get the value of a flag for this thread's scopes."""
124+ return per_thread.features.getFlag(flag)
125
126=== added file 'lib/lp/services/features/configure.zcml'
127--- lib/lp/services/features/configure.zcml 1970-01-01 00:00:00 +0000
128+++ lib/lp/services/features/configure.zcml 2010-08-17 02:31:17 +0000
129@@ -0,0 +1,19 @@
130+<!-- Copyright 2010 Canonical Ltd. This software is licensed under the
131+ GNU Affero General Public License version 3 (see the file LICENSE).
132+-->
133+
134+<configure
135+ xmlns="http://namespaces.zope.org/zope"
136+ xmlns:browser="http://namespaces.zope.org/browser">
137+
138+ <subscriber
139+ for="canonical.launchpad.webapp.interfaces.IStartRequestEvent"
140+ handler="lp.services.features.webapp.start_request"
141+ />
142+
143+ <subscriber
144+ for="zope.app.publication.interfaces.IEndRequestEvent"
145+ handler="lp.services.features.webapp.end_request"
146+ />
147+
148+</configure>
149
150=== added directory 'lib/lp/services/features/doc'
151=== added file 'lib/lp/services/features/doc/features.txt'
152--- lib/lp/services/features/doc/features.txt 1970-01-01 00:00:00 +0000
153+++ lib/lp/services/features/doc/features.txt 2010-08-17 02:31:17 +0000
154@@ -0,0 +1,127 @@
155+****************************
156+Feature Flag Developer Guide
157+****************************
158+
159+Introduction
160+************
161+
162+The point of feature flags is to let us turn some features of Launchpad on
163+and off without changing the code or restarting the application, and to
164+expose different features to different subsets of users.
165+
166+See <https://dev.launchpad.net/LEP/FeatureFlags> for more discussion and
167+rationale.
168+
169+The typical use for feature flags is within web page requests but they can
170+also be used in asynchronous jobs or apis or other parts of Launchpad.
171+
172+Internal model for feature flags
173+********************************
174+
175+A feature flag maps from a *name* to a *value*. The specific value used
176+for a particular request is determined by a set of zero or more *scopes*
177+that apply to that request, by finding the *rule* with the highest
178+*priority*.
179+
180+Flags are defined by a *name* that typically looks like a Python
181+identifier, for example ``notification.global.text``. A definition is
182+given for a particular *scope*, which also looks like a dotted identifier,
183+for example ``user.beta`` or ``server.edge``. This is just a naming
184+convention, and they do not need to correspond to Python modules.
185+
186+The value is stored in the database as just a Unicode string, and it might
187+be interpreted as a boolean, number, human-readable string or whatever.
188+
189+The default for flags is to be None if they're not set in the database, so
190+that should be a sensible baseline default state.
191+
192+Performance model
193+*****************
194+
195+Flags are supposed to be cheap enough that you can introduce them without
196+causing a performance concern.
197+
198+If the page does not check any flags, no extra work will be done. The
199+first time a page checks a flag, all the rules will be read from the
200+database and held in memory for the duration of the request.
201+
202+Scopes may be expensive in some cases, such as checking group membership.
203+Whether a scope is active or not is looked up the first time it's needed
204+within a particular request.
205+
206+The standard page footer identifies the flags and scopes that were
207+actually used by the page.
208+
209+Naming conventions
210+******************
211+
212+We have naming conventions for feature flags and scopes, so that people can
213+understand the likely impact of a particular flag and so they can find all
214+the flags likely to affect a feature.
215+
216+So for any flag we want to say:
217+
218+* What application area does this affect? (malone, survey, questions,
219+ code, etc)
220+
221+* What specific feature does it change?
222+
223+* What affect does it have on this feature? The most common is "enabled"
224+ but for some other we want to specify a specific value as well such as
225+ "date" or "size".
226+
227+These are concatenated with dots so the overall feature name looks a bit
228+like a Python module name.
229+
230+A similar approach is used for scopes.
231+
232+Checking flags in page templates
233+********************************
234+
235+You can conditionally show some text like this::
236+
237+ <tal:survey condition="features/user_survey.enabled">
238+ &nbsp;&bull;&nbsp;
239+ <a href="http://survey.example.com/">Take our survey!</a>
240+ </tal:survey>
241+
242+You can use the built-in TAL feature of prepending ``not:`` to the
243+condition, and for flags that have a value you could use them in
244+``tal:replace`` or ``tal:attributes``.
245+
246+If you just want to simply insert some text taken from a feature, say
247+something like::
248+
249+ Message of the day: ${motd.text}
250+
251+Templates can also check whether the request is in a particular scope, but
252+before using this consider whether the code will always be bound to that
253+scope or whether it would be more correct to define a new feature::
254+
255+ <p tal:condition="feature_scopes/server.staging">
256+ Staging server: all data will be discarded daily!</p>
257+
258+Checking flags in code
259+**********************
260+
261+The Zope traversal code establishes a `FeatureController` for the duration
262+of a request. The object can be obtained through either
263+`request.features` or `lp.services.features.per_thread.features`. This
264+provides various useful methods including `getFlag` to look up one feature
265+(memoized), and `isInScope` to check one scope (also memoized).
266+
267+As a convenience, `lp.services.features.getFeatureFlag` looks up a single
268+flag in the thread default controller.
269+
270+Debugging feature usage
271+***********************
272+
273+The flags active during a page request, and the scopes that were looked
274+up are visible in the comment at the bottom of every standard Launchpad
275+page.
276+
277+Defining scopes
278+***************
279+
280+
281+.. vim: ft=rst
282
283=== modified file 'lib/lp/services/features/flags.py'
284--- lib/lp/services/features/flags.py 2010-07-22 12:45:51 +0000
285+++ lib/lp/services/features/flags.py 2010-08-17 02:31:17 +0000
286@@ -1,17 +1,47 @@
287 # Copyright 2010 Canonical Ltd. This software is licensed under the
288 # GNU Affero General Public License version 3 (see the file LICENSE).
289
290-__all__ = ['FeatureController']
291+__all__ = [
292+ 'FeatureController',
293+ 'NullFeatureController',
294+ ]
295+
296
297 __metaclass__ = type
298
299
300+from storm.locals import Desc
301+
302 from lp.services.features.model import (
303+ FeatureFlag,
304 getFeatureStore,
305- FeatureFlag,
306 )
307
308
309+class Memoize(object):
310+
311+ def __init__(self, calc):
312+ self._known = {}
313+ self._calc = calc
314+
315+ def lookup(self, key):
316+ if key in self._known:
317+ return self._known[key]
318+ v = self._calc(key)
319+ self._known[key] = v
320+ return v
321+
322+
323+class ScopeDict(object):
324+ """Allow scopes to be looked up by getitem"""
325+
326+ def __init__(self, features):
327+ self.features = features
328+
329+ def __getitem__(self, scope_name):
330+ return self.features.isInScope(scope_name)
331+
332+
333 class FeatureController(object):
334 """A FeatureController tells application code what features are active.
335
336@@ -34,52 +64,101 @@
337 be one per web app request.
338
339 Intended performance: when this object is first constructed, it will read
340- the whole current feature flags from the database. This will take a few
341- ms. The controller is then supposed to be held in a thread-local for the
342- duration of the request.
343+ the whole feature flag table from the database. It is expected to be
344+ reasonably small. The scopes may be expensive to compute (eg checking
345+ team membership) so they are checked at most once when they are first
346+ needed.
347+
348+ The controller is then supposed to be held in a thread-local and reused
349+ for the duration of the request.
350
351 See <https://dev.launchpad.net/LEP/FeatureFlags>
352 """
353
354- def __init__(self, scopes):
355+ def __init__(self, scope_check_callback):
356 """Construct a new view of the features for a set of scopes.
357- """
358- self._store = getFeatureStore()
359- self._scopes = self._preenScopes(scopes)
360- self._cached_flags = self._queryAllFlags()
361-
362- def getScopes(self):
363- return frozenset(self._scopes)
364-
365- def getFlag(self, flag_name):
366- return self._cached_flags.get(flag_name)
367+
368+ :param scope_check_callback: Given a scope name, says whether
369+ it's active or not.
370+ """
371+ self._known_scopes = Memoize(scope_check_callback)
372+ self._known_flags = Memoize(self._checkFlag)
373+ # rules are read from the database the first time they're needed
374+ self._rules = None
375+ self.scopes = ScopeDict(self)
376+
377+ def getFlag(self, flag):
378+ """Get the value of a specific flag.
379+
380+ :param flag: A name to lookup. e.g. 'recipes.enabled'
381+ :return: The value of the flag determined by the highest priority rule
382+ that matched.
383+ """
384+ return self._known_flags.lookup(flag)
385+
386+ def _checkFlag(self, flag):
387+ self._needRules()
388+ if flag in self._rules:
389+ for scope, value in self._rules[flag]:
390+ if self._known_scopes.lookup(scope):
391+ return value
392+
393+ def isInScope(self, scope):
394+ return self._known_scopes.lookup(scope)
395+
396+ def __getitem__(self, flag_name):
397+ """FeatureController can be indexed.
398+
399+ This is to support easy zope traversal through eg
400+ "request/features/a.b.c". We don't support other collection
401+ protocols.
402+
403+ Note that calling this the first time for any key may cause
404+ arbitrarily large amounts of work to be done to determine if the
405+ controller is in any scopes relevant to this flag.
406+ """
407+ return self.getFlag(flag_name)
408
409 def getAllFlags(self):
410- """Get the feature flags active for the current scopes.
411+ """Return a dict of all active flags.
412
413- :returns: dict from flag_name (unicode) to value (unicode).
414+ This may be expensive because of evaluating many scopes, so it
415+ shouldn't normally be used by code that only wants to know about one
416+ or a few flags.
417 """
418- return dict(self._cached_flags)
419+ self._needRules()
420+ return dict((f, self.getFlag(f)) for f in self._rules)
421
422- def _queryAllFlags(self):
423+ def _loadRules(self):
424+ store = getFeatureStore()
425 d = {}
426- rs = (self._store
427- .find(FeatureFlag,
428- FeatureFlag.scope.is_in(self._scopes))
429- .order_by(FeatureFlag.priority)
430- .values(FeatureFlag.flag, FeatureFlag.value))
431- for flag, value in rs:
432- d[str(flag)] = value
433+ rs = (store
434+ .find(FeatureFlag)
435+ .order_by(Desc(FeatureFlag.priority))
436+ .values(FeatureFlag.flag, FeatureFlag.scope,
437+ FeatureFlag.value))
438+ for flag, scope, value in rs:
439+ d.setdefault(str(flag), []).append((str(scope), value))
440 return d
441
442- def _preenScopes(self, scopes):
443- # for convenience turn strings to unicode
444- us = []
445- for s in scopes:
446- if isinstance(s, unicode):
447- us.append(s)
448- elif isinstance(s, str):
449- us.append(unicode(s))
450- else:
451- raise TypeError("invalid scope: %r" % s)
452- return us
453+ def _needRules(self):
454+ if self._rules is None:
455+ self._rules = self._loadRules()
456+
457+ def usedFlags(self):
458+ """Return dict of flags used in this controller so far."""
459+ return dict(self._known_flags._known)
460+
461+ def usedScopes(self):
462+ """Return {scope: active} for scopes that have been used so far."""
463+ return dict(self._known_scopes._known)
464+
465+
466+class NullFeatureController(FeatureController):
467+ """For use in testing: everything is turned off"""
468+
469+ def __init__(self):
470+ FeatureController.__init__(self, lambda scope: None)
471+
472+ def _loadRules(self):
473+ return []
474
475=== modified file 'lib/lp/services/features/tests/test_flags.py'
476--- lib/lp/services/features/tests/test_flags.py 2010-07-22 12:45:51 +0000
477+++ lib/lp/services/features/tests/test_flags.py 2010-08-17 02:31:17 +0000
478@@ -12,7 +12,11 @@
479 from canonical.testing import layers
480 from lp.testing import TestCase
481
482-from lp.services.features import model
483+from lp.services.features import (
484+ getFeatureFlag,
485+ model,
486+ per_thread,
487+ )
488 from lp.services.features.flags import (
489 FeatureController,
490 )
491@@ -20,11 +24,10 @@
492
493 notification_name = 'notification.global.text'
494 notification_value = u'\N{SNOWMAN} stormy Launchpad weather ahead'
495-example_scope = 'beta_user'
496
497
498 testdata = [
499- (example_scope, notification_name, notification_value, 100),
500+ ('beta_user', notification_name, notification_value, 100),
501 ('default', 'ui.icing', u'3.0', 100),
502 ('beta_user', 'ui.icing', u'4.0', 300),
503 ]
504@@ -40,6 +43,14 @@
505 from storm.tracer import debug
506 debug(True)
507
508+ def makeControllerInScopes(self, scopes):
509+ """Make a controller that will report it's in the given scopes."""
510+ call_log = []
511+ def scope_cb(scope):
512+ call_log.append(scope)
513+ return scope in scopes
514+ return FeatureController(scope_cb), call_log
515+
516 def populateStore(self):
517 store = model.getFeatureStore()
518 for (scope, flag, value, priority) in testdata:
519@@ -49,37 +60,51 @@
520 value=value,
521 priority=priority))
522
523- def test_defaultFlags(self):
524- # the sample db has no flags set
525- control = FeatureController([])
526- self.assertEqual({},
527- control.getAllFlags())
528-
529 def test_getFlag(self):
530 self.populateStore()
531- control = FeatureController(['default'])
532+ control, call_log = self.makeControllerInScopes(['default'])
533 self.assertEqual(u'3.0',
534 control.getFlag('ui.icing'))
535+ self.assertEqual(['beta_user', 'default'], call_log)
536+
537+ def test_getItem(self):
538+ # for use in page templates, the flags can be treated as a dict
539+ self.populateStore()
540+ control, call_log = self.makeControllerInScopes(['default'])
541+ self.assertEqual(u'3.0',
542+ control['ui.icing'])
543+ self.assertEqual(['beta_user', 'default'], call_log)
544+ # after looking this up the value is known and the scopes are
545+ # positively and negatively cached
546+ self.assertEqual({'ui.icing': u'3.0'}, control.usedFlags())
547+ self.assertEqual(dict(beta_user=False, default=True),
548+ control.usedScopes())
549
550 def test_getAllFlags(self):
551 # can fetch all the active flags, and it gives back only the
552- # highest-priority settings
553+ # highest-priority settings. this may be expensive and shouldn't
554+ # normally be used.
555 self.populateStore()
556- control = FeatureController(['default', 'beta_user'])
557+ control, call_log = self.makeControllerInScopes(
558+ ['beta_user', 'default'])
559 self.assertEqual(
560 {'ui.icing': '4.0',
561 notification_name: notification_value},
562 control.getAllFlags())
563+ # evaluates all necessary flags; in this test data beta_user shadows
564+ # default settings
565+ self.assertEqual(['beta_user'], call_log)
566
567 def test_overrideFlag(self):
568 # if there are multiple settings for a flag, and they match multiple
569 # scopes, the priorities determine which is matched
570 self.populateStore()
571- default_control = FeatureController(['default'])
572+ default_control, call_log = self.makeControllerInScopes(['default'])
573 self.assertEqual(
574 u'3.0',
575 default_control.getFlag('ui.icing'))
576- beta_control = FeatureController(['default', 'beta_user'])
577+ beta_control, call_log = self.makeControllerInScopes(
578+ ['beta_user', 'default'])
579 self.assertEqual(
580 u'4.0',
581 beta_control.getFlag('ui.icing'))
582@@ -87,9 +112,53 @@
583 def test_undefinedFlag(self):
584 # if the flag is not defined, we get None
585 self.populateStore()
586- control = FeatureController(['default', 'beta_user'])
587+ control, call_log = self.makeControllerInScopes(
588+ ['beta_user', 'default'])
589 self.assertIs(None,
590 control.getFlag('unknown_flag'))
591- no_scope_flags = FeatureController([])
592+ no_scope_flags, call_log = self.makeControllerInScopes([])
593 self.assertIs(None,
594 no_scope_flags.getFlag('ui.icing'))
595+
596+ def test_threadGetFlag(self):
597+ self.populateStore()
598+ # the start-of-request handler will do something like this:
599+ per_thread.features, call_log = self.makeControllerInScopes(
600+ ['default', 'beta_user'])
601+ try:
602+ # then application code can simply ask without needing a context
603+ # object
604+ self.assertEqual(u'4.0', getFeatureFlag('ui.icing'))
605+ finally:
606+ per_thread.features = None
607+
608+ def testLazyScopeLookup(self):
609+ # feature scopes may be a bit expensive to look up, so we do it only
610+ # when it will make a difference to the result.
611+ self.populateStore()
612+ f, call_log = self.makeControllerInScopes(['beta_user'])
613+ self.assertEqual(u'4.0', f.getFlag('ui.icing'))
614+ # to calculate this it should only have had to check we're in the
615+ # beta_users scope; nothing else makes a difference
616+ self.assertEqual(dict(beta_user=True), f._known_scopes._known)
617+
618+ def testUnknownFeature(self):
619+ # looking up an unknown feature gives you None
620+ self.populateStore()
621+ f, call_log = self.makeControllerInScopes([])
622+ self.assertEqual(None, f.getFlag('unknown'))
623+ # no scopes need to be checked because it's just not in the database
624+ # and there's no point checking
625+ self.assertEqual({}, f._known_scopes._known)
626+ self.assertEquals([], call_log)
627+ # however, this we have now negative-cached the flag
628+ self.assertEqual(dict(unknown=None), f.usedFlags())
629+ self.assertEqual(dict(), f.usedScopes())
630+
631+ def testScopeDict(self):
632+ # can get scopes as a dict, for use by "feature_scopes/server.demo"
633+ f, call_log = self.makeControllerInScopes(['beta_user'])
634+ self.assertEqual(True, f.scopes['beta_user'])
635+ self.assertEqual(False, f.scopes['alpha_user'])
636+ self.assertEqual(True, f.scopes['beta_user'])
637+ self.assertEqual(['beta_user', 'alpha_user'], call_log)
638
639=== added file 'lib/lp/services/features/webapp.py'
640--- lib/lp/services/features/webapp.py 1970-01-01 00:00:00 +0000
641+++ lib/lp/services/features/webapp.py 2010-08-17 02:31:17 +0000
642@@ -0,0 +1,41 @@
643+# Copyright 2010 Canonical Ltd. This software is licensed under the
644+# GNU Affero General Public License version 3 (see the file LICENSE).
645+
646+"""Connect Feature flags into webapp requests."""
647+
648+__all__ = []
649+
650+__metaclass__ = type
651+
652+import canonical.config
653+
654+from lp.services.features import (
655+ per_thread,
656+ )
657+from lp.services.features.flags import (
658+ FeatureController,
659+ )
660+
661+
662+class ScopesFromRequest(object):
663+ """Identify feature scopes based on request state."""
664+
665+ def __init__(self, request):
666+ self._request = request
667+
668+ def lookup(self, scope_name):
669+ parts = scope_name.split('.')
670+ if len(parts) == 2:
671+ if parts[0] == 'server':
672+ return canonical.config.config['launchpad']['is_' + parts[1]]
673+
674+
675+def start_request(event):
676+ """Register FeatureController."""
677+ event.request.features = per_thread.features = FeatureController(
678+ ScopesFromRequest(event.request).lookup)
679+
680+
681+def end_request(event):
682+ """Done with this FeatureController."""
683+ event.request.features = per_thread.features = None