Merge lp:~benji/launchpad/bug-636193 into lp:launchpad
- bug-636193
- Merge into devel
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 | ||||
Related bugs: |
|
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]
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:/
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.
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/
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.
Benji York (benji) wrote : | # |
I've addressed the issue Robert noted.
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.
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!
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.
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
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() |
517 + # If we didn't find at least one matching handler, then the requested 'Unknown scope: %r. This can result from a '
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(
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.