Merge lp:~wallyworld/lazr.restful/propogate-notifications into lp:lazr.restful

Proposed by Ian Booth
Status: Merged
Merge reported by: Ian Booth
Merged at revision: not available
Proposed branch: lp:~wallyworld/lazr.restful/propogate-notifications
Merge into: lp:lazr.restful
Diff against target: 329 lines (+149/-18)
7 files modified
setup.py (+1/-0)
src/lazr/restful/NEWS.txt (+7/-1)
src/lazr/restful/_resource.py (+0/-2)
src/lazr/restful/interfaces/_rest.py (+34/-3)
src/lazr/restful/publisher.py (+49/-9)
src/lazr/restful/tests/test_webservice.py (+57/-3)
versions.cfg (+1/-0)
To merge this branch: bzr merge lp:~wallyworld/lazr.restful/propogate-notifications
Reviewer Review Type Date Requested Status
Benji York (community) code Approve
Review via email: mp+54690@code.launchpad.net

Description of the change

Add support for propagating notifications resulting from a server call back to the client. The notifications are json encoded in a response header attribute (XHR-Notifications). An adapter is used to allow the web server the capability of providing the notifications to be used. If no adapter is defined, no notifications are sent.

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

I like how this is shaping up. The test in test_webservice.py is great. Here are my comments on the design:

1. Custom HTTP headers need to start with X-, and by convention our custom HTTP headers start with X-Lazr-. So XHR-Notifications should be X-Lazr-XHR-Notifications. But there's nothing XHR-specific about these notifications. That's how you're using them now, but other clients could use them for other purposes. So I recommend just X-Lazr-Notification.

2. The more complicated the value of the HTTP header, the less comfortable I feel. My ideal is for HTTP header values to be plain text. If client support allows it, I would prefer to see each notification sent in a separate copy of the header:

X-Lazr-Notification: ['DEBUG', 'Notification 1']
X-Lazr-Notification: ['WARN', 'Notification 2']

Or, even better:

X-Lazr-Notification: DEBUG: Notification 1
X-Lazr-Notification: WARN: Notification 2

Or:

X-Lazr-Notification-DEBUG: Notification 1
X-Lazr-Notification-WARN: Notification 2

It's up to you--just keep in mind that non-Ajax clients might want this information as well.

3. I don't think you need an INotificationsProvider implementation in simple.py. It's a really simple class and any real implementation needs a source for the notifications anyway. If you want to use it in the example/base doctests (see below), you could

4. You register a notification adapter in example/base, but you don't use it in any of the example/base doctests. There's no reason to put something in the example web service if you don't demonstrate the functionality. You can either take out the notification adapter (along with the simple.py implementation), or you can add a demonstration to the example/base doctests. For an optional feature like this, I think a unit test is sufficient.

Revision history for this message
Ian Booth (wallyworld) wrote :

> 1. Custom HTTP headers need to start with X-, and by convention our custom
> HTTP headers start with X-Lazr-. So XHR-Notifications should be X-Lazr-XHR-
> Notifications. But there's nothing XHR-specific about these notifications.
> That's how you're using them now, but other clients could use them for other
> purposes. So I recommend just X-Lazr-Notification.
>

Done

> 2. The more complicated the value of the HTTP header, the less comfortable I
> feel. My ideal is for HTTP header values to be plain text. If client support
> allows it, I would prefer to see each notification sent in a separate copy of
> the header:
>
> X-Lazr-Notification: ['DEBUG', 'Notification 1']
> X-Lazr-Notification: ['WARN', 'Notification 2']
>
<snip>

I may be wrong - RFC2616 says
"Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma"

Since the notification text values may be escaped html and totally free form text, it's not possible I think to do what is suggested above. The only way I can see at this point is to encode the notification text values as a json string and send as one header value.

>
> 3. I don't think you need an INotificationsProvider implementation in
> simple.py. It's a really simple class and any real implementation needs a
> source for the notifications anyway. If you want to use it in the example/base
> doctests (see below), you could
>
> 4. You register a notification adapter in example/base, but you don't use it
> in any of the example/base doctests. There's no reason to put something in the
> example web service if you don't demonstrate the functionality. You can
> either take out the notification adapter (along with the simple.py
> implementation), or you can add a demonstration to the example/base doctests.
> For an optional feature like this, I think a unit test is sufficient.

