Merge lp:~jtv/launchpad/bug-735319 into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 12632
Proposed branch: lp:~jtv/launchpad/bug-735319
Merge into: lp:launchpad
Diff against target: 728 lines (+297/-82)
14 files modified
lib/canonical/launchpad/doc/librarian.txt (+26/-23)
lib/canonical/launchpad/doc/timeout.txt (+8/-7)
lib/canonical/launchpad/webapp/publication.py (+1/-1)
lib/lp/bugs/browser/tests/test_bugattachment_file_access.py (+6/-6)
lib/lp/registry/browser/tests/test_series_views.py (+4/-7)
lib/lp/services/features/__init__.py (+21/-2)
lib/lp/services/features/scopes.py (+65/-17)
lib/lp/services/features/testing.py (+8/-5)
lib/lp/services/features/tests/test_flags.py (+5/-4)
lib/lp/services/features/tests/test_scopes.py (+67/-0)
lib/lp/services/features/webapp.py (+6/-4)
lib/lp/services/scripts/base.py (+12/-0)
lib/lp/services/scripts/tests/test_feature_controller.py (+61/-0)
lib/lp/testing/__init__.py (+7/-6)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-735319
Reviewer Review Type Date Requested Status
Martin Pool (community) code* Approve
Review via email: mp+53578@code.launchpad.net

Commit message

[r=mbp][bug=735319] Let scripts access feature flags.

Description of the change

= Summary =

Bug 735319: scripts can't read feature flags. They get None values instead, which are conventionally interpreted as "false" in the case of boolean flags.

This leads to surprising bugs.

== Proposed fix ==

Set up a feature controller in LaunchpadScript.

== Pre-implementation notes ==

According to lifeless, there is no particular plan behind the way various pieces of the code get or set the applicable feature controller by reading or assigning lp.services.features.per_thread.features, or using getattr/setattr on it, so I cleaned that up. It's strictly single-setter/single-getter now.

== Implementation details ==

ScopesFromRequest is mostly independent of the webapp. Most of its work goes into finding a scope handler that matches a rule's sope spec, and then feeding the scope spec to that handler. I abstracted that out into a base class MultiScopeHandler, with separate pieces of code for "find matching scope handler" and "look up scope in matching handlers."

ScopesFromRequest now inherits from that new class, and a new sibling ScopesForSCript does a similar job for scripts. These are really just factories for the base class now, but I couldn't be bothered to make the name change. A script is identified by its "name" property.

LaunchpadScript now sets up a feature handler, which means that all our scripts will magically be able to read feature flags now.

You also get a few free bonuses with this branch:

Cleanup. The applicable feature controller is set as lp.services.features.per_thread.features. Many different modules read, assign, getattr, and/or setattr this variable, even though we aren't even quite sure yet where it should live. So I concentrated all access in a single setter, plus a getter that we had already. The per_thread variable is no longer exposed.

Script scope. You can now write rules that set feature flags in the scope of a particular script, using "script:<script-name>."

Override switch. Every LaunchpadScript now has a "script_disabled" flag that stops the script from executing. This should be easier and faster to manage than the existing lazr configuration or messing with cron tabs. It also puts some control closer to the engineering team.

I hope somebody will feel inspired to add a "verbosity" feature flag for the scripts as well. It'd be nice to minimize the menial jobs we bother the LOSAs for.

== Tests ==

{{{
./bin/test -vvc lp.services.features
}}}

== Demo and Q/A ==

Feature flags in the web app should still work. But also, we'll be able to disable a script of our choice (cron or plain) and it will exit quickly (well, not counting the tedious ZCML processing on startup) with a message about it being disabled by feature flag.

= Launchpad lint =

I cleaned up a lot, and in fact that's probably the first thing you'll see in the diff.

I didn't also want to change all the section headings though: too much work, too much diff, and I'd probably use the wrong ReST underlines anyway.

Then there's the unused imports. I ought to have looked up if any of those could be eliminated, but the branch was big enough already.

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/services/scripts/base.py
  lib/lp/services/features/__init__.py
  lib/lp/bugs/browser/tests/test_bugattachment_file_access.py
  lib/canonical/launchpad/webapp/publication.py
  lib/lp/services/scripts/tests/test_feature_controller.py
  lib/lp/services/features/tests/test_flags.py
  lib/canonical/launchpad/doc/librarian.txt
  lib/canonical/launchpad/doc/timeout.txt
  lib/lp/testing/__init__.py
  lib/lp/services/features/webapp.py
  lib/lp/services/features/scopes.py
  lib/lp/services/features/testing.py
  lib/lp/services/features/tests/test_scopes.py
  lib/lp/registry/browser/tests/test_series_views.py

