Merge lp:~benji/launchpad/bug-636193 into lp:launchpad

Proposed by Benji York
Status: Merged
Approved by: Benji York
Approved revision: no longer in the source branch.
Merged at revision: 12269
Proposed branch: lp:~benji/launchpad/bug-636193
Merge into: lp:launchpad
Diff against target: 838 lines (+388/-110)
11 files modified
lib/lp/services/features/browser/configure.zcml (+15/-3)
lib/lp/services/features/browser/info.py (+58/-0)
lib/lp/services/features/browser/tests/test_feature_info.py (+159/-0)
lib/lp/services/features/flags.py (+41/-5)
lib/lp/services/features/scopes.py (+0/-5)
lib/lp/services/features/templates/feature-info.pt (+75/-0)
lib/lp/services/features/templates/feature-rules.pt (+5/-2)
lib/lp/services/features/tests/test_webapp.py (+6/-0)
lib/lp/services/features/webapp.py (+1/-95)
lib/lp/services/tests/test_utils.py (+15/-0)
lib/lp/services/utils.py (+13/-0)
To merge this branch: bzr merge lp:~benji/launchpad/bug-636193
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
Guilherme Salgado (community) ui* Approve
Review via email: mp+46350@code.launchpad.net

Commit message

[r=lifeless][ui=salgado][bug=636193] add a listing of the current (documented) feature flags and scopes

Description of the change

Bug 636193 is about the need to know what feature flags and scopes are
available, what values are valid for them, and what they do.

To that end, I've added simple registries for feature flags and scopes.
However we still want adding feature flags to be lightweight, therefore
requiring documentation or registration for a feature flag before using
it is not desired.

The same does not go for scopes. They are added only rarely and asking
about a non-existent scope is more likely an error, therefore only known
scopes are allowed and an exception is raised when an unknown scope is
requested.

The pre-implementation discussion took place on bug 636193; the outcome
being comment #12 (https://bugs.launchpad.net/launchpad/+bug/636193/comments/12).

Because this effort was primarily a refactoring, relatively few new
tests were needed. All of the feature flag package's tests can be run
like so:

    bin/test c lp.services.features

The feature flags and scopes documentation can be seen at the
/+feature-info page. A link to that page was added to /+feature-rules
so it will be at hand when editing rules through the web.

The only lint output that persists is this:

    ./lib/lp/services/features/templates/feature-info.pt
        65: mismatched tag

...but that can't be helped. If I change the structure of the template
to appease lint, the page template machinery explodes. There can be
only one.

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

517 + # If we didn't find at least one matching handler, then the requested
518 + # scope is unknown and we want to alert the caller that they did
519 + # something wrong.
520 + if not found_a_handler:
521 + raise LookupError('Unknown scope: %r. This can result from a '
522 + 'typo or perhaps you need to create a new scope handler.'
523 + % (scope_name,))
524

This is a bug: its an operational requirement that the server not crash if a scope goes away during a deploy. Likewise we need to be able to add a rule with scope that isn't used yet but will become active on the next deploy. We can (and should) warn about unrecognised scopes in the +feature-rules page but thats a different discussion.

review: Needs Fixing
Revision history for this message
Benji York (benji) wrote :

I've addressed the issue Robert noted.

Revision history for this message
Guilherme Salgado (salgado) wrote :

The new page looks fine to me, and other than the link to the LEP suggested by Robert, I think it'd be great to have some examples of complete rules and maybe even user testing it by showing it to a dev and asking them to write a rule to, say, enable a feature X only for members of team Y.

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

So we discussed some possible improvements on IRC, but the key thing of meeting the constraints about deploys is fixed. Thanks!

review: Approve
Revision history for this message
Guilherme Salgado (salgado) wrote :

Benji, I'm still being mentored as a UI reviewer (that's what the * in the review type means), so my mentor (Curtis) should've had a look at this before you landed. Sorry if it wasn't clear. I've requested a review from him.

Revision history for this message
Benji York (benji) wrote :