Removed from examples etc and just retained the unit test.

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

On Fri, Mar 25, 2011 at 9:25 AM, Ian Booth <email address hidden> wrote:
>> 1. Custom HTTP headers need to start with X-, and by convention our custom
>> HTTP headers start with X-Lazr-. So XHR-Notifications should be X-Lazr-XHR-
>> Notifications. But there's nothing XHR-specific about these notifications.
>> That's how you're using them now, but other clients could use them for other
>> purposes. So I recommend just X-Lazr-Notification.
>>
>
> Done
>
>> 2. The more complicated the value of the HTTP header, the less comfortable I
>> feel. My ideal is for HTTP header values to be plain text. If client support
>> allows it, I would prefer to see each notification sent in a separate copy of
>> the header:
>>
>> X-Lazr-Notification: ['DEBUG', 'Notification 1']
>> X-Lazr-Notification: ['WARN', 'Notification 2']
>>
> <snip>
>
> I may be wrong - RFC2616 says
> "Multiple message-header fields with the same field-name MAY be
> present in a message if and only if the entire field-value for that
> header field is defined as a comma-separated list [i.e., #(values)].
> It MUST be possible to combine the multiple header fields into one
> "field-name: field-value" pair, without changing the semantics of the
> message, by appending each subsequent field-value to the first, each
> separated by a comma"

Yep. Unfortunately multiple headers aren't really meant to be "true"
sequences just shorthand for a comma-separated list. We could escape
any commas in the headers but we're starting to get hacky.

> Since the notification text values may be escaped html and totally
> free form text, it's not possible I think to do what is suggested
> above. The only way I can see at this point is to encode the
> notification text values as a json string and send as one header
> value.

Do we really want to allow arbitrary HTML? For example, what if I have
a GUI client and want to display these messages? I would have to use an
HTML renderer to be able to show them sanely. We could allow a small
subset of HTML like just anchor tags, but even that places a pretty big
burden on non-browser clients.

I propose that we start with a type (DEBUG, WARNING, etc.) and a
plain-text string. That may mean that we have to audit/rework the
existing notifications to make them compatible.
--
Benji York

Revision history for this message
Ian Booth (wallyworld) wrote :

>
>> Since the notification text values may be escaped html and totally
>> free form text, it's not possible I think to do what is suggested
>> above. The only way I can see at this point is to encode the
>> notification text values as a json string and send as one header
>> value.
>
> Do we really want to allow arbitrary HTML? For example, what if I have
> a GUI client and want to display these messages? I would have to use an
> HTML renderer to be able to show them sanely. We could allow a small
> subset of HTML like just anchor tags, but even that places a pretty big
> burden on non-browser clients.
>
> I propose that we start with a type (DEBUG, WARNING, etc.) and a
> plain-text string. That may mean that we have to audit/rework the
> existing notifications to make them compatible.

Trouble is, all the launchpad usages of notifications - well very many -
stuff html in there using structured(). The assumption on launchpad's
part is that these notifications are being displayed on an html client.
The lazr restful stuff is making no assumptions about the content of the
notifications. It just runs the content through json and sends it along.
It's up to client and server to ensure that stuff sent is acceptable. In
launchpad's case, client.js grabs the html and displays it. If launchpad
currently assumes it's to send notifications as html, that's ok. It can
be changed later and lazr restful will continue to function unchanged.
Non browser clients won't be supported right off the bat but they will
just ignore the notification header value anyway. The initial goal is to
fix the issue in launchpad where inline editing of stuff works different
to non ajax editing in that it doesn't display the same messages to the
user.

Revision history for this message
Leonard Richardson (leonardr) wrote :

OK, I'm convinced that there's no simpler solution right now than JSON-encoding the notifications and putting them in a single header. The implementation looks good as well.

The only suggestion I can make is that you should be able to put a single _processNotifications() call in WebServicePublicationMixin.callObject(), instead of calling it from within every implementation of __call__. This would centralize the notification code so it's only called once, and it would also put it 'outside' of the resource processing, in the request processing. This fits nicely with the way we moved the notifications themselves 'outside' of the resource representations and into the HTTP response.

Revision history for this message
Ian Booth (wallyworld) wrote :

> OK, I'm convinced that there's no simpler solution right now than JSON-
> encoding the notifications and putting them in a single header. The
> implementation looks good as well.
>
> The only suggestion I can make is that you should be able to put a single
> _processNotifications() call in WebServicePublicationMixin.callObject(),
> instead of calling it from within every implementation of __call__. This would
> centralize the notification code so it's only called once, and it would also
> put it 'outside' of the resource processing, in the request processing. This
> fits nicely with the way we moved the notifications themselves 'outside' of
> the resource representations and into the HTTP response.

I've moved the notifications processing code as suggested to WebServicePublicationMixin and tweaked the unit test accordingly. As discussed via mumble, I'm keeping the version as 0.18.1 and once approved, the new package will be released as such under this version number. I've changed the status to Needs Review.

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

There are a couple of things that are still needed on this branch.

First, I can't get the tests to pass. First there was an undeclared
dependency on testtool, so I removed references to it but then I got
many, many other failures. If you're not seeing these, then I suspect
you're running the tests with a dirty Python (e.g., a system Python).
Running with a clean Python (or virtualenv) should result in the same
failures.

The INotificationsProvider interface is just a marker (i.e., it has not
attributes or methods). It looks like it should have a "notifications"
attribute. Something like this should do:

=== modified file 'src/lazr/restful/interfaces/_rest.py'
--- src/lazr/restful/interfaces/_rest.py 2011-03-25 08:10:16 +0000
+++ src/lazr/restful/interfaces/_rest.py 2011-03-29 20:26:35 +0000
@@ -56,6 +56,8 @@
     'IWebServiceVersion',
     ]

