Merge lp:~mbp/launchpad/flags-webapp into lp:launchpad
- flags-webapp
- Merge into devel
Proposed by
Martin Pool
Status: | Merged |
---|---|
Approved by: | Robert Collins |
Approved revision: | no longer in the source branch. |
Merged at revision: | 11370 |
Proposed branch: | lp:~mbp/launchpad/flags-webapp |
Merge into: | lp:launchpad |
Diff against target: |
683 lines (+428/-60) 10 files modified
lib/canonical/configure.zcml (+2/-1) lib/canonical/launchpad/webapp/servers.py (+7/-0) lib/lp/app/browser/tests/base-layout.txt (+2/-1) lib/lp/app/templates/base-layout.pt (+6/-1) lib/lp/services/features/__init__.py (+22/-3) lib/lp/services/features/configure.zcml (+19/-0) lib/lp/services/features/doc/features.txt (+127/-0) lib/lp/services/features/flags.py (+117/-38) lib/lp/services/features/tests/test_flags.py (+85/-16) lib/lp/services/features/webapp.py (+41/-0) |
To merge this branch: | bzr merge lp:~mbp/launchpad/flags-webapp |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Robert Collins (community) | Approve | ||
Review via email: mp+32833@code.launchpad.net |
Commit message
add flags webapp infrastructure
Description of the change
Since <https:/
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'lib/canonical/configure.zcml' | |||
2 | --- lib/canonical/configure.zcml 2010-07-24 00:07:54 +0000 | |||
3 | +++ lib/canonical/configure.zcml 2010-08-17 02:31:17 +0000 | |||
4 | @@ -1,4 +1,4 @@ | |||
6 | 1 | <!-- Copyright 2009 Canonical Ltd. This software is licensed under the | 1 | <!-- Copyright 2009, 2010 Canonical Ltd. This software is licensed under the |
7 | 2 | GNU Affero General Public License version 3 (see the file LICENSE). | 2 | GNU Affero General Public License version 3 (see the file LICENSE). |
8 | 3 | --> | 3 | --> |
9 | 4 | 4 | ||
10 | @@ -21,6 +21,7 @@ | |||
11 | 21 | <include package="lp.services.job" /> | 21 | <include package="lp.services.job" /> |
12 | 22 | <include package="lp.services.memcache" /> | 22 | <include package="lp.services.memcache" /> |
13 | 23 | <include package="lp.services.profile" /> | 23 | <include package="lp.services.profile" /> |
14 | 24 | <include package="lp.services.features" /> | ||
15 | 24 | <include package="lp.services.scripts" /> | 25 | <include package="lp.services.scripts" /> |
16 | 25 | <include package="lp.services.worlddata" /> | 26 | <include package="lp.services.worlddata" /> |
17 | 26 | <include package="lp.services.salesforce" /> | 27 | <include package="lp.services.salesforce" /> |
18 | 27 | 28 | ||
19 | === modified file 'lib/canonical/launchpad/webapp/servers.py' | |||
20 | --- lib/canonical/launchpad/webapp/servers.py 2010-08-02 02:23:26 +0000 | |||
21 | +++ lib/canonical/launchpad/webapp/servers.py 2010-08-17 02:31:17 +0000 | |||
22 | @@ -79,6 +79,10 @@ | |||
23 | 79 | 79 | ||
24 | 80 | from canonical.lazr.timeout import set_default_timeout_function | 80 | from canonical.lazr.timeout import set_default_timeout_function |
25 | 81 | 81 | ||
26 | 82 | from lp.services.features.flags import ( | ||
27 | 83 | NullFeatureController, | ||
28 | 84 | ) | ||
29 | 85 | |||
30 | 82 | 86 | ||
31 | 83 | class StepsToGo: | 87 | class StepsToGo: |
32 | 84 | """ | 88 | """ |
33 | @@ -838,6 +842,9 @@ | |||
34 | 838 | self.needs_datetimepicker_iframe = False | 842 | self.needs_datetimepicker_iframe = False |
35 | 839 | self.needs_json = False | 843 | self.needs_json = False |
36 | 840 | self.needs_gmap2 = False | 844 | self.needs_gmap2 = False |
37 | 845 | # stub out the FeatureController that would normally be provided by | ||
38 | 846 | # the publication mechanism | ||
39 | 847 | self.features = NullFeatureController() | ||
40 | 841 | 848 | ||
41 | 842 | @property | 849 | @property |
42 | 843 | def uuid(self): | 850 | def uuid(self): |
43 | 844 | 851 | ||
44 | === modified file 'lib/lp/app/browser/tests/base-layout.txt' | |||
45 | --- lib/lp/app/browser/tests/base-layout.txt 2010-08-04 13:38:22 +0000 | |||
46 | +++ lib/lp/app/browser/tests/base-layout.txt 2010-08-17 02:31:17 +0000 | |||
47 | @@ -135,10 +135,11 @@ | |||
48 | 135 | Has application tabs: False | 135 | Has application tabs: False |
49 | 136 | Has side portlets: False | 136 | Has side portlets: False |
50 | 137 | At least ... queries issued in ... seconds | 137 | At least ... queries issued in ... seconds |
51 | 138 | Features: {} | ||
52 | 139 | in scopes {} | ||
53 | 138 | r... | 140 | r... |
54 | 139 | --> | 141 | --> |
55 | 140 | 142 | ||
56 | 141 | |||
57 | 142 | Page Headings | 143 | Page Headings |
58 | 143 | ------------- | 144 | ------------- |
59 | 144 | 145 | ||
60 | 145 | 146 | ||
61 | === modified file 'lib/lp/app/templates/base-layout.pt' | |||
62 | --- lib/lp/app/templates/base-layout.pt 2010-08-06 15:40:39 +0000 | |||
63 | +++ lib/lp/app/templates/base-layout.pt 2010-08-17 02:31:17 +0000 | |||
64 | @@ -14,6 +14,8 @@ | |||
65 | 14 | site_message modules/canonical.config/config/launchpad/site_message; | 14 | site_message modules/canonical.config/config/launchpad/site_message; |
66 | 15 | icingroot string:${rooturl}+icing/rev${revno}; | 15 | icingroot string:${rooturl}+icing/rev${revno}; |
67 | 16 | icingroot_contrib string:${rooturl}+icing-contrib/rev${revno}; | 16 | icingroot_contrib string:${rooturl}+icing-contrib/rev${revno}; |
68 | 17 | features request/features; | ||
69 | 18 | feature_scopes request/features/scopes; | ||
70 | 17 | CONTEXTS python:{'template':template, 'context': context, 'view':view}; | 19 | CONTEXTS python:{'template':template, 'context': context, 'view':view}; |
71 | 18 | " | 20 | " |
72 | 19 | ><metal:doctype define-slot="doctype"><tal:doctype tal:replace="structure string:<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">" /></metal:doctype> | 21 | ><metal:doctype define-slot="doctype"><tal:doctype tal:replace="structure string:<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">" /></metal:doctype> |
73 | @@ -171,7 +173,7 @@ | |||
74 | 171 | 173 | ||
75 | 172 | <tal:template> | 174 | <tal:template> |
76 | 173 | <tal:comment | 175 | <tal:comment |
78 | 174 | define="log modules/canonical.launchpad.webapp.adapter/summarize_requests" | 176 | define="log modules/canonical.launchpad.webapp.adapter/summarize_requests;" |
79 | 175 | replace="structure string:<!-- | 177 | replace="structure string:<!-- |
80 | 176 | Facet name: ${view/menu:selectedfacetname} | 178 | Facet name: ${view/menu:selectedfacetname} |
81 | 177 | Page type: ${view/macro:pagetype} | 179 | Page type: ${view/macro:pagetype} |
82 | @@ -181,6 +183,9 @@ | |||
83 | 181 | 183 | ||
84 | 182 | At least ${log} | 184 | At least ${log} |
85 | 183 | 185 | ||
86 | 186 | Features: ${request/features/usedFlags} | ||
87 | 187 | in scopes ${request/features/usedScopes} | ||
88 | 188 | |||
89 | 184 | r${revno} | 189 | r${revno} |
90 | 185 | -->" /> | 190 | -->" /> |
91 | 186 | </tal:template> | 191 | </tal:template> |
92 | 187 | 192 | ||
93 | === modified file 'lib/lp/services/features/__init__.py' | |||
94 | --- lib/lp/services/features/__init__.py 2010-07-21 11:05:14 +0000 | |||
95 | +++ lib/lp/services/features/__init__.py 2010-08-17 02:31:17 +0000 | |||
96 | @@ -5,6 +5,25 @@ | |||
97 | 5 | 5 | ||
98 | 6 | These can be turned on and off by admins, and can affect particular | 6 | These can be turned on and off by admins, and can affect particular |
99 | 7 | defined scopes such as "beta users" or "production servers." | 7 | defined scopes such as "beta users" or "production servers." |
103 | 8 | 8 | """ | |
104 | 9 | See <https://dev.launchpad.net/LEP/FeatureFlags> | 9 | |
105 | 10 | """ | 10 | import threading |
106 | 11 | |||
107 | 12 | |||
108 | 13 | __all__ = [ | ||
109 | 14 | 'getFeatureFlag', | ||
110 | 15 | 'per_thread', | ||
111 | 16 | ] | ||
112 | 17 | |||
113 | 18 | |||
114 | 19 | per_thread = threading.local() | ||
115 | 20 | """Holds the default per-thread feature controller in its .features attribute. | ||
116 | 21 | |||
117 | 22 | Framework code is responsible for setting this in the appropriate context, eg | ||
118 | 23 | when starting a web request. | ||
119 | 24 | """ | ||
120 | 25 | |||
121 | 26 | |||
122 | 27 | def getFeatureFlag(flag): | ||
123 | 28 | """Get the value of a flag for this thread's scopes.""" | ||
124 | 29 | return per_thread.features.getFlag(flag) | ||
125 | 11 | 30 | ||
126 | === added file 'lib/lp/services/features/configure.zcml' | |||
127 | --- lib/lp/services/features/configure.zcml 1970-01-01 00:00:00 +0000 | |||
128 | +++ lib/lp/services/features/configure.zcml 2010-08-17 02:31:17 +0000 | |||
129 | @@ -0,0 +1,19 @@ | |||
130 | 1 | <!-- Copyright 2010 Canonical Ltd. This software is licensed under the | ||
131 | 2 | GNU Affero General Public License version 3 (see the file LICENSE). | ||
132 | 3 | --> | ||
133 | 4 | |||
134 | 5 | <configure | ||
135 | 6 | xmlns="http://namespaces.zope.org/zope" | ||
136 | 7 | xmlns:browser="http://namespaces.zope.org/browser"> | ||
137 | 8 | |||
138 | 9 | <subscriber | ||
139 | 10 | for="canonical.launchpad.webapp.interfaces.IStartRequestEvent" | ||
140 | 11 | handler="lp.services.features.webapp.start_request" | ||
141 | 12 | /> | ||
142 | 13 | |||
143 | 14 | <subscriber | ||
144 | 15 | for="zope.app.publication.interfaces.IEndRequestEvent" | ||
145 | 16 | handler="lp.services.features.webapp.end_request" | ||
146 | 17 | /> | ||
147 | 18 | |||
148 | 19 | </configure> | ||
149 | 0 | 20 | ||
150 | === added directory 'lib/lp/services/features/doc' | |||
151 | === added file 'lib/lp/services/features/doc/features.txt' | |||
152 | --- lib/lp/services/features/doc/features.txt 1970-01-01 00:00:00 +0000 | |||
153 | +++ lib/lp/services/features/doc/features.txt 2010-08-17 02:31:17 +0000 | |||
154 | @@ -0,0 +1,127 @@ | |||
155 | 1 | **************************** | ||
156 | 2 | Feature Flag Developer Guide | ||
157 | 3 | **************************** | ||
158 | 4 | |||
159 | 5 | Introduction | ||
160 | 6 | ************ | ||
161 | 7 | |||
162 | 8 | The point of feature flags is to let us turn some features of Launchpad on | ||
163 | 9 | and off without changing the code or restarting the application, and to | ||
164 | 10 | expose different features to different subsets of users. | ||
165 | 11 | |||
166 | 12 | See <https://dev.launchpad.net/LEP/FeatureFlags> for more discussion and | ||
167 | 13 | rationale. | ||
168 | 14 | |||
169 | 15 | The typical use for feature flags is within web page requests but they can | ||
170 | 16 | also be used in asynchronous jobs or apis or other parts of Launchpad. | ||
171 | 17 | |||
172 | 18 | Internal model for feature flags | ||
173 | 19 | ******************************** | ||
174 | 20 | |||
175 | 21 | A feature flag maps from a *name* to a *value*. The specific value used | ||
176 | 22 | for a particular request is determined by a set of zero or more *scopes* | ||
177 | 23 | that apply to that request, by finding the *rule* with the highest | ||
178 | 24 | *priority*. | ||
179 | 25 | |||
180 | 26 | Flags are defined by a *name* that typically looks like a Python | ||
181 | 27 | identifier, for example ``notification.global.text``. A definition is | ||
182 | 28 | given for a particular *scope*, which also looks like a dotted identifier, | ||
183 | 29 | for example ``user.beta`` or ``server.edge``. This is just a naming | ||
184 | 30 | convention, and they do not need to correspond to Python modules. | ||
185 | 31 | |||
186 | 32 | The value is stored in the database as just a Unicode string, and it might | ||
187 | 33 | be interpreted as a boolean, number, human-readable string or whatever. | ||
188 | 34 | |||
189 | 35 | The default for flags is to be None if they're not set in the database, so | ||
190 | 36 | that should be a sensible baseline default state. | ||
191 | 37 | |||
192 | 38 | Performance model | ||
193 | 39 | ***************** | ||
194 | 40 | |||
195 | 41 | Flags are supposed to be cheap enough that you can introduce them without | ||
196 | 42 | causing a performance concern. | ||
197 | 43 | |||
198 | 44 | If the page does not check any flags, no extra work will be done. The | ||
199 | 45 | first time a page checks a flag, all the rules will be read from the | ||
200 | 46 | database and held in memory for the duration of the request. | ||
201 | 47 | |||
202 | 48 | Scopes may be expensive in some cases, such as checking group membership. | ||
203 | 49 | Whether a scope is active or not is looked up the first time it's needed | ||
204 | 50 | within a particular request. | ||
205 | 51 | |||
206 | 52 | The standard page footer identifies the flags and scopes that were | ||
207 | 53 | actually used by the page. | ||
208 | 54 | |||
209 | 55 | Naming conventions | ||
210 | 56 | ****************** | ||
211 | 57 | |||
212 | 58 | We have naming conventions for feature flags and scopes, so that people can | ||
213 | 59 | understand the likely impact of a particular flag and so they can find all | ||
214 | 60 | the flags likely to affect a feature. | ||
215 | 61 | |||
216 | 62 | So for any flag we want to say: | ||
217 | 63 | |||
218 | 64 | * What application area does this affect? (malone, survey, questions, | ||
219 | 65 | code, etc) | ||
220 | 66 | |||
221 | 67 | * What specific feature does it change? | ||
222 | 68 | |||
223 | 69 | * What affect does it have on this feature? The most common is "enabled" | ||
224 | 70 | but for some other we want to specify a specific value as well such as | ||
225 | 71 | "date" or "size". | ||
226 | 72 | |||
227 | 73 | These are concatenated with dots so the overall feature name looks a bit | ||
228 | 74 | like a Python module name. | ||
229 | 75 | |||
230 | 76 | A similar approach is used for scopes. | ||
231 | 77 | |||
232 | 78 | Checking flags in page templates | ||
233 | 79 | ******************************** | ||
234 | 80 | |||
235 | 81 | You can conditionally show some text like this:: | ||
236 | 82 | |||
237 | 83 | <tal:survey condition="features/user_survey.enabled"> | ||
238 | 84 | • | ||
239 | 85 | <a href="http://survey.example.com/">Take our survey!</a> | ||
240 | 86 | </tal:survey> | ||
241 | 87 | |||
242 | 88 | You can use the built-in TAL feature of prepending ``not:`` to the | ||
243 | 89 | condition, and for flags that have a value you could use them in | ||
244 | 90 | ``tal:replace`` or ``tal:attributes``. | ||
245 | 91 | |||
246 | 92 | If you just want to simply insert some text taken from a feature, say | ||
247 | 93 | something like:: | ||
248 | 94 | |||
249 | 95 | Message of the day: ${motd.text} | ||
250 | 96 | |||
251 | 97 | Templates can also check whether the request is in a particular scope, but | ||
252 | 98 | before using this consider whether the code will always be bound to that | ||
253 | 99 | scope or whether it would be more correct to define a new feature:: | ||
254 | 100 | |||
255 | 101 | <p tal:condition="feature_scopes/server.staging"> | ||
256 | 102 | Staging server: all data will be discarded daily!</p> | ||
257 | 103 | |||
258 | 104 | Checking flags in code | ||
259 | 105 | ********************** | ||
260 | 106 | |||
261 | 107 | The Zope traversal code establishes a `FeatureController` for the duration | ||
262 | 108 | of a request. The object can be obtained through either | ||
263 | 109 | `request.features` or `lp.services.features.per_thread.features`. This | ||
264 | 110 | provides various useful methods including `getFlag` to look up one feature | ||
265 | 111 | (memoized), and `isInScope` to check one scope (also memoized). | ||
266 | 112 | |||
267 | 113 | As a convenience, `lp.services.features.getFeatureFlag` looks up a single | ||
268 | 114 | flag in the thread default controller. | ||
269 | 115 | |||
270 | 116 | Debugging feature usage | ||
271 | 117 | *********************** | ||
272 | 118 | |||
273 | 119 | The flags active during a page request, and the scopes that were looked | ||
274 | 120 | up are visible in the comment at the bottom of every standard Launchpad | ||
275 | 121 | page. | ||
276 | 122 | |||
277 | 123 | Defining scopes | ||
278 | 124 | *************** | ||
279 | 125 | |||
280 | 126 | |||
281 | 127 | .. vim: ft=rst | ||
282 | 0 | 128 | ||
283 | === modified file 'lib/lp/services/features/flags.py' | |||
284 | --- lib/lp/services/features/flags.py 2010-07-22 12:45:51 +0000 | |||
285 | +++ lib/lp/services/features/flags.py 2010-08-17 02:31:17 +0000 | |||
286 | @@ -1,17 +1,47 @@ | |||
287 | 1 | # Copyright 2010 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2010 Canonical Ltd. This software is licensed under the |
288 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
289 | 3 | 3 | ||
291 | 4 | __all__ = ['FeatureController'] | 4 | __all__ = [ |
292 | 5 | 'FeatureController', | ||
293 | 6 | 'NullFeatureController', | ||
294 | 7 | ] | ||
295 | 8 | |||
296 | 5 | 9 | ||
297 | 6 | __metaclass__ = type | 10 | __metaclass__ = type |
298 | 7 | 11 | ||
299 | 8 | 12 | ||
300 | 13 | from storm.locals import Desc | ||
301 | 14 | |||
302 | 9 | from lp.services.features.model import ( | 15 | from lp.services.features.model import ( |
303 | 16 | FeatureFlag, | ||
304 | 10 | getFeatureStore, | 17 | getFeatureStore, |
305 | 11 | FeatureFlag, | ||
306 | 12 | ) | 18 | ) |
307 | 13 | 19 | ||
308 | 14 | 20 | ||
309 | 21 | class Memoize(object): | ||
310 | 22 | |||
311 | 23 | def __init__(self, calc): | ||
312 | 24 | self._known = {} | ||
313 | 25 | self._calc = calc | ||
314 | 26 | |||
315 | 27 | def lookup(self, key): | ||
316 | 28 | if key in self._known: | ||
317 | 29 | return self._known[key] | ||
318 | 30 | v = self._calc(key) | ||
319 | 31 | self._known[key] = v | ||
320 | 32 | return v | ||
321 | 33 | |||
322 | 34 | |||
323 | 35 | class ScopeDict(object): | ||
324 | 36 | """Allow scopes to be looked up by getitem""" | ||
325 | 37 | |||
326 | 38 | def __init__(self, features): | ||
327 | 39 | self.features = features | ||
328 | 40 | |||
329 | 41 | def __getitem__(self, scope_name): | ||
330 | 42 | return self.features.isInScope(scope_name) | ||
331 | 43 | |||
332 | 44 | |||
333 | 15 | class FeatureController(object): | 45 | class FeatureController(object): |
334 | 16 | """A FeatureController tells application code what features are active. | 46 | """A FeatureController tells application code what features are active. |
335 | 17 | 47 | ||
336 | @@ -34,52 +64,101 @@ | |||
337 | 34 | be one per web app request. | 64 | be one per web app request. |
338 | 35 | 65 | ||
339 | 36 | Intended performance: when this object is first constructed, it will read | 66 | Intended performance: when this object is first constructed, it will read |
343 | 37 | the whole current feature flags from the database. This will take a few | 67 | the whole feature flag table from the database. It is expected to be |
344 | 38 | ms. The controller is then supposed to be held in a thread-local for the | 68 | reasonably small. The scopes may be expensive to compute (eg checking |
345 | 39 | duration of the request. | 69 | team membership) so they are checked at most once when they are first |
346 | 70 | needed. | ||
347 | 71 | |||
348 | 72 | The controller is then supposed to be held in a thread-local and reused | ||
349 | 73 | for the duration of the request. | ||
350 | 40 | 74 | ||
351 | 41 | See <https://dev.launchpad.net/LEP/FeatureFlags> | 75 | See <https://dev.launchpad.net/LEP/FeatureFlags> |
352 | 42 | """ | 76 | """ |
353 | 43 | 77 | ||
355 | 44 | def __init__(self, scopes): | 78 | def __init__(self, scope_check_callback): |
356 | 45 | """Construct a new view of the features for a set of scopes. | 79 | """Construct a new view of the features for a set of scopes. |
367 | 46 | """ | 80 | |
368 | 47 | self._store = getFeatureStore() | 81 | :param scope_check_callback: Given a scope name, says whether |
369 | 48 | self._scopes = self._preenScopes(scopes) | 82 | it's active or not. |
370 | 49 | self._cached_flags = self._queryAllFlags() | 83 | """ |
371 | 50 | 84 | self._known_scopes = Memoize(scope_check_callback) | |
372 | 51 | def getScopes(self): | 85 | self._known_flags = Memoize(self._checkFlag) |
373 | 52 | return frozenset(self._scopes) | 86 | # rules are read from the database the first time they're needed |
374 | 53 | 87 | self._rules = None | |
375 | 54 | def getFlag(self, flag_name): | 88 | self.scopes = ScopeDict(self) |
376 | 55 | return self._cached_flags.get(flag_name) | 89 | |
377 | 90 | def getFlag(self, flag): | ||
378 | 91 | """Get the value of a specific flag. | ||
379 | 92 | |||
380 | 93 | :param flag: A name to lookup. e.g. 'recipes.enabled' | ||
381 | 94 | :return: The value of the flag determined by the highest priority rule | ||
382 | 95 | that matched. | ||
383 | 96 | """ | ||
384 | 97 | return self._known_flags.lookup(flag) | ||
385 | 98 | |||
386 | 99 | def _checkFlag(self, flag): | ||
387 | 100 | self._needRules() | ||
388 | 101 | if flag in self._rules: | ||
389 | 102 | for scope, value in self._rules[flag]: | ||
390 | 103 | if self._known_scopes.lookup(scope): | ||
391 | 104 | return value | ||
392 | 105 | |||
393 | 106 | def isInScope(self, scope): | ||
394 | 107 | return self._known_scopes.lookup(scope) | ||
395 | 108 | |||
396 | 109 | def __getitem__(self, flag_name): | ||
397 | 110 | """FeatureController can be indexed. | ||
398 | 111 | |||
399 | 112 | This is to support easy zope traversal through eg | ||
400 | 113 | "request/features/a.b.c". We don't support other collection | ||
401 | 114 | protocols. | ||
402 | 115 | |||
403 | 116 | Note that calling this the first time for any key may cause | ||
404 | 117 | arbitrarily large amounts of work to be done to determine if the | ||
405 | 118 | controller is in any scopes relevant to this flag. | ||
406 | 119 | """ | ||
407 | 120 | return self.getFlag(flag_name) | ||
408 | 56 | 121 | ||
409 | 57 | def getAllFlags(self): | 122 | def getAllFlags(self): |
411 | 58 | """Get the feature flags active for the current scopes. | 123 | """Return a dict of all active flags. |
412 | 59 | 124 | ||
414 | 60 | :returns: dict from flag_name (unicode) to value (unicode). | 125 | This may be expensive because of evaluating many scopes, so it |
415 | 126 | shouldn't normally be used by code that only wants to know about one | ||
416 | 127 | or a few flags. | ||
417 | 61 | """ | 128 | """ |
419 | 62 | return dict(self._cached_flags) | 129 | self._needRules() |
420 | 130 | return dict((f, self.getFlag(f)) for f in self._rules) | ||
421 | 63 | 131 | ||
423 | 64 | def _queryAllFlags(self): | 132 | def _loadRules(self): |
424 | 133 | store = getFeatureStore() | ||
425 | 65 | d = {} | 134 | d = {} |
433 | 66 | rs = (self._store | 135 | rs = (store |
434 | 67 | .find(FeatureFlag, | 136 | .find(FeatureFlag) |
435 | 68 | FeatureFlag.scope.is_in(self._scopes)) | 137 | .order_by(Desc(FeatureFlag.priority)) |
436 | 69 | .order_by(FeatureFlag.priority) | 138 | .values(FeatureFlag.flag, FeatureFlag.scope, |
437 | 70 | .values(FeatureFlag.flag, FeatureFlag.value)) | 139 | FeatureFlag.value)) |
438 | 71 | for flag, value in rs: | 140 | for flag, scope, value in rs: |
439 | 72 | d[str(flag)] = value | 141 | d.setdefault(str(flag), []).append((str(scope), value)) |
440 | 73 | return d | 142 | return d |
441 | 74 | 143 | ||
453 | 75 | def _preenScopes(self, scopes): | 144 | def _needRules(self): |
454 | 76 | # for convenience turn strings to unicode | 145 | if self._rules is None: |
455 | 77 | us = [] | 146 | self._rules = self._loadRules() |
456 | 78 | for s in scopes: | 147 | |
457 | 79 | if isinstance(s, unicode): | 148 | def usedFlags(self): |
458 | 80 | us.append(s) | 149 | """Return dict of flags used in this controller so far.""" |
459 | 81 | elif isinstance(s, str): | 150 | return dict(self._known_flags._known) |
460 | 82 | us.append(unicode(s)) | 151 | |
461 | 83 | else: | 152 | def usedScopes(self): |
462 | 84 | raise TypeError("invalid scope: %r" % s) | 153 | """Return {scope: active} for scopes that have been used so far.""" |
463 | 85 | return us | 154 | return dict(self._known_scopes._known) |
464 | 155 | |||
465 | 156 | |||
466 | 157 | class NullFeatureController(FeatureController): | ||
467 | 158 | """For use in testing: everything is turned off""" | ||
468 | 159 | |||
469 | 160 | def __init__(self): | ||
470 | 161 | FeatureController.__init__(self, lambda scope: None) | ||
471 | 162 | |||
472 | 163 | def _loadRules(self): | ||
473 | 164 | return [] | ||
474 | 86 | 165 | ||
475 | === modified file 'lib/lp/services/features/tests/test_flags.py' | |||
476 | --- lib/lp/services/features/tests/test_flags.py 2010-07-22 12:45:51 +0000 | |||
477 | +++ lib/lp/services/features/tests/test_flags.py 2010-08-17 02:31:17 +0000 | |||
478 | @@ -12,7 +12,11 @@ | |||
479 | 12 | from canonical.testing import layers | 12 | from canonical.testing import layers |
480 | 13 | from lp.testing import TestCase | 13 | from lp.testing import TestCase |
481 | 14 | 14 | ||
483 | 15 | from lp.services.features import model | 15 | from lp.services.features import ( |
484 | 16 | getFeatureFlag, | ||
485 | 17 | model, | ||
486 | 18 | per_thread, | ||
487 | 19 | ) | ||
488 | 16 | from lp.services.features.flags import ( | 20 | from lp.services.features.flags import ( |
489 | 17 | FeatureController, | 21 | FeatureController, |
490 | 18 | ) | 22 | ) |
491 | @@ -20,11 +24,10 @@ | |||
492 | 20 | 24 | ||
493 | 21 | notification_name = 'notification.global.text' | 25 | notification_name = 'notification.global.text' |
494 | 22 | notification_value = u'\N{SNOWMAN} stormy Launchpad weather ahead' | 26 | notification_value = u'\N{SNOWMAN} stormy Launchpad weather ahead' |
495 | 23 | example_scope = 'beta_user' | ||
496 | 24 | 27 | ||
497 | 25 | 28 | ||
498 | 26 | testdata = [ | 29 | testdata = [ |
500 | 27 | (example_scope, notification_name, notification_value, 100), | 30 | ('beta_user', notification_name, notification_value, 100), |
501 | 28 | ('default', 'ui.icing', u'3.0', 100), | 31 | ('default', 'ui.icing', u'3.0', 100), |
502 | 29 | ('beta_user', 'ui.icing', u'4.0', 300), | 32 | ('beta_user', 'ui.icing', u'4.0', 300), |
503 | 30 | ] | 33 | ] |
504 | @@ -40,6 +43,14 @@ | |||
505 | 40 | from storm.tracer import debug | 43 | from storm.tracer import debug |
506 | 41 | debug(True) | 44 | debug(True) |
507 | 42 | 45 | ||
508 | 46 | def makeControllerInScopes(self, scopes): | ||
509 | 47 | """Make a controller that will report it's in the given scopes.""" | ||
510 | 48 | call_log = [] | ||
511 | 49 | def scope_cb(scope): | ||
512 | 50 | call_log.append(scope) | ||
513 | 51 | return scope in scopes | ||
514 | 52 | return FeatureController(scope_cb), call_log | ||
515 | 53 | |||
516 | 43 | def populateStore(self): | 54 | def populateStore(self): |
517 | 44 | store = model.getFeatureStore() | 55 | store = model.getFeatureStore() |
518 | 45 | for (scope, flag, value, priority) in testdata: | 56 | for (scope, flag, value, priority) in testdata: |
519 | @@ -49,37 +60,51 @@ | |||
520 | 49 | value=value, | 60 | value=value, |
521 | 50 | priority=priority)) | 61 | priority=priority)) |
522 | 51 | 62 | ||
523 | 52 | def test_defaultFlags(self): | ||
524 | 53 | # the sample db has no flags set | ||
525 | 54 | control = FeatureController([]) | ||
526 | 55 | self.assertEqual({}, | ||
527 | 56 | control.getAllFlags()) | ||
528 | 57 | |||
529 | 58 | def test_getFlag(self): | 63 | def test_getFlag(self): |
530 | 59 | self.populateStore() | 64 | self.populateStore() |
532 | 60 | control = FeatureController(['default']) | 65 | control, call_log = self.makeControllerInScopes(['default']) |
533 | 61 | self.assertEqual(u'3.0', | 66 | self.assertEqual(u'3.0', |
534 | 62 | control.getFlag('ui.icing')) | 67 | control.getFlag('ui.icing')) |
535 | 68 | self.assertEqual(['beta_user', 'default'], call_log) | ||
536 | 69 | |||
537 | 70 | def test_getItem(self): | ||
538 | 71 | # for use in page templates, the flags can be treated as a dict | ||
539 | 72 | self.populateStore() | ||
540 | 73 | control, call_log = self.makeControllerInScopes(['default']) | ||
541 | 74 | self.assertEqual(u'3.0', | ||
542 | 75 | control['ui.icing']) | ||
543 | 76 | self.assertEqual(['beta_user', 'default'], call_log) | ||
544 | 77 | # after looking this up the value is known and the scopes are | ||
545 | 78 | # positively and negatively cached | ||
546 | 79 | self.assertEqual({'ui.icing': u'3.0'}, control.usedFlags()) | ||
547 | 80 | self.assertEqual(dict(beta_user=False, default=True), | ||
548 | 81 | control.usedScopes()) | ||
549 | 63 | 82 | ||
550 | 64 | def test_getAllFlags(self): | 83 | def test_getAllFlags(self): |
551 | 65 | # can fetch all the active flags, and it gives back only the | 84 | # can fetch all the active flags, and it gives back only the |
553 | 66 | # highest-priority settings | 85 | # highest-priority settings. this may be expensive and shouldn't |
554 | 86 | # normally be used. | ||
555 | 67 | self.populateStore() | 87 | self.populateStore() |
557 | 68 | control = FeatureController(['default', 'beta_user']) | 88 | control, call_log = self.makeControllerInScopes( |
558 | 89 | ['beta_user', 'default']) | ||
559 | 69 | self.assertEqual( | 90 | self.assertEqual( |
560 | 70 | {'ui.icing': '4.0', | 91 | {'ui.icing': '4.0', |
561 | 71 | notification_name: notification_value}, | 92 | notification_name: notification_value}, |
562 | 72 | control.getAllFlags()) | 93 | control.getAllFlags()) |
563 | 94 | # evaluates all necessary flags; in this test data beta_user shadows | ||
564 | 95 | # default settings | ||
565 | 96 | self.assertEqual(['beta_user'], call_log) | ||
566 | 73 | 97 | ||
567 | 74 | def test_overrideFlag(self): | 98 | def test_overrideFlag(self): |
568 | 75 | # if there are multiple settings for a flag, and they match multiple | 99 | # if there are multiple settings for a flag, and they match multiple |
569 | 76 | # scopes, the priorities determine which is matched | 100 | # scopes, the priorities determine which is matched |
570 | 77 | self.populateStore() | 101 | self.populateStore() |
572 | 78 | default_control = FeatureController(['default']) | 102 | default_control, call_log = self.makeControllerInScopes(['default']) |
573 | 79 | self.assertEqual( | 103 | self.assertEqual( |
574 | 80 | u'3.0', | 104 | u'3.0', |
575 | 81 | default_control.getFlag('ui.icing')) | 105 | default_control.getFlag('ui.icing')) |
577 | 82 | beta_control = FeatureController(['default', 'beta_user']) | 106 | beta_control, call_log = self.makeControllerInScopes( |
578 | 107 | ['beta_user', 'default']) | ||
579 | 83 | self.assertEqual( | 108 | self.assertEqual( |
580 | 84 | u'4.0', | 109 | u'4.0', |
581 | 85 | beta_control.getFlag('ui.icing')) | 110 | beta_control.getFlag('ui.icing')) |
582 | @@ -87,9 +112,53 @@ | |||
583 | 87 | def test_undefinedFlag(self): | 112 | def test_undefinedFlag(self): |
584 | 88 | # if the flag is not defined, we get None | 113 | # if the flag is not defined, we get None |
585 | 89 | self.populateStore() | 114 | self.populateStore() |
587 | 90 | control = FeatureController(['default', 'beta_user']) | 115 | control, call_log = self.makeControllerInScopes( |
588 | 116 | ['beta_user', 'default']) | ||
589 | 91 | self.assertIs(None, | 117 | self.assertIs(None, |
590 | 92 | control.getFlag('unknown_flag')) | 118 | control.getFlag('unknown_flag')) |
592 | 93 | no_scope_flags = FeatureController([]) | 119 | no_scope_flags, call_log = self.makeControllerInScopes([]) |
593 | 94 | self.assertIs(None, | 120 | self.assertIs(None, |
594 | 95 | no_scope_flags.getFlag('ui.icing')) | 121 | no_scope_flags.getFlag('ui.icing')) |
595 | 122 | |||
596 | 123 | def test_threadGetFlag(self): | ||
597 | 124 | self.populateStore() | ||
598 | 125 | # the start-of-request handler will do something like this: | ||
599 | 126 | per_thread.features, call_log = self.makeControllerInScopes( | ||
600 | 127 | ['default', 'beta_user']) | ||
601 | 128 | try: | ||
602 | 129 | # then application code can simply ask without needing a context | ||
603 | 130 | # object | ||
604 | 131 | self.assertEqual(u'4.0', getFeatureFlag('ui.icing')) | ||
605 | 132 | finally: | ||
606 | 133 | per_thread.features = None | ||
607 | 134 | |||
608 | 135 | def testLazyScopeLookup(self): | ||
609 | 136 | # feature scopes may be a bit expensive to look up, so we do it only | ||
610 | 137 | # when it will make a difference to the result. | ||
611 | 138 | self.populateStore() | ||
612 | 139 | f, call_log = self.makeControllerInScopes(['beta_user']) | ||
613 | 140 | self.assertEqual(u'4.0', f.getFlag('ui.icing')) | ||
614 | 141 | # to calculate this it should only have had to check we're in the | ||
615 | 142 | # beta_users scope; nothing else makes a difference | ||
616 | 143 | self.assertEqual(dict(beta_user=True), f._known_scopes._known) | ||
617 | 144 | |||
618 | 145 | def testUnknownFeature(self): | ||
619 | 146 | # looking up an unknown feature gives you None | ||
620 | 147 | self.populateStore() | ||
621 | 148 | f, call_log = self.makeControllerInScopes([]) | ||
622 | 149 | self.assertEqual(None, f.getFlag('unknown')) | ||
623 | 150 | # no scopes need to be checked because it's just not in the database | ||
624 | 151 | # and there's no point checking | ||
625 | 152 | self.assertEqual({}, f._known_scopes._known) | ||
626 | 153 | self.assertEquals([], call_log) | ||
627 | 154 | # however, this we have now negative-cached the flag | ||
628 | 155 | self.assertEqual(dict(unknown=None), f.usedFlags()) | ||
629 | 156 | self.assertEqual(dict(), f.usedScopes()) | ||
630 | 157 | |||
631 | 158 | def testScopeDict(self): | ||
632 | 159 | # can get scopes as a dict, for use by "feature_scopes/server.demo" | ||
633 | 160 | f, call_log = self.makeControllerInScopes(['beta_user']) | ||
634 | 161 | self.assertEqual(True, f.scopes['beta_user']) | ||
635 | 162 | self.assertEqual(False, f.scopes['alpha_user']) | ||
636 | 163 | self.assertEqual(True, f.scopes['beta_user']) | ||
637 | 164 | self.assertEqual(['beta_user', 'alpha_user'], call_log) | ||
638 | 96 | 165 | ||
639 | === added file 'lib/lp/services/features/webapp.py' | |||
640 | --- lib/lp/services/features/webapp.py 1970-01-01 00:00:00 +0000 | |||
641 | +++ lib/lp/services/features/webapp.py 2010-08-17 02:31:17 +0000 | |||
642 | @@ -0,0 +1,41 @@ | |||
643 | 1 | # Copyright 2010 Canonical Ltd. This software is licensed under the | ||
644 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
645 | 3 | |||
646 | 4 | """Connect Feature flags into webapp requests.""" | ||
647 | 5 | |||
648 | 6 | __all__ = [] | ||
649 | 7 | |||
650 | 8 | __metaclass__ = type | ||
651 | 9 | |||
652 | 10 | import canonical.config | ||
653 | 11 | |||
654 | 12 | from lp.services.features import ( | ||
655 | 13 | per_thread, | ||
656 | 14 | ) | ||
657 | 15 | from lp.services.features.flags import ( | ||
658 | 16 | FeatureController, | ||
659 | 17 | ) | ||
660 | 18 | |||
661 | 19 | |||
662 | 20 | class ScopesFromRequest(object): | ||
663 | 21 | """Identify feature scopes based on request state.""" | ||
664 | 22 | |||
665 | 23 | def __init__(self, request): | ||
666 | 24 | self._request = request | ||
667 | 25 | |||
668 | 26 | def lookup(self, scope_name): | ||
669 | 27 | parts = scope_name.split('.') | ||
670 | 28 | if len(parts) == 2: | ||
671 | 29 | if parts[0] == 'server': | ||
672 | 30 | return canonical.config.config['launchpad']['is_' + parts[1]] | ||
673 | 31 | |||
674 | 32 | |||
675 | 33 | def start_request(event): | ||
676 | 34 | """Register FeatureController.""" | ||
677 | 35 | event.request.features = per_thread.features = FeatureController( | ||
678 | 36 | ScopesFromRequest(event.request).lookup) | ||
679 | 37 | |||
680 | 38 | |||
681 | 39 | def end_request(event): | ||
682 | 40 | """Done with this FeatureController.""" | ||
683 | 41 | event.request.features = per_thread.features = None |
naturally, please do so.