On Thu, Jan 27, 2011 at 6:09 AM, Guilherme Salgado
<email address hidden> wrote:
> Benji, I'm still being mentored as a UI reviewer (that's what the * in the
> review type means), so my mentor (Curtis) should've had a look at this before
> you landed. Sorry if it wasn't clear.  I've requested a review from him

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/services/features/browser/configure.zcml'
2--- lib/lp/services/features/browser/configure.zcml 2010-11-08 13:45:59 +0000
3+++ lib/lp/services/features/browser/configure.zcml 2011-01-26 22:11:32 +0000
4@@ -11,9 +11,9 @@
5
6 <!-- View or edit all feature rules.
7
8- Readonly access is guarded by launchpad.Edit on ILaunchpadRoot, which
9- limits it to ~admins + ~registry, which are all trusted users. Write access
10- is for admins only.
11+ Readonly access is guarded by launchpad.Edit on ILaunchpadRoot, which
12+ limits it to ~admins + ~registry, which are all trusted users. Write
13+ access is for admins only.
14 -->
15 <browser:page
16 for="canonical.launchpad.webapp.interfaces.ILaunchpadRoot"
17@@ -22,4 +22,16 @@
18 permission="launchpad.Edit"
19 template="../templates/feature-rules.pt"/>
20
21+ <!-- View documentary info about the available feature flags.
22+
23+ Access is guarded by launchpad.Edit on ILaunchpadRoot, just like
24+ +feature-rules.
25+ -->
26+ <browser:page
27+ for="canonical.launchpad.webapp.interfaces.ILaunchpadRoot"
28+ class="lp.services.features.browser.info.FeatureInfoView"
29+ name="+feature-info"
30+ permission="launchpad.Edit"
31+ template="../templates/feature-info.pt"/>
32+
33 </configure>
34
35=== added file 'lib/lp/services/features/browser/info.py'
36--- lib/lp/services/features/browser/info.py 1970-01-01 00:00:00 +0000
37+++ lib/lp/services/features/browser/info.py 2011-01-26 22:11:32 +0000
38@@ -0,0 +1,58 @@
39+# Copyright 2011 Canonical Ltd. This software is licensed under the
40+# GNU Affero General Public License version 3 (see the file LICENSE).
41+
42+"""View and edit feature rules."""
43+
44+__metaclass__ = type
45+__all__ = [
46+ 'FeatureInfoView',
47+ ]
48+
49+
50+from collections import namedtuple
51+
52+from canonical.launchpad.webapp.publisher import LaunchpadView
53+from lp.services.features.flags import (
54+ flag_info,
55+ undocumented_flags,
56+ )
57+from lp.services.features.scopes import (
58+ HANDLERS,
59+ undocumented_scopes,
60+ )
61+from lp.services.utils import docstring_dedent
62+
63+
64+# Named tuples to use when passing flag and scope data to the template.
65+Flag = namedtuple('Flag', ('name', 'domain', 'description', 'default'))
66+Scope = namedtuple('Scope', ('regex', 'description'))
67+
68+
69+class FeatureInfoView(LaunchpadView):
70+ """Display feature flag documentation and other info."""
71+
72+ page_title = label = 'Feature flag info'
73+
74+ @property
75+ def flag_info(self):
76+ """A list of flags as named tuples, ready to be rendered."""
77+ return map(Flag._make, flag_info)
78+
79+ @property
80+ def undocumented_flags(self):
81+ """Flag names referenced during process lifetime but not documented.
82+ """
83+ return ', '.join(undocumented_flags)
84+
85+ @property
86+ def undocumented_scopes(self):
87+ """Scope names referenced during process lifetime but not documented.
88+ """
89+ return ', '.join(undocumented_scopes)
90+
91+ @property
92+ def scope_info(self):
93+ """A list of scopes as named tuples, ready to be rendered."""
94+ return [
95+ Scope._make((handler.pattern, docstring_dedent(handler.__doc__)))
96+ for handler in HANDLERS]
97
98=== added file 'lib/lp/services/features/browser/tests/test_feature_info.py'
99--- lib/lp/services/features/browser/tests/test_feature_info.py 1970-01-01 00:00:00 +0000
100+++ lib/lp/services/features/browser/tests/test_feature_info.py 2011-01-26 22:11:32 +0000
101@@ -0,0 +1,159 @@
102+# Copyright 2011 Canonical Ltd. This software is licensed under the
103+# GNU Affero General Public License version 3 (see the file LICENSE).
104+
105+"""Tests for feature rule editor"""
106+
107+__metaclass__ = type
108+
109+
110+from testtools.matchers import Not
111+from zope.component import getUtility
112+from zope.security.interfaces import Unauthorized
113+
114+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
115+from canonical.launchpad.webapp import canonical_url
116+from canonical.launchpad.webapp.interfaces import ILaunchpadRoot
117+from canonical.testing.layers import DatabaseFunctionalLayer
118+from lp.services.features.flags import (
119+ documented_flags,
120+ flag_info,
121+ NullFeatureController,
122+ undocumented_flags,
123+ )
124+from lp.services.features.scopes import (
125+ HANDLERS,
126+ undocumented_scopes,
127+ )
128+from lp.testing import (
129+ BrowserTestCase,
130+ person_logged_in,
131+ TestCase,
132+ )
133+from lp.testing.matchers import Contains
134+
135+
136+class TestFeatureControlPage(BrowserTestCase):
137+
138+ layer = DatabaseFunctionalLayer
139+
140+ def getFeatureInfoUrl(self):
141+ """Find the URL to the feature info page."""
142+ root = getUtility(ILaunchpadRoot)
143+ return canonical_url(root, view_name='+feature-info')
144+
145+ def getUserBrowserAsAdmin(self):
146+ """Make a new TestBrowser logged in as an admin user."""
147+ url = self.getFeatureInfoUrl()
148+ admin_team = getUtility(ILaunchpadCelebrities).admin
149+ return self.getUserBrowserAsTeamMember([admin_team])
150+
151+ def getUserBrowserAsTeamMember(self, teams):
152+ """Make a TestBrowser authenticated as a team member."""
153+ # XXX Martin Pool 2010-09-23 bug=646563: To make a UserBrowser, you
154+ # must know the password; we can't get the password for an existing
155+ # user so we have to make a new one.
156+ user = self.factory.makePerson(password='test')
157+ for team in teams:
158+ with person_logged_in(team.teamowner):
159+ team.addMember(user, reviewer=team.teamowner)
160+ return self.getUserBrowser(url=None, user=user, password='test')
161+
162+ def test_feature_documentation_displayed(self):
163+ """The feature flag documentation is displayed on the page."""
164+ browser = self.getUserBrowserAsAdmin()
165+ browser.open(self.getFeatureInfoUrl())
166+ for record in flag_info:
167+ for item in record:
168+ self.assertThat(browser.contents, Contains(item))
169+
170+ def test_scope_documentation_displayed(self):
171+ """The scope documentation is displayed on the page."""
172+ browser = self.getUserBrowserAsAdmin()
173+ browser.open(self.getFeatureInfoUrl())
174+ for pattern in [handler.pattern for handler in HANDLERS]:
175+ self.assertThat(browser.contents, Contains(pattern))
176+
177+ def test_undocumented_features_displayed(self):
178+ """The undocumented feature flag names are displayed on the page."""
179+ browser = self.getUserBrowserAsAdmin()
180+ # Stash away any already encountered undocumented flags.
181+ saved_undocumented = undocumented_flags.copy()
182+ undocumented_flags.clear()
183+ undocumented_flags.update(['first', 'second'])
184+ browser.open(self.getFeatureInfoUrl())
185+ # Put the saved undocumented flags back.
186+ undocumented_flags.clear()
187+ undocumented_flags.update(saved_undocumented)
188+ # Are the (injected) undocumented flags shown in the page?
189+ self.assertThat(browser.contents, Contains('first'))
190+ self.assertThat(browser.contents, Contains('second'))
191+
192+ def test_undocumented_scope_displayed(self):
193+ """The undocumented scope names are displayed on the page."""
194+ browser = self.getUserBrowserAsAdmin()
195+ # Stash away any already encountered undocumented scopes.
196+ saved_undocumented = undocumented_scopes.copy()
197+ undocumented_scopes.clear()
198+ undocumented_scopes.update(['first', 'second'])
199+ browser.open(self.getFeatureInfoUrl())
200+ # Put the saved undocumented scopes back.
201+ undocumented_scopes.clear()
202+ undocumented_scopes.update(saved_undocumented)
203+ # Are the (injected) undocumented scopes shown in the page?
204+ self.assertThat(browser.contents, Contains('first'))
205+ self.assertThat(browser.contents, Contains('second'))
206+
207+ def test_feature_info_anonymous_unauthorized(self):
208+ """Anonymous users can not view the feature flag info page."""
209+ browser = self.getUserBrowser()
210+ self.assertRaises(Unauthorized,
211+ browser.open,
212+ self.getFeatureInfoUrl())
213+
214+ def test_feature_rules_plebian_unauthorized(self):
215+ """Unauthorized logged-in users can't view the info page."""
216+ browser = self.getUserBrowserAsTeamMember([])
217+ self.assertRaises(Unauthorized,
218+ browser.open,
219+ self.getFeatureInfoUrl())
220+
221+
222+class TestUndocumentedFeatureFlags(TestCase):
223+ """Test the code that records accessing of undocumented feature flags."""
224+
225+ def setUp(self):
226+ super(TestUndocumentedFeatureFlags, self).setUp()
227+ # Stash away any already encountered undocumented flags.
228+ saved_undocumented = undocumented_flags.copy()
229+ saved_documented = documented_flags.copy()
230+ undocumented_flags.clear()
231+ documented_flags.clear()
232+
233+ def clean_up_undocumented_flags():
234+ # Put the saved undocumented flags back.
235+ undocumented_flags.clear()
236+ documented_flags.clear()
237+ undocumented_flags.update(saved_undocumented)
238+ documented_flags.update(saved_documented)
239+
240+ self.addCleanup(clean_up_undocumented_flags)
241+
242+ def test_reading_undocumented_feature_flags(self):
243+ """Reading undocumented feature flags records them as undocumented."""
244+ controller = NullFeatureController()
245+ # This test assumes there is no flag named "does-not-exist".
246+ assert 'does-not-exist' not in documented_flags
247+ controller.getFlag('does-not-exist')
248+ self.assertThat(undocumented_flags, Contains('does-not-exist'))
249+
250+ def test_reading_documented_feature_flags(self):
251+ """Reading documented flags does not record them as undocumented."""
252+ controller = NullFeatureController()
253+ # Make sure there is no flag named "documented-flag-name" before we
254+ # start testing.
255+ assert 'documented-flag-name' not in documented_flags
256+ documented_flags.update(['documented-flag-name'])
257+ controller.getFlag('documented-flag-name')
258+ self.assertThat(
259+ undocumented_flags,
260+ Not(Contains('documented-flag-name')))
261
262=== modified file 'lib/lp/services/features/flags.py'
263--- lib/lp/services/features/flags.py 2010-10-02 10:51:03 +0000
264+++ lib/lp/services/features/flags.py 2011-01-26 22:11:32 +0000
265@@ -3,7 +3,9 @@
266
267 __all__ = [
268 'FeatureController',
269+ 'flag_info',
270 'NullFeatureController',
271+ 'undocumented_flags',
272 ]
273
274
275@@ -15,8 +17,39 @@
276
277 __metaclass__ = type
278
279-
280-class Memoize(object):
281+# This table of flag name, value domain, and prose documentation is used to
282+# generate the web-visible feature flag documentation.
283+flag_info = sorted([
284+ ('code.recipes_enabled',
285+ '[on|off]',
286+ 'enable recipes',
287+ 'off'),
288+ ('hard_timeout',
289+ 'float',
290+ 'sets the hard timeout in seconds',
291+ ''),
292+ ('malone.advanced-subscriptions.enabled',
293+ '[on|off]',
294+ 'enables advanced subscriptions features',
295+ 'off'),
296+ ('memcache',
297+ '[enabled|disabled]',
298+ 'enables/disables memcache',
299+ 'enabled'),
300+ ('publicrestrictedlibrarian',
301+ '[on|off]',
302+ 'redirect to private URLs instead of proxying',
303+ 'off'),
304+ ])
305+
306+# The set of all flag names that are documented.
307+documented_flags = set(info[0] for info in flag_info)
308+# The set of all the flags names that have been used during the process
309+# lifetime, but were not documented in flag_info.
310+undocumented_flags = set()
311+
312+
313+class Memoize():
314
315 def __init__(self, calc):
316 self._known = {}
317@@ -30,7 +63,7 @@
318 return v
319
320
321-class ScopeDict(object):
322+class ScopeDict():
323 """Allow scopes to be looked up by getitem"""
324
325 def __init__(self, features):
326@@ -40,7 +73,7 @@
327 return self.features.isInScope(scope_name)
328
329
330-class FeatureController(object):
331+class FeatureController():
332 """A FeatureController tells application code what features are active.
333
334 It does this by meshing together two sources of data:
335@@ -94,12 +127,15 @@
336
337 def getFlag(self, flag):
338 """Get the value of a specific flag.
339-
340+
341 :param flag: A name to lookup. e.g. 'recipes.enabled'
342
343 :return: The value of the flag determined by the highest priority rule
344 that matched.
345 """
346+ # If this is an undocumented flag, record it.
347+ if flag not in documented_flags:
348+ undocumented_flags.add(flag)
349 return self._known_flags.lookup(flag)
350
351 def _checkFlag(self, flag):
352
353=== added file 'lib/lp/services/features/scopes.py'
354--- lib/lp/services/features/scopes.py 1970-01-01 00:00:00 +0000
355+++ lib/lp/services/features/scopes.py 2011-01-26 22:11:32 +0000
356@@ -0,0 +1,177 @@
357+# Copyright 2011 Canonical Ltd. This software is licensed under the
358+# GNU Affero General Public License version 3 (see the file LICENSE).
359+
360+"""Connect Feature flags into webapp requests."""
361+
362+__all__ = [
363+ 'HANDLERS',
364+ 'ScopesFromRequest',
365+ 'undocumented_scopes',
366+ ]
367+
368+__metaclass__ = type
369+
370+import re
371+
372+from zope.component import getUtility
373+
374+from canonical.launchpad.webapp.interfaces import ILaunchBag
375+from lp.services.propertycache import cachedproperty
376+import canonical.config
377+
378+
379+undocumented_scopes = set()
380+
381+
382+class BaseScope():
383+ """A base class for scope handlers.
384+
385+ The docstring of subclasses is used on the +feature-info page as
386+ documentation, so write them accordingly.
387+ """
388+
389+ # The regex pattern used to decide if a handler can evalute a particular
390+ # scope. Also used on +feature-info.
391+ pattern = None
392+
393+ def __init__(self, request):
394+ self.request = request
395+
396+ @cachedproperty
397+ def compiled_pattern(self):
398+ """The compiled scope matching regex. A small optimization."""
399+ return re.compile(self.pattern)
400+
401+ def lookup(self, scope_name):
402+ """Returns true if the given scope name is "active"."""
403+ raise NotImplementedError('Subclasses of BaseScope must implement '
404+ 'lookup.')
405+
406+
407+class DefaultScope(BaseScope):
408+ """The default scope. Always active."""
409+
410+ pattern = r'default$'
411+
412+ def lookup(self, scope_name):
413+ return True
414+
415+
416+class PageScope(BaseScope):
417+ """The current page ID.
418+
419+ Pageid scopes are written as 'pageid:' + the pageid to match. Pageids
420+ are treated as a namespace with : and # delimiters.
421+
422+ For example, the scope 'pageid:Foo' will be active on pages with pageids:
423+ Foo
424+ Foo:Bar
425+ Foo#quux
426+ """
427+
428+ pattern = r'pageid:'
429+
430+ def lookup(self, scope_name):
431+ """Is the given scope match the current pageid?"""
432+ pageid_scope = scope_name[len('pageid:'):]
433+ scope_segments = self._pageid_to_namespace(pageid_scope)
434+ request_segments = self._request_pageid_namespace
435+ # In 2.6, this can be replaced with izip_longest
436+ for pos, name in enumerate(scope_segments):
437+ if pos == len(request_segments):
438+ return False
439+ if request_segments[pos] != name:
440+ return False
441+ return True
442+
443+ @staticmethod
444+ def _pageid_to_namespace(pageid):
445+ """Return a list of namespace elements for pageid."""
446+ # Normalise delimiters.
447+ pageid = pageid.replace('#', ':')
448+ # Create a list to walk, empty namespaces are elided.
449+ return [name for name in pageid.split(':') if name]
450+
451+ @cachedproperty
452+ def _request_pageid_namespace(self):
453+ return tuple(self._pageid_to_namespace(
454+ self.request._orig_env.get('launchpad.pageid', '')))
455+
456+
457+class TeamScope(BaseScope):
458+ """The current user's team memberships.
459+
460+ Team ID scopes are written as 'team:' + the team name to match.
461+
462+ The scope 'team:launchpad-beta-users' will match members of the team
463+ 'launchpad-beta-users'.
464+ """
465+
466+ pattern = r'team:'
467+
468+ def lookup(self, scope_name):
469+ """Is the given scope a team membership?
470+
471+ This will do a two queries, so we probably want to keep the number of
472+ team based scopes in use to a small number. (Person.inTeam could be
473+ fixed to reduce this to one query).
474+ """
475+ team_name = scope_name[len('team:'):]
476+ person = getUtility(ILaunchBag).user
477+ if person is None:
478+ return False
479+ return person.inTeam(team_name)
480+
481+
482+class ServerScope(BaseScope):
483+ """Matches the current server.
484+
485+ For example, the scope server.lpnet is active when is_lpnet is set to True
486+ in the Launchpad configuration.
487+ """
488+
489+ pattern = r'server\.'
490+
491+ def lookup(self, scope_name):
492+ """Match the current server as a scope."""
493+ server_name = scope_name.split('.', 1)[1]
494+ try:
495+ return canonical.config.config['launchpad']['is_' + server_name]
496+ except KeyError:
497+ pass
498+ return False
499+
500+
501+# These are the handlers for all of the allowable scopes. Any new scope will
502+# need a scope handler and that scope handler has to be added to this list.
503+# See BaseScope for hints as to what a scope handler should look like.
504+HANDLERS = [DefaultScope, PageScope, TeamScope, ServerScope]
505+
506+
507+class ScopesFromRequest():
508+ """Identify feature scopes based on request state."""
509+
510+ def __init__(self, request):
511+ self.request = request
512+ self.handlers = [f(request) for f in HANDLERS]
513+
514+ def lookup(self, scope_name):
515+ """Determine if scope_name applies to this request.
516+
517+ This method iterates over the configured scope hanlders until it
518+ either finds one that claims the requested scope name is a match for
519+ the current request or the handlers are exhuasted, in which case the
520+ scope name is not a match.
521+ """
522+ found_a_handler = False
523+ for handler in self.handlers:
524+ if handler.compiled_pattern.match(scope_name):
525+ found_a_handler = True
526+ if handler.lookup(scope_name):
527+ return True
528+
529+ # If we didn't find at least one matching handler, then the requested
530+ # scope is unknown and we want to record the scope for the +flag-info page to display.
531+ if not found_a_handler:
532+ undocumented_scopes.add(scope_name)
533+
534
535=== removed file 'lib/lp/services/features/scopes.py'
536--- lib/lp/services/features/scopes.py 2010-07-21 11:05:14 +0000
537+++ lib/lp/services/features/scopes.py 1970-01-01 00:00:00 +0000
538@@ -1,5 +0,0 @@
539-# Copyright 2010 Canonical Ltd. This software is licensed under the
540-# GNU Affero General Public License version 3 (see the file LICENSE).
541-
542-"""Look up current feature flags.
543-"""
544
545=== added file 'lib/lp/services/features/templates/feature-info.pt'
546--- lib/lp/services/features/templates/feature-info.pt 1970-01-01 00:00:00 +0000
547+++ lib/lp/services/features/templates/feature-info.pt 2011-01-26 22:11:32 +0000
548@@ -0,0 +1,75 @@
549+<html
550+ xmlns="http://www.w3.org/1999/xhtml"
551+ xmlns:tal="http://xml.zope.org/namespaces/tal"
552+ xmlns:metal="http://xml.zope.org/namespaces/metal"
553+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
554+ metal:use-macro="view/macro:page/main_only"
555+ i18n:domain="launchpad"
556+ tal:define="page_title string:features;">
557+
558+<body metal:fill-slot="main">
559+
560+ <h2>Documented flags</h2>
561+ <table class="listing">
562+ <thead>
563+ <tr>
564+ <th>Name</th>
565+ <th>Value domain</th>
566+ <th>Default value</th>
567+ <th>Description</th>
568+ </tr>
569+ </thead>
570+ <tbody>
571+ <tr tal:repeat="info view/flag_info">
572+ <td tal:content="info/name">flag name here</td>
573+ <td tal:content="info/domain">flag domain here</td>
574+ <td tal:content="info/default">flag description here</td>
575+ <td tal:content="info/description">flag description here</td>
576+ </tr>
577+ </tbody>
578+ </table>
579+
580+ <p>
581+ <h2>Undocumented flags</h2>
582+ These flags were referenced during this process' lifetime but are not
583+ documented:
584+ <strong tal:condition="not:view/undocumented_flags">
585+ No undocumented feature flags have been used yet.
586+ </strong>
587+ <strong tal:content="view/undocumented_flags">list of flags</strong>
588+
589+ <p>
590+ <h2>Scopes</h2>
591+
592+ The table below describes the currently available scopes. The first column
593+ gives the regular expression the scope matches (for example, the
594+ "pageid:foo" scopes match the regex "pageid:") and the second gives a
595+ description of when the scope is active.
596+
597+ <p>
598+ <table class="listing">
599+ <thead>
600+ <tr>
601+ <th>Form (a regex)</th>
602+ <th>Description</th>
603+ </tr>
604+ </thead>
605+ <tbody>
606+ <tr tal:repeat="info view/scope_info">
607+ <td tal:content="info/regex">scope regex here</td>
608+ <td><pre tal:content="info/description">scope description here</pre></td>
609+ </tr>
610+ </tbody>
611+ </table>
612+
613+ <p>
614+ <h2>Undocumented scopes</h2>
615+ These scopes were referenced during this process' lifetime but are not
616+ documented:
617+ <strong tal:condition="not:view/undocumented_scopes">
618+ No undocumented scopes have been used yet.
619+ </strong>
620+ <strong tal:content="view/undocumented_scopes">list of scopes</strong>
621+
622+</body>
623+</html>
624
625=== modified file 'lib/lp/services/features/templates/feature-rules.pt'
626--- lib/lp/services/features/templates/feature-rules.pt 2011-01-05 21:50:00 +0000
627+++ lib/lp/services/features/templates/feature-rules.pt 2011-01-26 22:11:32 +0000
628@@ -11,12 +11,15 @@
629
630 <div metal:fill-slot="main">
631 <div metal:use-macro="context/@@launchpad_form/form">
632- <div metal:fill-slot="extra_top"
633- tal:condition="view/diff">
634+ <div metal:fill-slot="extra_top">
635+ <div tal:condition="view/diff">
636 <p>Your changes have been applied (and before and after values of the
637 rules logged by the <tal:logger replace="view/logger_name"/> logger):
638 </p>
639 <tal:diff replace="structure view/diff"/>
640+ </div>
641+ For more information about the available feature flags and scopes see
642+ the <a href="+feature-info">feature flag info</a>.
643 </div>
644 </div>
645 </div>
646
647=== modified file 'lib/lp/services/features/tests/test_webapp.py'
648--- lib/lp/services/features/tests/test_webapp.py 2010-12-15 06:06:06 +0000
649+++ lib/lp/services/features/tests/test_webapp.py 2011-01-26 22:11:32 +0000
650@@ -73,6 +73,12 @@
651 # There is no such key in the config, so this returns False.
652 self.assertFalse(scopes.lookup('server.pink'))
653
654+ def test_unknown_scope(self):
655+ # Asking about an unknown scope is not an error.
656+ request = LaunchpadTestRequest()
657+ scopes = webapp.ScopesFromRequest(request)
658+ scopes.lookup('not-a-real-scope')
659+
660
661 class TestDBScopes(TestCaseWithFactory):
662
663
664=== modified file 'lib/lp/services/features/webapp.py'
665--- lib/lp/services/features/webapp.py 2010-12-09 10:18:51 +0000
666+++ lib/lp/services/features/webapp.py 2011-01-26 22:11:32 +0000
667@@ -7,104 +7,10 @@
668
669 __metaclass__ = type
670
671-from zope.component import getUtility
672-
673-import canonical.config
674-from canonical.launchpad.webapp.interfaces import ILaunchBag
675 from lp.services.features import per_thread
676 from lp.services.features.flags import FeatureController
677 from lp.services.features.rulesource import StormFeatureRuleSource
678-from lp.services.propertycache import cachedproperty
679-
680-
681-class ScopesFromRequest(object):
682- """Identify feature scopes based on request state."""
683-
684- def __init__(self, request):
685- self._request = request
686-
687- def lookup(self, scope_name):
688- """Determine if scope_name applies to this request.
689-
690- Currently supports the following scopes:
691- - default
692- - server.lpnet etc (thunks through to the config is_lpnet)
693- - pageid:
694- This scope works on a namespace model: for a page
695- with pageid SomeType:+view#subselector
696- The following page ids scopes will match:
697- - pageid: (but use 'default' as it is simpler)
698- - pageid:SomeType
699- - pageid:SomeType:+view
700- - pageid:SomeType:+view#subselector
701- - team:
702- This scope looks up a team. For instance
703- - team:launchpad-beta-users
704- """
705- if scope_name == 'default':
706- return True
707- if scope_name.startswith('pageid:'):
708- return self._lookup_pageid(scope_name[len('pageid:'):])
709- if scope_name.startswith('team:'):
710- return self._lookup_team(scope_name[len('team:'):])
711- parts = scope_name.split('.')
712- if len(parts) == 2:
713- if parts[0] == 'server':
714- try:
715- return canonical.config.config['launchpad'][
716- 'is_' + parts[1]]
717- except KeyError:
718- return False
719-
720- def _lookup_pageid(self, pageid_scope):
721- """Lookup a pageid as a scope.
722-
723- pageid scopes are written as 'pageid:' + the pageid to match.
724- Page ids are treated as a namespace with : and # delimiters.
725-
726- E.g. the scope 'pageid:Foo' will affect pages with pageids:
727- Foo
728- Foo:Bar
729- Foo#quux
730- """
731- scope_segments = self._pageid_to_namespace(pageid_scope)
732- request_segments = self._request_pageid_namespace
733- # In 2.6, this can be replaced with izip_longest
734- for pos, name in enumerate(scope_segments):
735- if pos == len(request_segments):
736- return False
737- if request_segments[pos] != name:
738- return False
739- return True
740-
741- def _lookup_team(self, team_name):
742- """Lookup a team membership as a scope.
743-
744- This will do a two queries, so we probably want to keep the number of
745- team based scopes in use to a small number. (Person.inTeam could be
746- fixed to reduce this to one query).
747-
748- teamid scopes are written as 'team:' + the team name to match.
749-
750- E.g. the scope 'team:launchpad-beta-users' will match members of
751- the team 'launchpad-beta-users'.
752- """
753- person = getUtility(ILaunchBag).user
754- if person is None:
755- return False
756- return person.inTeam(team_name)
757-
758- def _pageid_to_namespace(self, pageid):
759- """Return a list of namespace elements for pageid."""
760- # Normalise delimiters.
761- pageid = pageid.replace('#', ':')
762- # Create a list to walk, empty namespaces are elided.
763- return [name for name in pageid.split(':') if name]
764-
765- @cachedproperty
766- def _request_pageid_namespace(self):
767- return tuple(self._pageid_to_namespace(
768- self._request._orig_env.get('launchpad.pageid', '')))
769+from lp.services.features.scopes import ScopesFromRequest
770
771
772 def start_request(event):
773
774=== modified file 'lib/lp/services/tests/test_utils.py'
775--- lib/lp/services/tests/test_utils.py 2010-07-19 15:58:46 +0000
776+++ lib/lp/services/tests/test_utils.py 2011-01-26 22:11:32 +0000
777@@ -12,6 +12,7 @@
778 from lp.services.utils import (
779 CachingIterator,
780 decorate_with,
781+ docstring_dedent,
782 iter_split,
783 )
784 from lp.testing import TestCase
785@@ -140,5 +141,19 @@
786 self.assertEqual(arbitrary_value, result)
787
788
789+class TestDocstringDedent(TestCase):
790+ """Tests for `docstring_dedent`."""
791+
792+ def test_single_line(self):
793+ self.assertEqual(docstring_dedent('docstring'), 'docstring')
794+
795+ def test_multi_line(self):
796+ docstring = """This is a multiline docstring.
797+
798+ This is the second line.
799+ """
800+ result = 'This is a multiline docstring.\n\nThis is the second line.'
801+ self.assertEqual(docstring_dedent(docstring), result)
802+
803 def test_suite():
804 return unittest.TestLoader().loadTestsFromName(__name__)
805
806=== modified file 'lib/lp/services/utils.py'
807--- lib/lp/services/utils.py 2010-10-26 15:47:24 +0000
808+++ lib/lp/services/utils.py 2011-01-26 22:11:32 +0000
809@@ -11,12 +11,14 @@
810 __all__ = [
811 'CachingIterator',
812 'decorate_with',
813+ 'docstring_dedent',
814 'iter_split',
815 'synchronize',
816 'text_delta',
817 'value_string',
818 ]
819
820+from textwrap import dedent
821 import itertools
822
823 from lazr.enum import BaseItem
824@@ -153,3 +155,14 @@
825 return function(*a, **kw)
826 return mergeFunctionMetadata(function, decorated)
827 return decorator
828+
829+
830+def docstring_dedent(s):
831+ """Remove leading indentation from a doc string.
832+
833+ Since the first line doesn't have indentation, split it off, dedent, and
834+ then reassemble.
835+ """
836+ # Make sure there is at least one newline so the split works.
837+ first, rest = (s+'\n').split('\n', 1)
838+ return (first + '\n' + dedent(rest)).strip()