+from textwrap import dedent
+
 from zope.schema import Bool, Dict, Int, List, Text, TextLine
 from zope.interface import Attribute, Interface
 # These two should really be imported from zope.interface, but
@@ -281,16 +283,20 @@
     """A response object which contains notifications.

     A web framework may define an adapter for this interface, allowing
- the web service to provide an implementation having a 'notifications'
- property, providing a list of namedtuples (level, message) which can be
- used by the caller to display extra information about the completed
- request. 'level' matches the standard logging levels:
- DEBUG = logging.DEBUG # A debugging message
- INFO = logging.INFO # simple confirmation of a change
- WARNING = logging.WARNING # action will not be successful unless you ...
- ERROR = logging.ERROR # the previous action did not succeed, and why
+ the web service to provide notifications to be sent to the client.
     """

+ notifications = Attribute(dedent("""\
+ A list of namedtuples (level, message) which can be used by the
+ caller to display extra information about the completed request.
+ 'level' matches the standard logging levels:
+
+ DEBUG = logging.DEBUG # a debugging message
+ INFO = logging.INFO # simple confirmation of a change
+ WARNING = logging.WARNING # action will not be successful unless...
+ ERROR = logging.ERROR # the previous action did not succeed
+ """))
+

Now for lesser things:

A couple of the multi-line imports in src/lazr/restful/publisher.py need
trailing commas.

Here's a small whitespace fix:

=== modified file 'src/lazr/restful/publisher.py'
--- src/lazr/restful/publisher.py 2011-03-28 00:09:43 +0000
+++ src/lazr/restful/publisher.py 2011-03-29 20:31:53 +0000
@@ -201,7 +201,7 @@
             and notifications_provider.notifications):
             notifications = ([(notification.level, notification.message)
                  for notification in notifications_provider.notifications])
- json_notifications =simplejson.dumps(notifications)
+ json_notifications = simplejson.dumps(notifications)
         request.response.setHeader(
             'X-Lazr-Notifications', json_notifications)

review: Needs Fixing (code)
195. By Ian Booth

Add testtools dependency and other drive by fixes

Revision history for this message
Ian Booth (wallyworld) wrote :

> There are a couple of things that are still needed on this branch.
>
> First, I can't get the tests to pass. First there was an undeclared
> dependency on testtool, so I removed references to it but then I got
> many, many other failures. If you're not seeing these, then I suspect
> you're running the tests with a dirty Python (e.g., a system Python).
> Running with a clean Python (or virtualenv) should result in the same
> failures.
>