./lib/canonical/launchpad/doc/librarian.txt
       1: narrative uses a moin header.
      13: narrative uses a moin header.
      25: narrative uses a moin header.
      30: narrative uses a moin header.
     198: narrative uses a moin header.
     291: narrative uses a moin header.
     437: narrative uses a moin header.
     505: narrative uses a moin header.
     524: narrative uses a moin header.
     565: narrative uses a moin header.
     670: narrative uses a moin header.
     786: narrative uses a moin header.
     799: narrative uses a moin header.
     876: narrative uses a moin header.
./lib/canonical/launchpad/doc/timeout.txt
       1: narrative uses a moin header.
      81: narrative uses a moin header.
     117: narrative uses a moin header.
./lib/lp/testing/__init__.py
     135: 'anonymous_logged_in' imported but unused
     135: 'with_anonymous_login' imported but unused
     154: 'launchpadlib_for' imported but unused
     154: 'launchpadlib_credentials_for' imported but unused
     135: 'with_person_logged_in' imported but unused
     135: 'person_logged_in' imported but unused
     154: 'oauth_access_token_for' imported but unused
     135: 'login_celebrity' imported but unused
     135: 'with_celebrity_logged_in' imported but unused
     153: 'test_tales' imported but unused
     135: 'celebrity_logged_in' imported but unused
     135: 'run_with_login' imported but unused
     135: 'is_logged_in' imported but unused
     135: 'login_team' imported but unused
     135: 'login_person' imported but unused
     135: 'login_as' imported but unused

Jeroen

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

small notes - not a code review.

We already have a 'don't start script' feature that doesn't require
editing cron scripts - and *unlike* one based on feature flags, also
does not require db access (which is important for db deploys). Please
delete that part of your patch, because I am confident we'll have
unintended downtime if we have a db based duplicate facility.

Only LOSAs can set feature flags, so setting e.g. verbosity via a
feature flag is still a losa interrupt. It may be better than editing
cronscripts [or not - I haven't thought about it] : but losa
interruptedness is not a differentiating factor.

The rest sounds lovely.

Cheers,
Rob

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

That sounds good to me too. Please update
<https://dev.launchpad.net/FeatureFlags> too (maybe after it lands).

Martin

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Thanks for looking into this, and especially so soon.

We do have at least one person who can set feature flags: Stuart.

To me, setting a feature flag actually makes more sense than editing a config file, as does the regularity of being able to do it uniformly to any script by name. I do agree that the duplication is bad but given the ability to have either, maybe it's the config one that ought to go.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

For the record, we just talked about this on IRC and Robert made clear that:

(1) policy is for LOSAs to edit feature flags anyway, and
(2) we can't disable scripts by database during schema rollouts.

So I've taken out the disable switch for scripts.

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

I've read the code (a bit quickly because it's late) and it looks pretty good to me.

I wonder if we could/should actually just remove NullFeatureController.

review: Approve (code*)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/doc/librarian.txt'
2--- lib/canonical/launchpad/doc/librarian.txt 2011-01-19 14:26:33 +0000
3+++ lib/canonical/launchpad/doc/librarian.txt 2011-03-21 05:22:30 +0000
4@@ -1,18 +1,19 @@
5 = Librarian Access =
6
7 The librarian is a file storage service for launchpad. Conceptually similar to
8-other file storage API's like S3, it is used to store binary or large content -
9-bug attachments, package builds, images and so on.
10+other file storage API's like S3, it is used to store binary or large
11+content - bug attachments, package builds, images and so on.
12
13 Content in the librarian can be exposed at different urls. To expose some
14-content use a LibraryFileAlias. Private content is supported as well - for that
15-tokens are added to permit access for a limited time by a client - each time a
16-client attempts to dereference a private LibraryFileAlias a token is emitted.
17+content use a LibraryFileAlias. Private content is supported as well - for
18+that tokens are added to permit access for a limited time by a client - each
19+time a client attempts to dereference a private LibraryFileAlias a token is
20+emitted.
21
22 = Deployment notes =
23
24-(These may seem a bit out of place - they are, but they need to be written down
25-somewhere, and the deployment choices inform the implementation choices).
26+(These may seem a bit out of place - they are, but they need to be written
27+down somewhere, and the deployment choices inform the implementation choices).
28
29 The basics are simple: The librarian talks to clients. However restricted file
30 access makes things a little more complex. As the librarian itself doesn't do
31@@ -23,13 +24,14 @@
32
33 == setUp ==
34
35- >>> from canonical.database.sqlbase import session_store
36- >>> from canonical.launchpad.database.librarian import TimeLimitedToken
37+ >>> from canonical.database.sqlbase import session_store
38+ >>> from canonical.launchpad.database.librarian import TimeLimitedToken
39
40 == High Level ==
41
42 >>> from StringIO import StringIO
43- >>> from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
44+ >>> from canonical.launchpad.interfaces.librarian import (
45+ ... ILibraryFileAliasSet)
46 >>> data = 'This is some data'
47
48 We can create LibraryFileAliases using the ILibraryFileAliasSet utility.
49@@ -460,7 +462,8 @@
50
51 Filenames with spaces in them work.
52
53- >>> aid = client.addFile('hot dog', len(data), StringIO(data), 'text/plain')
54+ >>> aid = client.addFile(
55+ ... 'hot dog', len(data), StringIO(data), 'text/plain')
56 >>> transaction.commit()
57 >>> f = client.getFileByAlias(aid)
58 >>> f.read()
59@@ -507,8 +510,8 @@
60 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
61 >>> req = LaunchpadTestRequest()
62 >>> alias = lfas.create(
63- ... 'text2.txt', len(data), StringIO(data), 'text/plain', NEVER_EXPIRES
64- ... )
65+ ... 'text2.txt', len(data), StringIO(data), 'text/plain',
66+ ... NEVER_EXPIRES)
67 >>> transaction.commit()
68 >>> lfa_view = getMultiAdapter((alias,req), name='+index')
69 >>> lfa_view.initialize()
70@@ -557,7 +560,7 @@
71
72 Clear out existing tokens.
73
74- >>> _ = session_store().find(TimeLimitedToken).remove()
75+ >>> _ = session_store().find(TimeLimitedToken).remove()
76
77 == RedirectPerhapsWithTokenLibraryFileAliasView ==
78
79@@ -566,8 +569,8 @@
80 is interleaved: zope's registration system is too static to permit doing it
81 cleanly with separate classes.
82
83-To enable the behaviour we want to test while its controlled by a feature flag,
84-we must enable a feature flag for it:
85+To enable the behaviour we want to test while its controlled by a feature
86+flag, we must enable a feature flag for it:
87
88 >>> from lp.services.features.model import FeatureFlag, getFeatureStore
89 >>> ignore = getFeatureStore().add(FeatureFlag(
90@@ -589,11 +592,11 @@
91
92 XXX bug=631884 we have to establish the flags object manually for testing.
93
94- >>> from lp.services.features import per_thread
95+ >>> from lp.services.features import install_feature_controller
96 >>> from lp.services.features.flags import FeatureController
97 >>> from lp.services.features.webapp import ScopesFromRequest
98- >>> per_thread.features = FeatureController(
99- ... ScopesFromRequest(empty_request).lookup)
100+ >>> install_feature_controller(FeatureController(
101+ ... ScopesFromRequest(empty_request).lookup))
102
103 >>> restricted_view = RedirectPerhapsWithTokenLibraryFileAliasView(
104 ... restricted_file, empty_request)
105@@ -616,7 +619,7 @@
106 >>> view.target.endswith(session_store().find(TimeLimitedToken,
107 ... path=private_path).any().token)
108 True
109-
110+
111 The domain for the target starts with the id of the LibraryFileAlias.
112
113 >>> view.target.startswith('https://i%d.restricted.' % restricted_file.id)
114@@ -657,12 +660,12 @@
115 ... FeatureFlag.flag==u'publicrestrictedlibrarian').remove()
116 >>> transaction.commit()
117
118-Because the flags code aggressively caches, we have to do a small dance to
119+Because the flags code aggressively caches, we have to do a small dance to
120 convince it a new request has started too.
121
122 >>> empty_request = LaunchpadTestRequest()
123- >>> per_thread.features = FeatureController(
124- ... ScopesFromRequest(empty_request).lookup)
125+ >>> install_feature_controller(FeatureController(
126+ ... ScopesFromRequest(empty_request).lookup))
127
128 == StreamOrRedirectLibraryFileAliasView ==
129
130
131=== modified file 'lib/canonical/launchpad/doc/timeout.txt'
132--- lib/canonical/launchpad/doc/timeout.txt 2010-10-18 22:24:59 +0000
133+++ lib/canonical/launchpad/doc/timeout.txt 2011-03-21 05:22:30 +0000
134@@ -81,14 +81,15 @@
135 = Overriding hard timeouts via FeatureFlags =
136
137 It's possible to use FeatureFlags to control the hard timeout. This is used to
138-deal with pages that suddenly start performing badly, which are being optimised
139-but should not hold back the overall timeout decrease, or for which there are
140-only a few specific users and we are willing to have them run for longer
141-periods. For more information on feature flags see lp.services.features.
142+deal with pages that suddenly start performing badly, which are being
143+optimised but should not hold back the overall timeout decrease, or for which
144+there are only a few specific users and we are willing to have them run for
145+longer periods. For more information on feature flags see
146+lp.services.features.
147
148 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
149 >>> from lp.services.features.model import FeatureFlag, getFeatureStore
150- >>> from lp.services.features import per_thread
151+ >>> from lp.services.features import install_feature_controller
152 >>> from lp.services.features.flags import FeatureController
153 >>> from lp.services.features.webapp import ScopesFromRequest
154
155@@ -99,8 +100,8 @@
156 ... db_statement_timeout = 10000'''))
157
158 >>> empty_request = LaunchpadTestRequest()
159- >>> per_thread.features = FeatureController(
160- ... ScopesFromRequest(empty_request).lookup)
161+ >>> install_feature_controller(FeatureController(
162+ ... ScopesFromRequest(empty_request).lookup))
163 >>> ignore = getFeatureStore().add(FeatureFlag(
164 ... scope=u'default', flag=u'hard_timeout', value=u'20000',
165 ... priority=1))
166
167=== modified file 'lib/canonical/launchpad/webapp/publication.py'
168--- lib/canonical/launchpad/webapp/publication.py 2011-02-25 18:06:16 +0000
169+++ lib/canonical/launchpad/webapp/publication.py 2011-03-21 05:22:30 +0000
170@@ -464,7 +464,7 @@
171 'RootObject:OpStats', 'RootObject:+opstats',
172 'RootObject:+haproxy'):
173 request.features = NullFeatureController()
174- features.per_thread.features = request.features
175+ features.install_feature_controller(request.features)
176
177 # Calculate the hard timeout: needed because featureflags can be used
178 # to control the hard timeout, and they trigger DB access, but our
179
180=== modified file 'lib/lp/bugs/browser/tests/test_bugattachment_file_access.py'
181--- lib/lp/bugs/browser/tests/test_bugattachment_file_access.py 2011-03-15 11:18:01 +0000
182+++ lib/lp/bugs/browser/tests/test_bugattachment_file_access.py 2011-03-21 05:22:30 +0000
183@@ -108,9 +108,9 @@
184 view = navigation.publishTraverse(request, '+files')
185 # XXX Ensure the feature will be off - everything is off with
186 # NullFeatureController. bug=631884
187- lp.services.features.per_thread.features = NullFeatureController()
188- self.addCleanup(
189- setattr, lp.services.features.per_thread, 'features', None)
190+ lp.services.features.install_feature_controller(
191+ NullFeatureController())
192+ self.addCleanup(lp.services.features.install_feature_controller, None)
193 next_view, traversal_path = view.browserDefault(request)
194 self.assertEqual(view, next_view)
195 file_ = next_view()
196@@ -152,9 +152,9 @@
197 view = navigation.publishTraverse(request, '+files')
198 # XXX Ensure the feature will be off - everything is off with
199 # NullFeatureController. bug=631884
200- lp.services.features.per_thread.features = NullFeatureController()
201- self.addCleanup(
202- setattr, lp.services.features.per_thread, 'features', None)
203+ lp.services.features.install_feature_controller(
204+ NullFeatureController())
205+ self.addCleanup(lp.services.features.install_feature_controller, None)
206 next_view, traversal_path = view.browserDefault(request)
207 self.assertIsInstance(
208 next_view, SafeStreamOrRedirectLibraryFileAliasView)
209
210=== modified file 'lib/lp/registry/browser/tests/test_series_views.py'
211--- lib/lp/registry/browser/tests/test_series_views.py 2011-03-07 19:53:13 +0000
212+++ lib/lp/registry/browser/tests/test_series_views.py 2011-03-21 05:22:30 +0000
213@@ -1,4 +1,4 @@
214-# Copyright 2010 Canonical Ltd. This software is licensed under the
215+# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
216 # GNU Affero General Public License version 3 (see the file LICENSE).
217
218 __metaclass__ = type
219@@ -31,7 +31,7 @@
220 )
221 from lp.services.features import (
222 getFeatureFlag,
223- per_thread,
224+ install_feature_controller,
225 )
226 from lp.testing import (
227 TestCaseWithFactory,
228@@ -81,11 +81,8 @@
229 # features.
230 def in_scope(value):
231 return True
232- per_thread.features = FeatureController(in_scope)
233-
234- def reset_per_thread_features():
235- per_thread.features = None
236- test_case.addCleanup(reset_per_thread_features)
237+ install_feature_controller(FeatureController(in_scope))
238+ test_case.addCleanup(install_feature_controller, None)
239
240
241 class DistroSeriesLocalPackageDiffsPageTestCase(TestCaseWithFactory):
242
243=== modified file 'lib/lp/services/features/__init__.py'
244--- lib/lp/services/features/__init__.py 2010-11-02 17:47:05 +0000
245+++ lib/lp/services/features/__init__.py 2011-03-21 05:22:30 +0000
246@@ -173,7 +173,8 @@
247 __all__ = [
248 'get_relevant_feature_controller',
249 'getFeatureFlag',
250- 'per_thread',
251+ 'install_feature_controller',
252+ 'make_script_feature_controller',
253 ]
254
255
256@@ -185,8 +186,13 @@
257 """
258
259
260+def install_feature_controller(controller):
261+ """Install a `FeatureController` on this thread."""
262+ per_thread.features = controller
263+
264+
265 def get_relevant_feature_controller():
266- """Get a FeatureController for this thread."""
267+ """Get a `FeatureController` for this thread."""
268
269 # The noncommittal name "relevant" is because this function may change to
270 # look things up from the current request or some other mechanism in
271@@ -202,3 +208,16 @@
272 if features is None:
273 return None
274 return features.getFlag(flag)
275+
276+
277+def make_script_feature_controller(script_name):
278+ """Create and install a `FeatureController` for the named script."""
279+ # Avoid circular import.
280+ from lp.services.features.flags import FeatureController
281+ from lp.services.features.rulesource import StormFeatureRuleSource
282+ from lp.services.features.scopes import ScopesForScript
283+
284+ install_feature_controller(
285+ FeatureController(
286+ ScopesForScript(script_name).lookup,
287+ StormFeatureRuleSource()))
288
289=== modified file 'lib/lp/services/features/scopes.py'
290--- lib/lp/services/features/scopes.py 2011-01-26 22:07:08 +0000
291+++ lib/lp/services/features/scopes.py 2011-03-21 05:22:30 +0000
292@@ -5,6 +5,7 @@
293
294 __all__ = [
295 'HANDLERS',
296+ 'ScopesForScript',
297 'ScopesFromRequest',
298 'undocumented_scopes',
299 ]
300@@ -142,18 +143,51 @@
301 return False
302
303
304+class ScriptScope(BaseScope):
305+ """Matches the name of the currently running script.
306+
307+ For example, the scope script:embroider is active in a script called
308+ "embroider."
309+ """
310+
311+ pattern = r'script:'
312+
313+ def __init__(self, script_name):
314+ super(ScriptScope, self).__init__(None)
315+ self.script_scope = self.pattern + script_name
316+
317+ def lookup(self, scope_name):
318+ """Match the running script as a scope."""
319+ return scope_name == self.script_scope
320+
321+
322+# Handlers for the scopes that may occur in the webapp.
323+WEBAPP_SCOPE_HANDLERS = [DefaultScope, PageScope, TeamScope, ServerScope]
324+
325+
326+# Handlers for the scopes that may occur in scripts.
327+SCRIPT_SCOPE_HANDLERS = [DefaultScope, ScriptScope]
328+
329+
330 # These are the handlers for all of the allowable scopes. Any new scope will
331 # need a scope handler and that scope handler has to be added to this list.
332 # See BaseScope for hints as to what a scope handler should look like.
333-HANDLERS = [DefaultScope, PageScope, TeamScope, ServerScope]
334-
335-
336-class ScopesFromRequest():
337- """Identify feature scopes based on request state."""
338-
339- def __init__(self, request):
340+HANDLERS = set(WEBAPP_SCOPE_HANDLERS + SCRIPT_SCOPE_HANDLERS)
341+
342+
343+class MultiScopeHandler():
344+ """A scope handler that combines multiple `BaseScope`s."""
345+
346+ def __init__(self, request, scopes):
347 self.request = request
348- self.handlers = [f(request) for f in HANDLERS]
349+ self.handlers = scopes
350+
351+ def _findMatchingHandlers(self, scope_name):
352+ """Find any handlers that match `scope_name`."""
353+ return [
354+ handler
355+ for handler in self.handlers
356+ if handler.compiled_pattern.match(scope_name)]
357
358 def lookup(self, scope_name):
359 """Determine if scope_name applies to this request.
360@@ -163,15 +197,29 @@
361 the current request or the handlers are exhuasted, in which case the
362 scope name is not a match.
363 """
364- found_a_handler = False
365- for handler in self.handlers:
366- if handler.compiled_pattern.match(scope_name):
367- found_a_handler = True
368- if handler.lookup(scope_name):
369- return True
370+ matching_handlers = self._findMatchingHandlers(scope_name)
371+ for handler in matching_handlers:
372+ if handler.lookup(scope_name):
373+ return True
374
375- # If we didn't find at least one matching handler, then the requested
376- # scope is unknown and we want to record the scope for the +flag-info page to display.
377- if not found_a_handler:
378+ # If we didn't find at least one matching handler, then the
379+ # requested scope is unknown and we want to record the scope for
380+ # the +flag-info page to display.
381+ if len(matching_handlers) == 0:
382 undocumented_scopes.add(scope_name)
383
384+
385+class ScopesFromRequest(MultiScopeHandler):
386+ """Identify feature scopes based on request state."""
387+
388+ def __init__(self, request):
389+ super(ScopesFromRequest, self).__init__(
390+ request, [f(request) for f in WEBAPP_SCOPE_HANDLERS])
391+
392+
393+class ScopesForScript(MultiScopeHandler):
394+ """Identify feature scopes for a given script."""
395+
396+ def __init__(self, script_name):
397+ super(ScopesForScript, self).__init__(
398+ None, [DefaultScope(None), ScriptScope(script_name)])
399
400=== modified file 'lib/lp/services/features/testing.py'
401--- lib/lp/services/features/testing.py 2010-12-02 16:13:51 +0000
402+++ lib/lp/services/features/testing.py 2011-03-21 05:22:30 +0000
403@@ -9,7 +9,10 @@
404
405 from fixtures import Fixture
406
407-from lp.services.features import per_thread
408+from lp.services.features import (
409+ get_relevant_feature_controller,
410+ install_feature_controller,
411+ )
412 from lp.services.features.flags import FeatureController
413 from lp.services.features.rulesource import (
414 Rule,
415@@ -51,10 +54,10 @@
416 rule_source.setAllRules, rule_source.getAllRulesAsTuples())
417 rule_source.setAllRules(self.makeNewRules())
418
419- original_controller = getattr(per_thread, 'features', None)
420- controller = FeatureController(lambda _: True, rule_source)
421- per_thread.features = controller
422- self.addCleanup(setattr, per_thread, 'features', original_controller)
423+ original_controller = get_relevant_feature_controller()
424+ install_feature_controller(
425+ FeatureController(lambda _: True, rule_source))
426+ self.addCleanup(install_feature_controller, original_controller)
427
428 def makeNewRules(self):
429 """Make a set of new feature flag rules."""
430
431=== modified file 'lib/lp/services/features/tests/test_flags.py'
432--- lib/lp/services/features/tests/test_flags.py 2011-01-04 15:20:04 +0000
433+++ lib/lp/services/features/tests/test_flags.py 2011-03-21 05:22:30 +0000
434@@ -11,7 +11,7 @@
435 from canonical.testing import layers
436 from lp.services.features import (
437 getFeatureFlag,
438- per_thread,
439+ install_feature_controller,
440 )
441 from lp.services.features.flags import FeatureController
442 from lp.services.features.rulesource import StormFeatureRuleSource
443@@ -115,19 +115,20 @@
444 def test_threadGetFlag(self):
445 self.populateStore()
446 # the start-of-request handler will do something like this:
447- per_thread.features, call_log = self.makeControllerInScopes(
448+ controller, call_log = self.makeControllerInScopes(
449 ['default', 'beta_user'])
450+ install_feature_controller(controller)
451 try:
452 # then application code can simply ask without needing a context
453 # object
454 self.assertEqual(u'4.0', getFeatureFlag('ui.icing'))
455 finally:
456- per_thread.features = None
457+ install_feature_controller(None)
458
459 def test_threadGetFlagNoContext(self):
460 # If there is no context, please don't crash. workaround for the root
461 # cause in bug 631884.
462- per_thread.features = None
463+ install_feature_controller(None)
464 self.assertEqual(None, getFeatureFlag('ui.icing'))
465
466 def testLazyScopeLookup(self):
467
468=== added file 'lib/lp/services/features/tests/test_scopes.py'
469--- lib/lp/services/features/tests/test_scopes.py 1970-01-01 00:00:00 +0000
470+++ lib/lp/services/features/tests/test_scopes.py 2011-03-21 05:22:30 +0000
471@@ -0,0 +1,67 @@
472+# Copyright 2011 Canonical Ltd. This software is licensed under the
473+# GNU Affero General Public License version 3 (see the file LICENSE).
474+
475+"""Test feature-flag scopes."""
476+
477+__metaclass__ = type
478+
479+from canonical.testing.layers import DatabaseFunctionalLayer
480+from lp.testing import TestCaseWithFactory
481+from lp.services.features.scopes import (
482+ BaseScope,
483+ MultiScopeHandler,
484+ ScopesForScript,
485+ ScriptScope,
486+ )
487+
488+
489+class FakeScope(BaseScope):
490+ pattern = r'fake:'
491+
492+ def lookup(self, scope_name):
493+ return scope_name == (self.pattern + self.request)
494+
495+
496+class TestScopes(TestCaseWithFactory):
497+
498+ layer = DatabaseFunctionalLayer
499+
500+ def test_ScriptScope_lookup_matches_script_scope(self):
501+ script_name = self.factory.getUniqueString()
502+ scope = ScriptScope(script_name)
503+ self.assertTrue(scope.lookup("script:" + script_name))
504+
505+ def test_ScriptScope_lookup_does_not_match_other_script_scope(self):
506+ script_name = self.factory.getUniqueString()
507+ scope = ScriptScope(script_name)
508+ self.assertFalse(scope.lookup("script:other"))
509+
510+ def test_MultiScopeHandler_lookup_ignores_unmatched_scope(self):
511+ scope_name = self.factory.getUniqueString()
512+ handler = MultiScopeHandler(None, [FakeScope(scope_name)])
513+ self.assertFalse(handler.lookup("other:other"))
514+
515+ def test_MultiScopeHandler_lookup_ignores_inapplicable_scope(self):
516+ scope_name = self.factory.getUniqueString()
517+ handler = MultiScopeHandler(None, [FakeScope(scope_name)])
518+ self.assertFalse(handler.lookup("fake:other"))
519+
520+ def test_MultiScopeHandler_lookup_finds_matching_scope(self):
521+ scope_name = self.factory.getUniqueString()
522+ handler = MultiScopeHandler(None, [FakeScope(scope_name)])
523+ self.assertTrue(handler.lookup("fake:" + scope_name))
524+
525+ def test_ScopesForScript_includes_default_scope(self):
526+ script_name = self.factory.getUniqueString()
527+ scopes = ScopesForScript(script_name)
528+ self.assertTrue(scopes.lookup("default"))
529+
530+ def test_ScopesForScript_lookup_finds_script(self):
531+ script_name = self.factory.getUniqueString()
532+ scopes = ScopesForScript(script_name)
533+ self.assertTrue(scopes.lookup("script:" + script_name))
534+
535+ def test_ScopesForScript_lookup_does_not_find_other_script(self):
536+ script_name = self.factory.getUniqueString()
537+ scopes = ScopesForScript(script_name)
538+ self.assertFalse(scopes.lookup("script:other"))
539
540=== modified file 'lib/lp/services/features/webapp.py'
541--- lib/lp/services/features/webapp.py 2011-01-14 21:48:57 +0000
542+++ lib/lp/services/features/webapp.py 2011-03-21 05:22:30 +0000
543@@ -1,4 +1,4 @@
544-# Copyright 2010 Canonical Ltd. This software is licensed under the
545+# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
546 # GNU Affero General Public License version 3 (see the file LICENSE).
547
548 """Connect Feature flags into webapp requests."""
549@@ -7,7 +7,7 @@
550
551 __metaclass__ = type
552
553-from lp.services.features import per_thread
554+from lp.services.features import install_feature_controller
555 from lp.services.features.flags import FeatureController
556 from lp.services.features.rulesource import StormFeatureRuleSource
557 from lp.services.features.scopes import ScopesFromRequest
558@@ -15,11 +15,13 @@
559
560 def start_request(event):
561 """Register FeatureController."""
562- event.request.features = per_thread.features = FeatureController(
563+ event.request.features = FeatureController(
564 ScopesFromRequest(event.request).lookup,
565 StormFeatureRuleSource())
566+ install_feature_controller(event.request.features)
567
568
569 def end_request(event):
570 """Done with this FeatureController."""
571- event.request.features = per_thread.features = None
572+ install_feature_controller(None)
573+ event.request.features = None
574
575=== modified file 'lib/lp/services/scripts/base.py'
576--- lib/lp/services/scripts/base.py 2011-02-21 00:06:55 +0000
577+++ lib/lp/services/scripts/base.py 2011-03-21 05:22:30 +0000
578@@ -39,6 +39,12 @@
579 setupInteractionByEmail,
580 )
581 from canonical.lp import initZopeless
582+from lp.services.features import (
583+ getFeatureFlag,
584+ get_relevant_feature_controller,
585+ install_feature_controller,
586+ make_script_feature_controller,
587+ )
588 from lp.services.scripts.interfaces.scriptactivity import IScriptActivitySet
589
590
591@@ -294,6 +300,7 @@
592 @log_unhandled_exception_and_exit
593 def run(self, use_web_security=False, isolation=None):
594 """Actually run the script, executing zcml and initZopeless."""
595+
596 if isolation is None:
597 isolation = ISOLATION_LEVEL_DEFAULT
598 self._init_zca(use_web_security=use_web_security)
599@@ -303,6 +310,9 @@
600 profiler = None
601 if self.options.profile:
602 profiler = Profile()
603+
604+ original_feature_controller = get_relevant_feature_controller()
605+ install_feature_controller(make_script_feature_controller(self.name))
606 try:
607 if profiler:
608 profiler.runcall(self.main)
609@@ -316,6 +326,8 @@
610 else:
611 date_completed = datetime.datetime.now(UTC)
612 self.record_activity(date_started, date_completed)
613+ finally:
614+ install_feature_controller(original_feature_controller)
615 if profiler:
616 profiler.dump_stats(self.options.profile)
617
618
619=== added file 'lib/lp/services/scripts/tests/test_feature_controller.py'
620--- lib/lp/services/scripts/tests/test_feature_controller.py 1970-01-01 00:00:00 +0000
621+++ lib/lp/services/scripts/tests/test_feature_controller.py 2011-03-21 05:22:30 +0000
622@@ -0,0 +1,61 @@
623+# Copyright 2011 Canonical Ltd. This software is licensed under the
624+# GNU Affero General Public License version 3 (see the file LICENSE).
625+
626+"""Test feature controller in scripts."""
627+
628+__metaclass__ = type
629+
630+from canonical.testing.layers import DatabaseFunctionalLayer
631+from lp.services.features import (
632+ get_relevant_feature_controller,
633+ install_feature_controller,
634+ )
635+from lp.services.features.flags import NullFeatureController
636+from lp.services.features.testing import FeatureFixture
637+from lp.services.scripts.base import LaunchpadScript
638+from lp.testing import TestCase
639+from lp.testing.fakemethod import FakeMethod
640+
641+
642+class FakeScript(LaunchpadScript):
643+ """A dummy script that only records which feature controller is active."""
644+
645+ observed_feature_controller = object()
646+
647+ def __init__(self, name):
648+ super(FakeScript, self).__init__(name=name, test_args=[])
649+
650+ def main(self):
651+ self.observed_feature_controller = get_relevant_feature_controller()
652+
653+ # Shortcut some underpinnings of LaunchpadScript.run that we can't
654+ # afford to have happen in tests.
655+ _init_zca = FakeMethod()
656+ _init_db = FakeMethod()
657+ record_activity = FakeMethod()
658+
659+
660+class TestScriptFeatureController(TestCase):
661+ layer = DatabaseFunctionalLayer
662+
663+ def setUp(self):
664+ super(TestScriptFeatureController, self).setUp()
665+ self.original_controller = get_relevant_feature_controller()
666+
667+ def tearDown(self):
668+ install_feature_controller(self.original_controller)
669+ super(TestScriptFeatureController, self).tearDown()
670+
671+ def test_script_installs_script_feature_controller(self):
672+ script = FakeScript(name="bongo")
673+ script_feature_controller = get_relevant_feature_controller()
674+ self.assertNotEqual(
675+ self.original_controller, script.observed_feature_controller)
676+ self.assertNotEqual(None, script.observed_feature_controller)
677+
678+ def test_script_restores_feature_controller(self):
679+ previous_controller = NullFeatureController()
680+ install_feature_controller(previous_controller)
681+ FakeScript(name="mongo").run()
682+ self.assertEqual(
683+ previous_controller, get_relevant_feature_controller())
684
685=== modified file 'lib/lp/testing/__init__.py'
686--- lib/lp/testing/__init__.py 2011-03-09 18:48:09 +0000
687+++ lib/lp/testing/__init__.py 2011-03-21 05:22:30 +0000
688@@ -968,6 +968,7 @@
689 This prevents the events from propagating to their normal subscribers.
690 The recorded events can be accessed via the 'events' list.
691 """
692+
693 def __init__(self):
694 self.events = []
695 self.old_subscribers = None
696@@ -987,13 +988,13 @@
697 def feature_flags():
698 """Provide a context in which feature flags work."""
699 empty_request = LaunchpadTestRequest()
700- old_features = getattr(features.per_thread, 'features', None)
701- features.per_thread.features = FeatureController(
702- ScopesFromRequest(empty_request).lookup)
703+ old_features = features.get_relevant_feature_controller()
704+ features.install_feature_controller(FeatureController(
705+ ScopesFromRequest(empty_request).lookup))
706 try:
707 yield
708 finally:
709- features.per_thread.features = old_features
710+ features.install_feature_controller(old_features)
711
712
713 def get_lsb_information():
714@@ -1102,12 +1103,12 @@
715 """Set a feature flag to the specified value.
716
717 In order to access the flag, use the feature_flags context manager or
718- populate features.per_thread.features some other way.
719+ set the feature controller in some other way.
720 :param name: The name of the flag.
721 :param value: The value of the flag.
722 :param scope: The scope in which the specified value applies.
723 """
724- assert getattr(features.per_thread, 'features', None) is not None
725+ assert features.get_relevant_feature_controller() is not None
726 flag = FeatureFlag(
727 scope=scope, flag=name, value=value, priority=priority)
728 store = getFeatureStore()