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
=== modified file 'lib/canonical/launchpad/doc/librarian.txt'
--- lib/canonical/launchpad/doc/librarian.txt 2011-01-19 14:26:33 +0000
+++ lib/canonical/launchpad/doc/librarian.txt 2011-03-21 05:22:30 +0000
@@ -1,18 +1,19 @@
1= Librarian Access =1= Librarian Access =
22
3The librarian is a file storage service for launchpad. Conceptually similar to3The librarian is a file storage service for launchpad. Conceptually similar to
4other file storage API's like S3, it is used to store binary or large content -4other file storage API's like S3, it is used to store binary or large
5bug attachments, package builds, images and so on.5content - bug attachments, package builds, images and so on.
66
7Content in the librarian can be exposed at different urls. To expose some7Content in the librarian can be exposed at different urls. To expose some
8content use a LibraryFileAlias. Private content is supported as well - for that8content use a LibraryFileAlias. Private content is supported as well - for
9tokens are added to permit access for a limited time by a client - each time a9that tokens are added to permit access for a limited time by a client - each
10client attempts to dereference a private LibraryFileAlias a token is emitted.10time a client attempts to dereference a private LibraryFileAlias a token is
11emitted.
1112
12= Deployment notes =13= Deployment notes =
1314
14(These may seem a bit out of place - they are, but they need to be written down15(These may seem a bit out of place - they are, but they need to be written
15somewhere, and the deployment choices inform the implementation choices).16down somewhere, and the deployment choices inform the implementation choices).
1617
17The basics are simple: The librarian talks to clients. However restricted file18The basics are simple: The librarian talks to clients. However restricted file
18access makes things a little more complex. As the librarian itself doesn't do19access makes things a little more complex. As the librarian itself doesn't do
@@ -23,13 +24,14 @@
2324
24== setUp ==25== setUp ==
2526
26 >>> from canonical.database.sqlbase import session_store27 >>> from canonical.database.sqlbase import session_store
27 >>> from canonical.launchpad.database.librarian import TimeLimitedToken28 >>> from canonical.launchpad.database.librarian import TimeLimitedToken
2829
29== High Level ==30== High Level ==
3031
31 >>> from StringIO import StringIO32 >>> from StringIO import StringIO
32 >>> from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet33 >>> from canonical.launchpad.interfaces.librarian import (
34 ... ILibraryFileAliasSet)
33 >>> data = 'This is some data'35 >>> data = 'This is some data'
3436
35We can create LibraryFileAliases using the ILibraryFileAliasSet utility.37We can create LibraryFileAliases using the ILibraryFileAliasSet utility.
@@ -460,7 +462,8 @@
460462
461Filenames with spaces in them work.463Filenames with spaces in them work.
462464
463 >>> aid = client.addFile('hot dog', len(data), StringIO(data), 'text/plain')465 >>> aid = client.addFile(
466 ... 'hot dog', len(data), StringIO(data), 'text/plain')
464 >>> transaction.commit()467 >>> transaction.commit()
465 >>> f = client.getFileByAlias(aid)468 >>> f = client.getFileByAlias(aid)
466 >>> f.read()469 >>> f.read()
@@ -507,8 +510,8 @@
507 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest510 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
508 >>> req = LaunchpadTestRequest()511 >>> req = LaunchpadTestRequest()
509 >>> alias = lfas.create(512 >>> alias = lfas.create(
510 ... 'text2.txt', len(data), StringIO(data), 'text/plain', NEVER_EXPIRES513 ... 'text2.txt', len(data), StringIO(data), 'text/plain',
511 ... )514 ... NEVER_EXPIRES)
512 >>> transaction.commit()515 >>> transaction.commit()
513 >>> lfa_view = getMultiAdapter((alias,req), name='+index')516 >>> lfa_view = getMultiAdapter((alias,req), name='+index')
514 >>> lfa_view.initialize()517 >>> lfa_view.initialize()
@@ -557,7 +560,7 @@
557560
558Clear out existing tokens.561Clear out existing tokens.
559562
560 >>> _ = session_store().find(TimeLimitedToken).remove()563 >>> _ = session_store().find(TimeLimitedToken).remove()
561564
562== RedirectPerhapsWithTokenLibraryFileAliasView ==565== RedirectPerhapsWithTokenLibraryFileAliasView ==
563566
@@ -566,8 +569,8 @@
566is interleaved: zope's registration system is too static to permit doing it569is interleaved: zope's registration system is too static to permit doing it
567cleanly with separate classes.570cleanly with separate classes.
568571
569To enable the behaviour we want to test while its controlled by a feature flag,572To enable the behaviour we want to test while its controlled by a feature
570we must enable a feature flag for it:573flag, we must enable a feature flag for it:
571574
572 >>> from lp.services.features.model import FeatureFlag, getFeatureStore575 >>> from lp.services.features.model import FeatureFlag, getFeatureStore
573 >>> ignore = getFeatureStore().add(FeatureFlag(576 >>> ignore = getFeatureStore().add(FeatureFlag(
@@ -589,11 +592,11 @@
589592
590XXX bug=631884 we have to establish the flags object manually for testing.593XXX bug=631884 we have to establish the flags object manually for testing.
591594
592 >>> from lp.services.features import per_thread595 >>> from lp.services.features import install_feature_controller
593 >>> from lp.services.features.flags import FeatureController596 >>> from lp.services.features.flags import FeatureController
594 >>> from lp.services.features.webapp import ScopesFromRequest597 >>> from lp.services.features.webapp import ScopesFromRequest
595 >>> per_thread.features = FeatureController(598 >>> install_feature_controller(FeatureController(
596 ... ScopesFromRequest(empty_request).lookup)599 ... ScopesFromRequest(empty_request).lookup))
597600
598 >>> restricted_view = RedirectPerhapsWithTokenLibraryFileAliasView(601 >>> restricted_view = RedirectPerhapsWithTokenLibraryFileAliasView(
599 ... restricted_file, empty_request)602 ... restricted_file, empty_request)
@@ -616,7 +619,7 @@
616 >>> view.target.endswith(session_store().find(TimeLimitedToken,619 >>> view.target.endswith(session_store().find(TimeLimitedToken,
617 ... path=private_path).any().token)620 ... path=private_path).any().token)
618 True621 True
619 622
620The domain for the target starts with the id of the LibraryFileAlias.623The domain for the target starts with the id of the LibraryFileAlias.
621624
622 >>> view.target.startswith('https://i%d.restricted.' % restricted_file.id)625 >>> view.target.startswith('https://i%d.restricted.' % restricted_file.id)
@@ -657,12 +660,12 @@
657 ... FeatureFlag.flag==u'publicrestrictedlibrarian').remove()660 ... FeatureFlag.flag==u'publicrestrictedlibrarian').remove()
658 >>> transaction.commit()661 >>> transaction.commit()
659662
660Because the flags code aggressively caches, we have to do a small dance to 663Because the flags code aggressively caches, we have to do a small dance to
661convince it a new request has started too.664convince it a new request has started too.
662665
663 >>> empty_request = LaunchpadTestRequest()666 >>> empty_request = LaunchpadTestRequest()
664 >>> per_thread.features = FeatureController(667 >>> install_feature_controller(FeatureController(
665 ... ScopesFromRequest(empty_request).lookup)668 ... ScopesFromRequest(empty_request).lookup))
666669
667== StreamOrRedirectLibraryFileAliasView ==670== StreamOrRedirectLibraryFileAliasView ==
668671
669672
=== modified file 'lib/canonical/launchpad/doc/timeout.txt'
--- lib/canonical/launchpad/doc/timeout.txt 2010-10-18 22:24:59 +0000
+++ lib/canonical/launchpad/doc/timeout.txt 2011-03-21 05:22:30 +0000
@@ -81,14 +81,15 @@
81= Overriding hard timeouts via FeatureFlags =81= Overriding hard timeouts via FeatureFlags =
8282
83It's possible to use FeatureFlags to control the hard timeout. This is used to83It's possible to use FeatureFlags to control the hard timeout. This is used to
84deal with pages that suddenly start performing badly, which are being optimised84deal with pages that suddenly start performing badly, which are being
85but should not hold back the overall timeout decrease, or for which there are85optimised but should not hold back the overall timeout decrease, or for which
86only a few specific users and we are willing to have them run for longer86there are only a few specific users and we are willing to have them run for
87periods. For more information on feature flags see lp.services.features.87longer periods. For more information on feature flags see
88lp.services.features.
8889
89 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest90 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
90 >>> from lp.services.features.model import FeatureFlag, getFeatureStore91 >>> from lp.services.features.model import FeatureFlag, getFeatureStore
91 >>> from lp.services.features import per_thread92 >>> from lp.services.features import install_feature_controller
92 >>> from lp.services.features.flags import FeatureController93 >>> from lp.services.features.flags import FeatureController
93 >>> from lp.services.features.webapp import ScopesFromRequest94 >>> from lp.services.features.webapp import ScopesFromRequest
9495
@@ -99,8 +100,8 @@
99 ... db_statement_timeout = 10000'''))100 ... db_statement_timeout = 10000'''))
100101
101 >>> empty_request = LaunchpadTestRequest()102 >>> empty_request = LaunchpadTestRequest()
102 >>> per_thread.features = FeatureController(103 >>> install_feature_controller(FeatureController(
103 ... ScopesFromRequest(empty_request).lookup)104 ... ScopesFromRequest(empty_request).lookup))
104 >>> ignore = getFeatureStore().add(FeatureFlag(105 >>> ignore = getFeatureStore().add(FeatureFlag(
105 ... scope=u'default', flag=u'hard_timeout', value=u'20000',106 ... scope=u'default', flag=u'hard_timeout', value=u'20000',
106 ... priority=1))107 ... priority=1))
107108
=== modified file 'lib/canonical/launchpad/webapp/publication.py'
--- lib/canonical/launchpad/webapp/publication.py 2011-02-25 18:06:16 +0000
+++ lib/canonical/launchpad/webapp/publication.py 2011-03-21 05:22:30 +0000
@@ -464,7 +464,7 @@
464 'RootObject:OpStats', 'RootObject:+opstats',464 'RootObject:OpStats', 'RootObject:+opstats',
465 'RootObject:+haproxy'):465 'RootObject:+haproxy'):
466 request.features = NullFeatureController()466 request.features = NullFeatureController()
467 features.per_thread.features = request.features467 features.install_feature_controller(request.features)
468468
469 # Calculate the hard timeout: needed because featureflags can be used469 # Calculate the hard timeout: needed because featureflags can be used
470 # to control the hard timeout, and they trigger DB access, but our470 # to control the hard timeout, and they trigger DB access, but our
471471
=== modified file 'lib/lp/bugs/browser/tests/test_bugattachment_file_access.py'
--- lib/lp/bugs/browser/tests/test_bugattachment_file_access.py 2011-03-15 11:18:01 +0000
+++ lib/lp/bugs/browser/tests/test_bugattachment_file_access.py 2011-03-21 05:22:30 +0000
@@ -108,9 +108,9 @@
108 view = navigation.publishTraverse(request, '+files')108 view = navigation.publishTraverse(request, '+files')
109 # XXX Ensure the feature will be off - everything is off with109 # XXX Ensure the feature will be off - everything is off with
110 # NullFeatureController. bug=631884110 # NullFeatureController. bug=631884
111 lp.services.features.per_thread.features = NullFeatureController()111 lp.services.features.install_feature_controller(
112 self.addCleanup(112 NullFeatureController())
113 setattr, lp.services.features.per_thread, 'features', None)113 self.addCleanup(lp.services.features.install_feature_controller, None)
114 next_view, traversal_path = view.browserDefault(request)114 next_view, traversal_path = view.browserDefault(request)
115 self.assertEqual(view, next_view)115 self.assertEqual(view, next_view)
116 file_ = next_view()116 file_ = next_view()
@@ -152,9 +152,9 @@
152 view = navigation.publishTraverse(request, '+files')152 view = navigation.publishTraverse(request, '+files')
153 # XXX Ensure the feature will be off - everything is off with153 # XXX Ensure the feature will be off - everything is off with
154 # NullFeatureController. bug=631884154 # NullFeatureController. bug=631884
155 lp.services.features.per_thread.features = NullFeatureController()155 lp.services.features.install_feature_controller(
156 self.addCleanup(156 NullFeatureController())
157 setattr, lp.services.features.per_thread, 'features', None)157 self.addCleanup(lp.services.features.install_feature_controller, None)
158 next_view, traversal_path = view.browserDefault(request)158 next_view, traversal_path = view.browserDefault(request)
159 self.assertIsInstance(159 self.assertIsInstance(
160 next_view, SafeStreamOrRedirectLibraryFileAliasView)160 next_view, SafeStreamOrRedirectLibraryFileAliasView)
161161
=== modified file 'lib/lp/registry/browser/tests/test_series_views.py'
--- lib/lp/registry/browser/tests/test_series_views.py 2011-03-07 19:53:13 +0000
+++ lib/lp/registry/browser/tests/test_series_views.py 2011-03-21 05:22:30 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the1# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
@@ -31,7 +31,7 @@
31 )31 )
32from lp.services.features import (32from lp.services.features import (
33 getFeatureFlag,33 getFeatureFlag,
34 per_thread,34 install_feature_controller,
35 )35 )
36from lp.testing import (36from lp.testing import (
37 TestCaseWithFactory,37 TestCaseWithFactory,
@@ -81,11 +81,8 @@
81 # features.81 # features.
82 def in_scope(value):82 def in_scope(value):
83 return True83 return True
84 per_thread.features = FeatureController(in_scope)84 install_feature_controller(FeatureController(in_scope))
8585 test_case.addCleanup(install_feature_controller, None)
86 def reset_per_thread_features():
87 per_thread.features = None
88 test_case.addCleanup(reset_per_thread_features)
8986
9087
91class DistroSeriesLocalPackageDiffsPageTestCase(TestCaseWithFactory):88class DistroSeriesLocalPackageDiffsPageTestCase(TestCaseWithFactory):
9289
=== modified file 'lib/lp/services/features/__init__.py'
--- lib/lp/services/features/__init__.py 2010-11-02 17:47:05 +0000
+++ lib/lp/services/features/__init__.py 2011-03-21 05:22:30 +0000
@@ -173,7 +173,8 @@
173__all__ = [173__all__ = [
174 'get_relevant_feature_controller',174 'get_relevant_feature_controller',
175 'getFeatureFlag',175 'getFeatureFlag',
176 'per_thread',176 'install_feature_controller',
177 'make_script_feature_controller',
177 ]178 ]
178179
179180
@@ -185,8 +186,13 @@
185"""186"""
186187
187188
189def install_feature_controller(controller):
190 """Install a `FeatureController` on this thread."""
191 per_thread.features = controller
192
193
188def get_relevant_feature_controller():194def get_relevant_feature_controller():
189 """Get a FeatureController for this thread."""195 """Get a `FeatureController` for this thread."""
190196
191 # The noncommittal name "relevant" is because this function may change to197 # The noncommittal name "relevant" is because this function may change to
192 # look things up from the current request or some other mechanism in198 # look things up from the current request or some other mechanism in
@@ -202,3 +208,16 @@
202 if features is None:208 if features is None:
203 return None209 return None
204 return features.getFlag(flag)210 return features.getFlag(flag)
211
212
213def make_script_feature_controller(script_name):
214 """Create and install a `FeatureController` for the named script."""
215 # Avoid circular import.
216 from lp.services.features.flags import FeatureController
217 from lp.services.features.rulesource import StormFeatureRuleSource
218 from lp.services.features.scopes import ScopesForScript
219
220 install_feature_controller(
221 FeatureController(
222 ScopesForScript(script_name).lookup,
223 StormFeatureRuleSource()))
205224
=== modified file 'lib/lp/services/features/scopes.py'
--- lib/lp/services/features/scopes.py 2011-01-26 22:07:08 +0000
+++ lib/lp/services/features/scopes.py 2011-03-21 05:22:30 +0000
@@ -5,6 +5,7 @@
55
6__all__ = [6__all__ = [
7 'HANDLERS',7 'HANDLERS',
8 'ScopesForScript',
8 'ScopesFromRequest',9 'ScopesFromRequest',
9 'undocumented_scopes',10 'undocumented_scopes',
10 ]11 ]
@@ -142,18 +143,51 @@
142 return False143 return False
143144
144145
146class ScriptScope(BaseScope):
147 """Matches the name of the currently running script.
148
149 For example, the scope script:embroider is active in a script called
150 "embroider."
151 """
152
153 pattern = r'script:'
154
155 def __init__(self, script_name):
156 super(ScriptScope, self).__init__(None)
157 self.script_scope = self.pattern + script_name
158
159 def lookup(self, scope_name):
160 """Match the running script as a scope."""
161 return scope_name == self.script_scope
162
163
164# Handlers for the scopes that may occur in the webapp.
165WEBAPP_SCOPE_HANDLERS = [DefaultScope, PageScope, TeamScope, ServerScope]
166
167
168# Handlers for the scopes that may occur in scripts.
169SCRIPT_SCOPE_HANDLERS = [DefaultScope, ScriptScope]
170
171
145# These are the handlers for all of the allowable scopes. Any new scope will172# These are the handlers for all of the allowable scopes. Any new scope will
146# need a scope handler and that scope handler has to be added to this list.173# need a scope handler and that scope handler has to be added to this list.
147# See BaseScope for hints as to what a scope handler should look like.174# See BaseScope for hints as to what a scope handler should look like.
148HANDLERS = [DefaultScope, PageScope, TeamScope, ServerScope]175HANDLERS = set(WEBAPP_SCOPE_HANDLERS + SCRIPT_SCOPE_HANDLERS)
149176
150177
151class ScopesFromRequest():178class MultiScopeHandler():
152 """Identify feature scopes based on request state."""179 """A scope handler that combines multiple `BaseScope`s."""
153180
154 def __init__(self, request):181 def __init__(self, request, scopes):
155 self.request = request182 self.request = request
156 self.handlers = [f(request) for f in HANDLERS]183 self.handlers = scopes
184
185 def _findMatchingHandlers(self, scope_name):
186 """Find any handlers that match `scope_name`."""
187 return [
188 handler
189 for handler in self.handlers
190 if handler.compiled_pattern.match(scope_name)]
157191
158 def lookup(self, scope_name):192 def lookup(self, scope_name):
159 """Determine if scope_name applies to this request.193 """Determine if scope_name applies to this request.
@@ -163,15 +197,29 @@
163 the current request or the handlers are exhuasted, in which case the197 the current request or the handlers are exhuasted, in which case the
164 scope name is not a match.198 scope name is not a match.
165 """199 """
166 found_a_handler = False200 matching_handlers = self._findMatchingHandlers(scope_name)
167 for handler in self.handlers:201 for handler in matching_handlers:
168 if handler.compiled_pattern.match(scope_name):202 if handler.lookup(scope_name):
169 found_a_handler = True203 return True
170 if handler.lookup(scope_name):
171 return True
172204
173 # If we didn't find at least one matching handler, then the requested205 # If we didn't find at least one matching handler, then the
174 # scope is unknown and we want to record the scope for the +flag-info page to display.206 # requested scope is unknown and we want to record the scope for
175 if not found_a_handler:207 # the +flag-info page to display.
208 if len(matching_handlers) == 0:
176 undocumented_scopes.add(scope_name)209 undocumented_scopes.add(scope_name)
177210
211
212class ScopesFromRequest(MultiScopeHandler):
213 """Identify feature scopes based on request state."""
214
215 def __init__(self, request):
216 super(ScopesFromRequest, self).__init__(
217 request, [f(request) for f in WEBAPP_SCOPE_HANDLERS])
218
219
220class ScopesForScript(MultiScopeHandler):
221 """Identify feature scopes for a given script."""
222
223 def __init__(self, script_name):
224 super(ScopesForScript, self).__init__(
225 None, [DefaultScope(None), ScriptScope(script_name)])
178226
=== modified file 'lib/lp/services/features/testing.py'
--- lib/lp/services/features/testing.py 2010-12-02 16:13:51 +0000
+++ lib/lp/services/features/testing.py 2011-03-21 05:22:30 +0000
@@ -9,7 +9,10 @@
99
10from fixtures import Fixture10from fixtures import Fixture
1111
12from lp.services.features import per_thread12from lp.services.features import (
13 get_relevant_feature_controller,
14 install_feature_controller,
15 )
13from lp.services.features.flags import FeatureController16from lp.services.features.flags import FeatureController
14from lp.services.features.rulesource import (17from lp.services.features.rulesource import (
15 Rule,18 Rule,
@@ -51,10 +54,10 @@
51 rule_source.setAllRules, rule_source.getAllRulesAsTuples())54 rule_source.setAllRules, rule_source.getAllRulesAsTuples())
52 rule_source.setAllRules(self.makeNewRules())55 rule_source.setAllRules(self.makeNewRules())
5356
54 original_controller = getattr(per_thread, 'features', None)57 original_controller = get_relevant_feature_controller()
55 controller = FeatureController(lambda _: True, rule_source)58 install_feature_controller(
56 per_thread.features = controller59 FeatureController(lambda _: True, rule_source))
57 self.addCleanup(setattr, per_thread, 'features', original_controller)60 self.addCleanup(install_feature_controller, original_controller)
5861
59 def makeNewRules(self):62 def makeNewRules(self):
60 """Make a set of new feature flag rules."""63 """Make a set of new feature flag rules."""
6164
=== modified file 'lib/lp/services/features/tests/test_flags.py'
--- lib/lp/services/features/tests/test_flags.py 2011-01-04 15:20:04 +0000
+++ lib/lp/services/features/tests/test_flags.py 2011-03-21 05:22:30 +0000
@@ -11,7 +11,7 @@
11from canonical.testing import layers11from canonical.testing import layers
12from lp.services.features import (12from lp.services.features import (
13 getFeatureFlag,13 getFeatureFlag,
14 per_thread,14 install_feature_controller,
15 )15 )
16from lp.services.features.flags import FeatureController16from lp.services.features.flags import FeatureController
17from lp.services.features.rulesource import StormFeatureRuleSource17from lp.services.features.rulesource import StormFeatureRuleSource
@@ -115,19 +115,20 @@
115 def test_threadGetFlag(self):115 def test_threadGetFlag(self):
116 self.populateStore()116 self.populateStore()
117 # the start-of-request handler will do something like this:117 # the start-of-request handler will do something like this:
118 per_thread.features, call_log = self.makeControllerInScopes(118 controller, call_log = self.makeControllerInScopes(
119 ['default', 'beta_user'])119 ['default', 'beta_user'])
120 install_feature_controller(controller)
120 try:121 try:
121 # then application code can simply ask without needing a context122 # then application code can simply ask without needing a context
122 # object123 # object
123 self.assertEqual(u'4.0', getFeatureFlag('ui.icing'))124 self.assertEqual(u'4.0', getFeatureFlag('ui.icing'))
124 finally:125 finally:
125 per_thread.features = None126 install_feature_controller(None)
126127
127 def test_threadGetFlagNoContext(self):128 def test_threadGetFlagNoContext(self):
128 # If there is no context, please don't crash. workaround for the root129 # If there is no context, please don't crash. workaround for the root
129 # cause in bug 631884.130 # cause in bug 631884.
130 per_thread.features = None131 install_feature_controller(None)
131 self.assertEqual(None, getFeatureFlag('ui.icing'))132 self.assertEqual(None, getFeatureFlag('ui.icing'))
132133
133 def testLazyScopeLookup(self):134 def testLazyScopeLookup(self):
134135
=== added file 'lib/lp/services/features/tests/test_scopes.py'
--- lib/lp/services/features/tests/test_scopes.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/features/tests/test_scopes.py 2011-03-21 05:22:30 +0000
@@ -0,0 +1,67 @@
1# Copyright 2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test feature-flag scopes."""
5
6__metaclass__ = type
7
8from canonical.testing.layers import DatabaseFunctionalLayer
9from lp.testing import TestCaseWithFactory
10from lp.services.features.scopes import (
11 BaseScope,
12 MultiScopeHandler,
13 ScopesForScript,
14 ScriptScope,
15 )
16
17
18class FakeScope(BaseScope):
19 pattern = r'fake:'
20
21 def lookup(self, scope_name):
22 return scope_name == (self.pattern + self.request)
23
24
25class TestScopes(TestCaseWithFactory):
26
27 layer = DatabaseFunctionalLayer
28
29 def test_ScriptScope_lookup_matches_script_scope(self):
30 script_name = self.factory.getUniqueString()
31 scope = ScriptScope(script_name)
32 self.assertTrue(scope.lookup("script:" + script_name))
33
34 def test_ScriptScope_lookup_does_not_match_other_script_scope(self):
35 script_name = self.factory.getUniqueString()
36 scope = ScriptScope(script_name)
37 self.assertFalse(scope.lookup("script:other"))
38
39 def test_MultiScopeHandler_lookup_ignores_unmatched_scope(self):
40 scope_name = self.factory.getUniqueString()
41 handler = MultiScopeHandler(None, [FakeScope(scope_name)])
42 self.assertFalse(handler.lookup("other:other"))
43
44 def test_MultiScopeHandler_lookup_ignores_inapplicable_scope(self):
45 scope_name = self.factory.getUniqueString()
46 handler = MultiScopeHandler(None, [FakeScope(scope_name)])
47 self.assertFalse(handler.lookup("fake:other"))
48
49 def test_MultiScopeHandler_lookup_finds_matching_scope(self):
50 scope_name = self.factory.getUniqueString()
51 handler = MultiScopeHandler(None, [FakeScope(scope_name)])
52 self.assertTrue(handler.lookup("fake:" + scope_name))
53
54 def test_ScopesForScript_includes_default_scope(self):
55 script_name = self.factory.getUniqueString()
56 scopes = ScopesForScript(script_name)
57 self.assertTrue(scopes.lookup("default"))
58
59 def test_ScopesForScript_lookup_finds_script(self):
60 script_name = self.factory.getUniqueString()
61 scopes = ScopesForScript(script_name)
62 self.assertTrue(scopes.lookup("script:" + script_name))
63
64 def test_ScopesForScript_lookup_does_not_find_other_script(self):
65 script_name = self.factory.getUniqueString()
66 scopes = ScopesForScript(script_name)
67 self.assertFalse(scopes.lookup("script:other"))
068
=== modified file 'lib/lp/services/features/webapp.py'
--- lib/lp/services/features/webapp.py 2011-01-14 21:48:57 +0000
+++ lib/lp/services/features/webapp.py 2011-03-21 05:22:30 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the1# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Connect Feature flags into webapp requests."""4"""Connect Feature flags into webapp requests."""
@@ -7,7 +7,7 @@
77
8__metaclass__ = type8__metaclass__ = type
99
10from lp.services.features import per_thread10from lp.services.features import install_feature_controller
11from lp.services.features.flags import FeatureController11from lp.services.features.flags import FeatureController
12from lp.services.features.rulesource import StormFeatureRuleSource12from lp.services.features.rulesource import StormFeatureRuleSource
13from lp.services.features.scopes import ScopesFromRequest13from lp.services.features.scopes import ScopesFromRequest
@@ -15,11 +15,13 @@
1515
16def start_request(event):16def start_request(event):
17 """Register FeatureController."""17 """Register FeatureController."""
18 event.request.features = per_thread.features = FeatureController(18 event.request.features = FeatureController(
19 ScopesFromRequest(event.request).lookup,19 ScopesFromRequest(event.request).lookup,
20 StormFeatureRuleSource())20 StormFeatureRuleSource())
21 install_feature_controller(event.request.features)
2122
2223
23def end_request(event):24def end_request(event):
24 """Done with this FeatureController."""25 """Done with this FeatureController."""
25 event.request.features = per_thread.features = None26 install_feature_controller(None)
27 event.request.features = None
2628
=== modified file 'lib/lp/services/scripts/base.py'
--- lib/lp/services/scripts/base.py 2011-02-21 00:06:55 +0000
+++ lib/lp/services/scripts/base.py 2011-03-21 05:22:30 +0000
@@ -39,6 +39,12 @@
39 setupInteractionByEmail,39 setupInteractionByEmail,
40 )40 )
41from canonical.lp import initZopeless41from canonical.lp import initZopeless
42from lp.services.features import (
43 getFeatureFlag,
44 get_relevant_feature_controller,
45 install_feature_controller,
46 make_script_feature_controller,
47 )
42from lp.services.scripts.interfaces.scriptactivity import IScriptActivitySet48from lp.services.scripts.interfaces.scriptactivity import IScriptActivitySet
4349
4450
@@ -294,6 +300,7 @@
294 @log_unhandled_exception_and_exit300 @log_unhandled_exception_and_exit
295 def run(self, use_web_security=False, isolation=None):301 def run(self, use_web_security=False, isolation=None):
296 """Actually run the script, executing zcml and initZopeless."""302 """Actually run the script, executing zcml and initZopeless."""
303
297 if isolation is None:304 if isolation is None:
298 isolation = ISOLATION_LEVEL_DEFAULT305 isolation = ISOLATION_LEVEL_DEFAULT
299 self._init_zca(use_web_security=use_web_security)306 self._init_zca(use_web_security=use_web_security)
@@ -303,6 +310,9 @@
303 profiler = None310 profiler = None
304 if self.options.profile:311 if self.options.profile:
305 profiler = Profile()312 profiler = Profile()
313
314 original_feature_controller = get_relevant_feature_controller()
315 install_feature_controller(make_script_feature_controller(self.name))
306 try:316 try:
307 if profiler:317 if profiler:
308 profiler.runcall(self.main)318 profiler.runcall(self.main)
@@ -316,6 +326,8 @@
316 else:326 else:
317 date_completed = datetime.datetime.now(UTC)327 date_completed = datetime.datetime.now(UTC)
318 self.record_activity(date_started, date_completed)328 self.record_activity(date_started, date_completed)
329 finally:
330 install_feature_controller(original_feature_controller)
319 if profiler:331 if profiler:
320 profiler.dump_stats(self.options.profile)332 profiler.dump_stats(self.options.profile)
321333
322334
=== added file 'lib/lp/services/scripts/tests/test_feature_controller.py'
--- lib/lp/services/scripts/tests/test_feature_controller.py 1970-01-01 00:00:00 +0000
+++ lib/lp/services/scripts/tests/test_feature_controller.py 2011-03-21 05:22:30 +0000
@@ -0,0 +1,61 @@
1# Copyright 2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test feature controller in scripts."""
5
6__metaclass__ = type
7
8from canonical.testing.layers import DatabaseFunctionalLayer
9from lp.services.features import (
10 get_relevant_feature_controller,
11 install_feature_controller,
12 )
13from lp.services.features.flags import NullFeatureController
14from lp.services.features.testing import FeatureFixture
15from lp.services.scripts.base import LaunchpadScript
16from lp.testing import TestCase
17from lp.testing.fakemethod import FakeMethod
18
19
20class FakeScript(LaunchpadScript):
21 """A dummy script that only records which feature controller is active."""
22
23 observed_feature_controller = object()
24
25 def __init__(self, name):
26 super(FakeScript, self).__init__(name=name, test_args=[])
27
28 def main(self):
29 self.observed_feature_controller = get_relevant_feature_controller()
30
31 # Shortcut some underpinnings of LaunchpadScript.run that we can't
32 # afford to have happen in tests.
33 _init_zca = FakeMethod()
34 _init_db = FakeMethod()
35 record_activity = FakeMethod()
36
37
38class TestScriptFeatureController(TestCase):
39 layer = DatabaseFunctionalLayer
40
41 def setUp(self):
42 super(TestScriptFeatureController, self).setUp()
43 self.original_controller = get_relevant_feature_controller()
44
45 def tearDown(self):
46 install_feature_controller(self.original_controller)
47 super(TestScriptFeatureController, self).tearDown()
48
49 def test_script_installs_script_feature_controller(self):
50 script = FakeScript(name="bongo")
51 script_feature_controller = get_relevant_feature_controller()
52 self.assertNotEqual(
53 self.original_controller, script.observed_feature_controller)
54 self.assertNotEqual(None, script.observed_feature_controller)
55
56 def test_script_restores_feature_controller(self):
57 previous_controller = NullFeatureController()
58 install_feature_controller(previous_controller)
59 FakeScript(name="mongo").run()
60 self.assertEqual(
61 previous_controller, get_relevant_feature_controller())
062
=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py 2011-03-09 18:48:09 +0000
+++ lib/lp/testing/__init__.py 2011-03-21 05:22:30 +0000
@@ -968,6 +968,7 @@
968 This prevents the events from propagating to their normal subscribers.968 This prevents the events from propagating to their normal subscribers.
969 The recorded events can be accessed via the 'events' list.969 The recorded events can be accessed via the 'events' list.
970 """970 """
971
971 def __init__(self):972 def __init__(self):
972 self.events = []973 self.events = []
973 self.old_subscribers = None974 self.old_subscribers = None
@@ -987,13 +988,13 @@
987def feature_flags():988def feature_flags():
988 """Provide a context in which feature flags work."""989 """Provide a context in which feature flags work."""
989 empty_request = LaunchpadTestRequest()990 empty_request = LaunchpadTestRequest()
990 old_features = getattr(features.per_thread, 'features', None)991 old_features = features.get_relevant_feature_controller()
991 features.per_thread.features = FeatureController(992 features.install_feature_controller(FeatureController(
992 ScopesFromRequest(empty_request).lookup)993 ScopesFromRequest(empty_request).lookup))
993 try:994 try:
994 yield995 yield
995 finally:996 finally:
996 features.per_thread.features = old_features997 features.install_feature_controller(old_features)
997998
998999
999def get_lsb_information():1000def get_lsb_information():
@@ -1102,12 +1103,12 @@
1102 """Set a feature flag to the specified value.1103 """Set a feature flag to the specified value.
11031104
1104 In order to access the flag, use the feature_flags context manager or1105 In order to access the flag, use the feature_flags context manager or
1105 populate features.per_thread.features some other way.1106 set the feature controller in some other way.
1106 :param name: The name of the flag.1107 :param name: The name of the flag.
1107 :param value: The value of the flag.1108 :param value: The value of the flag.
1108 :param scope: The scope in which the specified value applies.1109 :param scope: The scope in which the specified value applies.
1109 """1110 """
1110 assert getattr(features.per_thread, 'features', None) is not None1111 assert features.get_relevant_feature_controller() is not None
1111 flag = FeatureFlag(1112 flag = FeatureFlag(
1112 scope=scope, flag=name, value=value, priority=priority)1113 scope=scope, flag=name, value=value, priority=priority)
1113 store = getFeatureStore()1114 store = getFeatureStore()