testtool is used in tests other than the new one created for this mp. The issue is that it had not been added to the project dependencies in setup.py etc. So I've fixed that and run the tests using virtualenv and they all pass. I had to also remove as a drive-by a bad import that was causing lots of noise in the test run.

Other requested fixes also done.

196. By Ian Booth

Remove testtools from install_requires

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

Looks good.

review: Approve (code)
197. By Ian Booth

Merge trunk and fix an import

198. By Ian Booth

Fix release date in NEWS.txt

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'setup.py'
2--- setup.py 2011-03-31 03:34:49 +0000
3+++ setup.py 2011-03-31 22:44:29 +0000
4@@ -66,6 +66,7 @@
5 'pytz',
6 'setuptools',
7 'simplejson>=2.1.0',
8+ 'testtools',
9 'van.testing',
10 'wsgiref',
11 'zope.app.pagetemplate',
12
13=== modified file 'src/lazr/restful/NEWS.txt'
14--- src/lazr/restful/NEWS.txt 2011-03-31 04:30:56 +0000
15+++ src/lazr/restful/NEWS.txt 2011-03-31 22:44:29 +0000
16@@ -2,7 +2,7 @@
17 NEWS for lazr.restful
18 =====================
19
20-0.18.1 (2011-03-25)
21+0.18.1 (2011-04-01)
22 ===================
23
24 Fixed minor test failures.
25@@ -10,6 +10,12 @@
26 The object modification event will not be fired if a client sends an
27 empty changeset via PATCH.
28
29+The webservice may define an adapter which is used, after an operation on a
30+resource, to provide notifications consisting of namedtuples (level, message).
31+Any notifications are json encoded and inserted into the response header using
32+the 'X-Lazr-Notification' key. They may then be used by the caller to provide
33+extra information to the user about the completed request.
34+
35 The webservice:json TALES function now returns JSON that will survive
36 HTML escaping.
37
38
39=== modified file 'src/lazr/restful/_resource.py'
40--- src/lazr/restful/_resource.py 2011-03-31 03:36:23 +0000
41+++ src/lazr/restful/_resource.py 2011-03-31 22:44:29 +0000
42@@ -102,7 +102,6 @@
43 IHTTPResource,
44 IJSONPublishable,
45 IReference,
46- IReferenceChoice,
47 IRepresentationCache,
48 IResourceDELETEOperation,
49 IResourceGETOperation,
50@@ -121,7 +120,6 @@
51 )
52 from lazr.restful.utils import (
53 extract_write_portion,
54- get_current_browser_request,
55 get_current_web_service_request,
56 parse_accept_style_header,
57 sorted_named_things,
58
59=== modified file 'src/lazr/restful/interfaces/_rest.py'
60--- src/lazr/restful/interfaces/_rest.py 2011-03-15 14:13:10 +0000
61+++ src/lazr/restful/interfaces/_rest.py 2011-03-31 22:44:29 +0000
62@@ -32,6 +32,7 @@
63 'IFieldMarshaller',
64 'IFileLibrarian',
65 'IHTTPResource',
66+ 'INotificationsProvider',
67 'IJSONPublishable',
68 'IJSONRequestCache',
69 'IRepresentationCache',
70@@ -55,15 +56,28 @@
71 'IWebServiceVersion',
72 ]
73
74-from zope.schema import Bool, Dict, Int, List, Text, TextLine
75-from zope.interface import Attribute, Interface
76+from textwrap import dedent
77+from zope.schema import (
78+ Bool,
79+ Dict,
80+ Int,
81+ List,
82+ Text,
83+ TextLine,
84+ )
85+from zope.interface import (
86+ Attribute,
87+ Interface,
88+ )
89 # These two should really be imported from zope.interface, but
90 # the import fascist complains because they are not in __all__ there.
91 from zope.interface.interface import invariant
92 from zope.interface.exceptions import Invalid
93 from zope.publisher.interfaces import IPublishTraverse
94 from zope.publisher.interfaces.browser import (
95- IBrowserRequest, IDefaultBrowserLayer)
96+ IBrowserRequest,
97+ IDefaultBrowserLayer,
98+ )
99 from lazr.batchnavigator.interfaces import InvalidBatchSizeError
100
101 # Constants for periods of time
102@@ -276,6 +290,23 @@
103 """
104
105
106+class INotificationsProvider(Interface):
107+ """A response object which contains notifications.
108+
109+ A web framework may define an adapter for this interface, allowing
110+ the web service to provide notifications to be sent to the client.
111+ """
112+ notifications = Attribute(dedent("""\
113+ A list of namedtuples (level, message) which can be used by the
114+ caller to display extra information about the completed request.
115+ 'level' matches the standard logging levels:
116+
117+ DEBUG = logging.DEBUG # a debugging message
118+ INFO = logging.INFO # simple confirmation of a change
119+ WARNING = logging.WARNING # action will not be successful unless...
120+ ERROR = logging.ERROR # the previous action did not succeed
121+ """))
122+
123 class IWebServiceClientRequest(IBrowserRequest):
124 """Interface for requests to the web service."""
125 version = Attribute("The version of the web service that the client "
126
127=== modified file 'src/lazr/restful/publisher.py'
128--- src/lazr/restful/publisher.py 2011-03-17 14:39:17 +0000
129+++ src/lazr/restful/publisher.py 2011-03-31 22:44:29 +0000
130@@ -14,14 +14,23 @@
131 ]
132
133
134-import traceback
135+import simplejson
136 import urllib
137 import urlparse
138
139 from zope.component import (
140- adapter, getMultiAdapter, getUtility, queryAdapter, queryMultiAdapter)
141+ adapter,
142+ getMultiAdapter,
143+ getUtility,
144+ queryAdapter,
145+ queryMultiAdapter,
146+ )
147 from zope.component.interfaces import ComponentLookupError
148-from zope.interface import alsoProvides, implementer, implements
149+from zope.interface import (
150+ alsoProvides,
151+ implementer,
152+ implements,
153+ )
154 from zope.publisher.interfaces import NotFound
155 from zope.publisher.interfaces.browser import IBrowserRequest
156 from zope.schema.interfaces import IBytes
157@@ -30,13 +39,26 @@
158 from lazr.uri import URI
159
160 from lazr.restful import (
161- CollectionResource, EntryField, EntryFieldResource,
162- EntryResource, ScopedCollection, ServiceRootResource)
163+ CollectionResource,
164+ EntryField,
165+ EntryFieldResource,
166+ EntryResource,
167+ ScopedCollection,
168+ )
169 from lazr.restful.interfaces import (
170- IByteStorage, ICollection, ICollectionField, IEntry, IEntryField,
171- IHTTPResource, IReference, IServiceRootResource,
172- IWebBrowserInitiatedRequest, IWebServiceClientRequest,
173- IWebServiceConfiguration, IWebServiceVersion)
174+ IByteStorage,
175+ ICollection,
176+ ICollectionField,
177+ IEntry,
178+ IEntryField,
179+ IHTTPResource,
180+ INotificationsProvider,
181+ IReference,
182+ IServiceRootResource,
183+ IWebBrowserInitiatedRequest,
184+ IWebServiceClientRequest,
185+ IWebServiceConfiguration,
186+ )
187 from lazr.restful.utils import tag_request_with_version_name
188
189
190@@ -166,10 +188,28 @@
191 # Wrap the resource in a security proxy.
192 return ProxyFactory(resource)
193
194+ def _processNotifications(self, request):
195+ """Add any notification messages to the response headers.
196+
197+ If the webservice has defined an INotificationsProvider adaptor, use
198+ it to include with the response the relevant notification messages
199+ and their severity levels.
200+ """
201+ notifications_provider = INotificationsProvider(request, None)
202+ notifications = []
203+ if (notifications_provider is not None
204+ and notifications_provider.notifications):
205+ notifications = ([(notification.level, notification.message)
206+ for notification in notifications_provider.notifications])
207+ json_notifications = simplejson.dumps(notifications)
208+ request.response.setHeader(
209+ 'X-Lazr-Notifications', json_notifications)
210+
211 def callObject(self, request, object):
212 """Help web browsers handle redirects correctly."""
213 value = super(
214 WebServicePublicationMixin, self).callObject(request, object)
215+ self._processNotifications(request)
216 if request.response.getStatus() / 100 == 3:
217 vhost = URI(request.getApplicationURL()).host
218 if IWebBrowserInitiatedRequest.providedBy(request):
219
220=== modified file 'src/lazr/restful/tests/test_webservice.py'
221--- src/lazr/restful/tests/test_webservice.py 2011-03-25 11:33:14 +0000
222+++ src/lazr/restful/tests/test_webservice.py 2011-03-31 22:44:29 +0000
223@@ -9,6 +9,8 @@
224 from lxml import etree
225 from operator import attrgetter
226 from textwrap import dedent
227+import collections
228+import logging
229 import random
230 import re
231 import simplejson
232@@ -20,7 +22,6 @@
233 getUtility,
234 )
235 from zope.interface import implements, Interface
236-from zope.interface.interface import InterfaceClass
237 from zope.publisher.browser import TestRequest
238 from zope.schema import Choice, Date, Datetime, TextLine
239 from zope.schema.interfaces import ITextLine
240@@ -31,8 +32,6 @@
241 )
242 from zope.traversing.browser.interfaces import IAbsoluteURL
243
244-from wadllib.application import Application
245-
246 from lazr.enum import EnumeratedType, Item
247 from lazr.lifecycle.interfaces import IObjectModifiedEvent
248 from lazr.restful import (
249@@ -44,6 +43,7 @@
250 ICollection,
251 IEntry,
252 IFieldHTMLRenderer,
253+ INotificationsProvider,
254 IResourceGETOperation,
255 IServiceRootResource,
256 IWebBrowserOriginatingRequest,
257@@ -923,6 +923,60 @@
258 return ResourceOperation(None, request)
259
260
261+Notification = collections.namedtuple('Notification', ['level', 'message'])
262+
263+
264+class NotificationsProviderTest(EntryTestCase):
265+ """Test that notifcations are included in the response headers."""
266+
267+ testmodule_objects = [HasOneField, IHasOneField]
268+
269+ class DummyWebsiteRequestWithNotifications:
270+ """A request to the website, as opposed to the web service."""
271+ implements(INotificationsProvider)
272+
273+ @property
274+ def notifications(self):
275+ return [Notification(logging.INFO, "Informational"),
276+ Notification(logging.WARNING, "Warning")
277+ ]
278+ def setUp(self):
279+ super(NotificationsProviderTest, self).setUp()
280+ self.default_media_type = "application/json;include=lp_html"
281+ self._register_website_url_space(IHasOneField)
282+ self._register_notification_adapter()
283+
284+ def _register_notification_adapter(self):
285+ """Simulates a service where an entry corresponds to a web page."""
286+
287+ # First, create a converter from web service requests to
288+ # web service requests with notifications.
289+ def web_service_request_to_notification_request(service_request):
290+ """Create a corresponding request to the website."""
291+ return self.DummyWebsiteRequestWithNotifications()
292+
293+ getGlobalSiteManager().registerAdapter(
294+ web_service_request_to_notification_request,
295+ [IWebServiceClientRequest], INotificationsProvider)
296+
297+ @contextmanager
298+ def resource(self):
299+ """Simplify the entry_resource call."""
300+ with self.entry_resource(IHasOneField, HasOneField, "") as resource:
301+ yield resource
302+
303+ def test_response_notifications(self):
304+ with self.resource() as resource:
305+ resource.request.publication.callObject(
306+ resource.request, resource)
307+ notifications = resource.request.response.getHeader(
308+ "X-Lazr-Notifications")
309+ self.assertFalse(notifications is None)
310+ notifications = simplejson.loads(notifications)
311+ expected_notifications = [
312+ [logging.INFO, "Informational"], [logging.WARNING, "Warning"]]
313+ self.assertEquals(notifications, expected_notifications)
314+
315 class EventTestCase(EntryTestCase):
316
317 testmodule_objects = [IHasOneField]
318
319=== modified file 'versions.cfg'
320--- versions.cfg 2011-03-31 03:34:49 +0000
321+++ versions.cfg 2011-03-31 22:44:29 +0000
322@@ -26,6 +26,7 @@
323 pytz = 2010h
324 setuptools = 0.6c11
325 simplejson = 2.1.3
326+testtools = 0.9.8
327 transaction = 1.0.0
328 van.testing = 2.0.1
329 wsgi-intercept = 0.4

Subscribers

People subscribed via source and target branches