Merge lp:~mbp/launchpad/flags-webapp 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: 9652
Proposed branch: lp:~mbp/launchpad/flags-webapp
Merge into: lp:launchpad/db-devel
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
Jelmer Vernooij Pending
Review via email: mp+31122@code.launchpad.net

Commit message

set feature flags per web request

Description of the change

Further development of feature flags:

 * register a FeatureController for the duration of a web request
 * log active features and scopes at the bottom of the base page template
 * add per_thread FeatureController
 * add 'features' and 'feature_scopes' macros for page templates
 * developer documentation for feature flags
 * feature flags only run their query when they are first accessed, and only look up scopes when necessary, in case the scope checks are expensive

Depends on feature changes not yet merged to devel, therefore targeted to db-devel.

Follows on from https://code.edge.launchpad.net/~mbp/launchpad/flags/+merge/30581

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

+ but for some other we want to specify a specific value as well such as

Would read better as 'for some we want' or 'for some others we want'.

You have some new methods which are pretty trivial without docstrings. I think its usually worth putting a docstring in - I generally find I need one myself 3/4 months later :).

+ def makeControllerInScopes(self, scopes):
+ """Make a controller that will report it's in the given scopes.
+ """
+ call_log = []
+
+ def scope_cb(scope):
+ call_log.append(scope)
+ return scope in scopes
+ return FeatureController(scope_cb), call_log

That might work better as a decorator or something. E.g. logging_controller = LoggingFeatureController()
log = logging_controller.log

If you want to rearrange it along those lines, do so and land without another review.

Some future stuff that we will want to test if its not already is handling of scope resolution that barfs - e.g. a team that is deleted or what have you : the loose datamodel of flags requires handling of lookup failures : that is probably a per-scope-type callback responsibility though.

Thanks,
Rob

review: Approve
Revision history for this message
Robert Collins (lifeless) wrote :

I've done a few tidyups based on my review, am making a new mp and ec2ing it now.

Revision history for this message
Martin Pool (mbp) wrote :

r9599 passed a full 'make check' in a virtual machine, so it should be ok to land when pqm reopens.

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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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-10 06:41:21 +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

Subscribers

People subscribed via source and target branches

to status/vote changes: