Merge lp:~allenap/launchpad/lp-app-longpoll into lp:launchpad
- lp-app-longpoll
- Merge into devel
Status: | Merged |
---|---|
Approved by: | Gavin Panella |
Approved revision: | no longer in the source branch. |
Merged at revision: | 13375 |
Proposed branch: | lp:~allenap/launchpad/lp-app-longpoll |
Merge into: | lp:launchpad |
Diff against target: |
702 lines (+617/-0) 12 files modified
lib/lp/app/configure.zcml (+1/-0) lib/lp/app/longpoll/__init__.py (+47/-0) lib/lp/app/longpoll/adapters/event.py (+47/-0) lib/lp/app/longpoll/adapters/subscriber.py (+56/-0) lib/lp/app/longpoll/adapters/tests/test_event.py (+84/-0) lib/lp/app/longpoll/adapters/tests/test_subscriber.py (+134/-0) lib/lp/app/longpoll/configure.zcml (+10/-0) lib/lp/app/longpoll/interfaces.py (+51/-0) lib/lp/app/longpoll/tests/__init__.py (+2/-0) lib/lp/app/longpoll/tests/test_longpoll.py (+104/-0) lib/lp/testing/fixture.py (+18/-0) lib/lp/testing/tests/test_fixture.py (+63/-0) |
To merge this branch: | bzr merge lp:~allenap/launchpad/lp-app-longpoll |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Graham Binns (community) | code | Approve | |
Review via email: mp+66872@code.launchpad.net |
Commit message
[r=gmb][no-qa] New lp.app.longpoll package.
Description of the change
New lp.app.longpoll package.
This provides a higher-level API on top of lp.services.
primary goal for now is to make it trivially easy to connect events in
the application to an interested page.
For example, an event in the application can be generated as simply
as:
emit(an_object, "event_name", {...})
and a page being prepared can arrange to receive these event via
long-poll with a single line of code:
subscribe(
Behind the scenes a new, short-lived, queue will be created and
subscribed to the correct routing key for the event. The in-page JSON
cache will have the queue details added to it so that the client can
immediately long-poll for events.
This branch contains the server-side code. The client-side code is
being worked on by Raphael.
To support the emit/subscribe two-step a single adapter must be
registered. It must (multi-) adapt the object that issues events and
an event description to an ILongPollEvent. This branch contains a
suitable base-class, LongPollEvent. Suppose a Job emits events from
_set_status using emit(self, "status", status.name) the following
adapter would work:
class JobLongPollEven
@property
def event_key(self):
return generate_event_key(
This ensures that there's a stable and consistent event naming scheme.
Preview Diff
1 | === modified file 'lib/lp/app/configure.zcml' | |||
2 | --- lib/lp/app/configure.zcml 2011-05-27 21:25:58 +0000 | |||
3 | +++ lib/lp/app/configure.zcml 2011-07-05 13:35:30 +0000 | |||
4 | @@ -10,6 +10,7 @@ | |||
5 | 10 | xmlns:lp="http://namespaces.canonical.com/lp" | 10 | xmlns:lp="http://namespaces.canonical.com/lp" |
6 | 11 | i18n_domain="launchpad"> | 11 | i18n_domain="launchpad"> |
7 | 12 | <include package=".browser"/> | 12 | <include package=".browser"/> |
8 | 13 | <include package=".longpoll" /> | ||
9 | 13 | <include package="lp.app.validators" /> | 14 | <include package="lp.app.validators" /> |
10 | 14 | <include package="lp.app.widgets" /> | 15 | <include package="lp.app.widgets" /> |
11 | 15 | 16 | ||
12 | 16 | 17 | ||
13 | === added directory 'lib/lp/app/longpoll' | |||
14 | === added file 'lib/lp/app/longpoll/__init__.py' | |||
15 | --- lib/lp/app/longpoll/__init__.py 1970-01-01 00:00:00 +0000 | |||
16 | +++ lib/lp/app/longpoll/__init__.py 2011-07-05 13:35:30 +0000 | |||
17 | @@ -0,0 +1,47 @@ | |||
18 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
19 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
20 | 3 | |||
21 | 4 | """Long-poll infrastructure.""" | ||
22 | 5 | |||
23 | 6 | __metaclass__ = type | ||
24 | 7 | __all__ = [ | ||
25 | 8 | "emit", | ||
26 | 9 | "subscribe", | ||
27 | 10 | ] | ||
28 | 11 | |||
29 | 12 | from .interfaces import ( | ||
30 | 13 | ILongPollEvent, | ||
31 | 14 | ILongPollSubscriber, | ||
32 | 15 | ) | ||
33 | 16 | from lazr.restful.utils import get_current_browser_request | ||
34 | 17 | from zope.component import getMultiAdapter | ||
35 | 18 | |||
36 | 19 | |||
37 | 20 | def subscribe(target, event, request=None): | ||
38 | 21 | """Convenience method to subscribe the current request. | ||
39 | 22 | |||
40 | 23 | :param target: Something that can be adapted to `ILongPollEvent`. | ||
41 | 24 | :param event: The name of the event to subscribe to. | ||
42 | 25 | :param request: The request for which to get an `ILongPollSubscriber`. It | ||
43 | 26 | a request is not specified the currently active request is used. | ||
44 | 27 | :return: The `ILongPollEvent` that has been subscribed to. | ||
45 | 28 | """ | ||
46 | 29 | event = getMultiAdapter((target, event), ILongPollEvent) | ||
47 | 30 | if request is None: | ||
48 | 31 | request = get_current_browser_request() | ||
49 | 32 | subscriber = ILongPollSubscriber(request) | ||
50 | 33 | subscriber.subscribe(event) | ||
51 | 34 | return event | ||
52 | 35 | |||
53 | 36 | |||
54 | 37 | def emit(source, event, data): | ||
55 | 38 | """Convenience method to emit a message for an event. | ||
56 | 39 | |||
57 | 40 | :param source: Something, along with `event`, that can be adapted to | ||
58 | 41 | `ILongPollEvent`. | ||
59 | 42 | :param event: A name/key of the event that is emitted. | ||
60 | 43 | :return: The `ILongPollEvent` that has been emitted. | ||
61 | 44 | """ | ||
62 | 45 | event = getMultiAdapter((source, event), ILongPollEvent) | ||
63 | 46 | event.emit(data) | ||
64 | 47 | return event | ||
65 | 0 | 48 | ||
66 | === added directory 'lib/lp/app/longpoll/adapters' | |||
67 | === added file 'lib/lp/app/longpoll/adapters/__init__.py' | |||
68 | === added file 'lib/lp/app/longpoll/adapters/event.py' | |||
69 | --- lib/lp/app/longpoll/adapters/event.py 1970-01-01 00:00:00 +0000 | |||
70 | +++ lib/lp/app/longpoll/adapters/event.py 2011-07-05 13:35:30 +0000 | |||
71 | @@ -0,0 +1,47 @@ | |||
72 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
73 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
74 | 3 | |||
75 | 4 | """Long poll adapters.""" | ||
76 | 5 | |||
77 | 6 | __metaclass__ = type | ||
78 | 7 | __all__ = [ | ||
79 | 8 | "generate_event_key", | ||
80 | 9 | "LongPollEvent", | ||
81 | 10 | ] | ||
82 | 11 | |||
83 | 12 | from lp.services.messaging.queue import RabbitRoutingKey | ||
84 | 13 | |||
85 | 14 | |||
86 | 15 | def generate_event_key(*components): | ||
87 | 16 | """Generate a suitable event name.""" | ||
88 | 17 | if len(components) == 0: | ||
89 | 18 | raise AssertionError( | ||
90 | 19 | "Event keys must contain at least one component.") | ||
91 | 20 | return "longpoll.event.%s" % ".".join( | ||
92 | 21 | str(component) for component in components) | ||
93 | 22 | |||
94 | 23 | |||
95 | 24 | class LongPollEvent: | ||
96 | 25 | """Base-class for event adapters. | ||
97 | 26 | |||
98 | 27 | Sub-classes need to declare something along the lines of: | ||
99 | 28 | |||
100 | 29 | adapts(Interface, Interface) | ||
101 | 30 | implements(ILongPollEvent) | ||
102 | 31 | |||
103 | 32 | """ | ||
104 | 33 | |||
105 | 34 | def __init__(self, source, event): | ||
106 | 35 | self.source = source | ||
107 | 36 | self.event = event | ||
108 | 37 | |||
109 | 38 | @property | ||
110 | 39 | def event_key(self): | ||
111 | 40 | """See `ILongPollEvent`.""" | ||
112 | 41 | raise NotImplementedError(self.__class__.event_key) | ||
113 | 42 | |||
114 | 43 | def emit(self, data): | ||
115 | 44 | """See `ILongPollEvent`.""" | ||
116 | 45 | payload = {"event_key": self.event_key, "event_data": data} | ||
117 | 46 | routing_key = RabbitRoutingKey(self.event_key) | ||
118 | 47 | routing_key.send(payload) | ||
119 | 0 | 48 | ||
120 | === added file 'lib/lp/app/longpoll/adapters/subscriber.py' | |||
121 | --- lib/lp/app/longpoll/adapters/subscriber.py 1970-01-01 00:00:00 +0000 | |||
122 | +++ lib/lp/app/longpoll/adapters/subscriber.py 2011-07-05 13:35:30 +0000 | |||
123 | @@ -0,0 +1,56 @@ | |||
124 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
125 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
126 | 3 | |||
127 | 4 | """Long poll adapters.""" | ||
128 | 5 | |||
129 | 6 | __metaclass__ = type | ||
130 | 7 | __all__ = [ | ||
131 | 8 | "generate_subscribe_key", | ||
132 | 9 | "LongPollSubscriber", | ||
133 | 10 | ] | ||
134 | 11 | |||
135 | 12 | from uuid import uuid4 | ||
136 | 13 | |||
137 | 14 | from lazr.restful.interfaces import IJSONRequestCache | ||
138 | 15 | from zope.component import adapts | ||
139 | 16 | from zope.interface import implements | ||
140 | 17 | from zope.publisher.interfaces import IApplicationRequest | ||
141 | 18 | |||
142 | 19 | from lp.app.longpoll.interfaces import ILongPollSubscriber | ||
143 | 20 | from lp.services.messaging.queue import ( | ||
144 | 21 | RabbitQueue, | ||
145 | 22 | RabbitRoutingKey, | ||
146 | 23 | ) | ||
147 | 24 | |||
148 | 25 | |||
149 | 26 | def generate_subscribe_key(): | ||
150 | 27 | """Generate a suitable new, unique, subscribe key.""" | ||
151 | 28 | return "longpoll.subscribe.%s" % uuid4() | ||
152 | 29 | |||
153 | 30 | |||
154 | 31 | class LongPollSubscriber: | ||
155 | 32 | |||
156 | 33 | adapts(IApplicationRequest) | ||
157 | 34 | implements(ILongPollSubscriber) | ||
158 | 35 | |||
159 | 36 | def __init__(self, request): | ||
160 | 37 | self.request = request | ||
161 | 38 | |||
162 | 39 | @property | ||
163 | 40 | def subscribe_key(self): | ||
164 | 41 | objects = IJSONRequestCache(self.request).objects | ||
165 | 42 | if "longpoll" in objects: | ||
166 | 43 | return objects["longpoll"]["key"] | ||
167 | 44 | return None | ||
168 | 45 | |||
169 | 46 | def subscribe(self, event): | ||
170 | 47 | cache = IJSONRequestCache(self.request) | ||
171 | 48 | if "longpoll" not in cache.objects: | ||
172 | 49 | cache.objects["longpoll"] = { | ||
173 | 50 | "key": generate_subscribe_key(), | ||
174 | 51 | "subscriptions": [], | ||
175 | 52 | } | ||
176 | 53 | subscribe_queue = RabbitQueue(self.subscribe_key) | ||
177 | 54 | routing_key = RabbitRoutingKey(event.event_key) | ||
178 | 55 | routing_key.associateConsumer(subscribe_queue) | ||
179 | 56 | cache.objects["longpoll"]["subscriptions"].append(event.event_key) | ||
180 | 0 | 57 | ||
181 | === added directory 'lib/lp/app/longpoll/adapters/tests' | |||
182 | === added file 'lib/lp/app/longpoll/adapters/tests/__init__.py' | |||
183 | === added file 'lib/lp/app/longpoll/adapters/tests/test_event.py' | |||
184 | --- lib/lp/app/longpoll/adapters/tests/test_event.py 1970-01-01 00:00:00 +0000 | |||
185 | +++ lib/lp/app/longpoll/adapters/tests/test_event.py 2011-07-05 13:35:30 +0000 | |||
186 | @@ -0,0 +1,84 @@ | |||
187 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
188 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
189 | 3 | |||
190 | 4 | """Long-poll event adapter tests.""" | ||
191 | 5 | |||
192 | 6 | __metaclass__ = type | ||
193 | 7 | |||
194 | 8 | from zope.interface import implements | ||
195 | 9 | |||
196 | 10 | from canonical.testing.layers import ( | ||
197 | 11 | BaseLayer, | ||
198 | 12 | LaunchpadFunctionalLayer, | ||
199 | 13 | ) | ||
200 | 14 | from lp.app.longpoll.adapters.event import ( | ||
201 | 15 | generate_event_key, | ||
202 | 16 | LongPollEvent, | ||
203 | 17 | ) | ||
204 | 18 | from lp.app.longpoll.interfaces import ILongPollEvent | ||
205 | 19 | from lp.services.messaging.queue import RabbitMessageBase | ||
206 | 20 | from lp.testing import TestCase | ||
207 | 21 | from lp.testing.matchers import Contains | ||
208 | 22 | |||
209 | 23 | |||
210 | 24 | class FakeEvent(LongPollEvent): | ||
211 | 25 | |||
212 | 26 | implements(ILongPollEvent) | ||
213 | 27 | |||
214 | 28 | @property | ||
215 | 29 | def event_key(self): | ||
216 | 30 | return "event-key-%s-%s" % (self.source, self.event) | ||
217 | 31 | |||
218 | 32 | |||
219 | 33 | class TestLongPollEvent(TestCase): | ||
220 | 34 | |||
221 | 35 | layer = LaunchpadFunctionalLayer | ||
222 | 36 | |||
223 | 37 | def test_interface(self): | ||
224 | 38 | event = FakeEvent("source", "event") | ||
225 | 39 | self.assertProvides(event, ILongPollEvent) | ||
226 | 40 | |||
227 | 41 | def test_event_key(self): | ||
228 | 42 | # event_key is not implemented in LongPollEvent; subclasses must | ||
229 | 43 | # provide it. | ||
230 | 44 | event = LongPollEvent("source", "event") | ||
231 | 45 | self.assertRaises(NotImplementedError, getattr, event, "event_key") | ||
232 | 46 | |||
233 | 47 | def test_emit(self): | ||
234 | 48 | # LongPollEvent.emit() sends the given data to `event_key`. | ||
235 | 49 | event = FakeEvent("source", "event") | ||
236 | 50 | event_data = {"hello": 1234} | ||
237 | 51 | event.emit(event_data) | ||
238 | 52 | expected_message = { | ||
239 | 53 | "event_key": event.event_key, | ||
240 | 54 | "event_data": event_data, | ||
241 | 55 | } | ||
242 | 56 | pending_messages = [ | ||
243 | 57 | message for (call, message) in | ||
244 | 58 | RabbitMessageBase.class_locals.messages] | ||
245 | 59 | self.assertThat(pending_messages, Contains(expected_message)) | ||
246 | 60 | |||
247 | 61 | |||
248 | 62 | class TestFunctions(TestCase): | ||
249 | 63 | |||
250 | 64 | layer = BaseLayer | ||
251 | 65 | |||
252 | 66 | def test_generate_event_key_no_components(self): | ||
253 | 67 | self.assertRaises( | ||
254 | 68 | AssertionError, generate_event_key) | ||
255 | 69 | |||
256 | 70 | def test_generate_event_key(self): | ||
257 | 71 | self.assertEqual( | ||
258 | 72 | "longpoll.event.event-name", | ||
259 | 73 | generate_event_key("event-name")) | ||
260 | 74 | self.assertEqual( | ||
261 | 75 | "longpoll.event.source-name.event-name", | ||
262 | 76 | generate_event_key("source-name", "event-name")) | ||
263 | 77 | self.assertEqual( | ||
264 | 78 | "longpoll.event.type-name.source-name.event-name", | ||
265 | 79 | generate_event_key("type-name", "source-name", "event-name")) | ||
266 | 80 | |||
267 | 81 | def test_generate_event_key_stringifies_components(self): | ||
268 | 82 | self.assertEqual( | ||
269 | 83 | "longpoll.event.job.1234.COMPLETED", | ||
270 | 84 | generate_event_key("job", 1234, "COMPLETED")) | ||
271 | 0 | 85 | ||
272 | === added file 'lib/lp/app/longpoll/adapters/tests/test_subscriber.py' | |||
273 | --- lib/lp/app/longpoll/adapters/tests/test_subscriber.py 1970-01-01 00:00:00 +0000 | |||
274 | +++ lib/lp/app/longpoll/adapters/tests/test_subscriber.py 2011-07-05 13:35:30 +0000 | |||
275 | @@ -0,0 +1,134 @@ | |||
276 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
277 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
278 | 3 | |||
279 | 4 | """Long-poll subscriber adapter tests.""" | ||
280 | 5 | |||
281 | 6 | __metaclass__ = type | ||
282 | 7 | |||
283 | 8 | from itertools import count | ||
284 | 9 | |||
285 | 10 | from lazr.restful.interfaces import IJSONRequestCache | ||
286 | 11 | from testtools.matchers import ( | ||
287 | 12 | Not, | ||
288 | 13 | StartsWith, | ||
289 | 14 | ) | ||
290 | 15 | from zope.interface import implements | ||
291 | 16 | |||
292 | 17 | from canonical.launchpad.webapp.servers import LaunchpadTestRequest | ||
293 | 18 | from canonical.testing.layers import ( | ||
294 | 19 | BaseLayer, | ||
295 | 20 | LaunchpadFunctionalLayer, | ||
296 | 21 | ) | ||
297 | 22 | from lp.app.longpoll.adapters.subscriber import ( | ||
298 | 23 | generate_subscribe_key, | ||
299 | 24 | LongPollSubscriber, | ||
300 | 25 | ) | ||
301 | 26 | from lp.app.longpoll.interfaces import ( | ||
302 | 27 | ILongPollEvent, | ||
303 | 28 | ILongPollSubscriber, | ||
304 | 29 | ) | ||
305 | 30 | from lp.services.messaging.queue import ( | ||
306 | 31 | RabbitQueue, | ||
307 | 32 | RabbitRoutingKey, | ||
308 | 33 | ) | ||
309 | 34 | from lp.testing import TestCase | ||
310 | 35 | from lp.testing.matchers import Contains | ||
311 | 36 | |||
312 | 37 | |||
313 | 38 | class FakeEvent: | ||
314 | 39 | |||
315 | 40 | implements(ILongPollEvent) | ||
316 | 41 | |||
317 | 42 | event_key_indexes = count(1) | ||
318 | 43 | |||
319 | 44 | def __init__(self): | ||
320 | 45 | self.event_key = "event-key-%d" % next(self.event_key_indexes) | ||
321 | 46 | |||
322 | 47 | |||
323 | 48 | class TestLongPollSubscriber(TestCase): | ||
324 | 49 | |||
325 | 50 | layer = LaunchpadFunctionalLayer | ||
326 | 51 | |||
327 | 52 | def test_interface(self): | ||
328 | 53 | request = LaunchpadTestRequest() | ||
329 | 54 | subscriber = LongPollSubscriber(request) | ||
330 | 55 | self.assertProvides(subscriber, ILongPollSubscriber) | ||
331 | 56 | |||
332 | 57 | def test_subscribe_key(self): | ||
333 | 58 | request = LaunchpadTestRequest() | ||
334 | 59 | subscriber = LongPollSubscriber(request) | ||
335 | 60 | # A subscribe key is not generated yet. | ||
336 | 61 | self.assertIs(subscriber.subscribe_key, None) | ||
337 | 62 | # It it only generated on the first subscription. | ||
338 | 63 | subscriber.subscribe(FakeEvent()) | ||
339 | 64 | subscribe_key = subscriber.subscribe_key | ||
340 | 65 | self.assertIsInstance(subscribe_key, str) | ||
341 | 66 | self.assertNotEqual(0, len(subscribe_key)) | ||
342 | 67 | # It remains the same for later subscriptions. | ||
343 | 68 | subscriber.subscribe(FakeEvent()) | ||
344 | 69 | self.assertEqual(subscribe_key, subscriber.subscribe_key) | ||
345 | 70 | |||
346 | 71 | def test_adapter(self): | ||
347 | 72 | request = LaunchpadTestRequest() | ||
348 | 73 | subscriber = ILongPollSubscriber(request) | ||
349 | 74 | self.assertIsInstance(subscriber, LongPollSubscriber) | ||
350 | 75 | # A difference subscriber is returned on subsequent adaptions, but it | ||
351 | 76 | # has the same subscribe_key. | ||
352 | 77 | subscriber2 = ILongPollSubscriber(request) | ||
353 | 78 | self.assertIsNot(subscriber, subscriber2) | ||
354 | 79 | self.assertEqual(subscriber.subscribe_key, subscriber2.subscribe_key) | ||
355 | 80 | |||
356 | 81 | def test_subscribe_queue(self): | ||
357 | 82 | # LongPollSubscriber.subscribe() creates a new queue with a new unique | ||
358 | 83 | # name that is bound to the event's event_key. | ||
359 | 84 | request = LaunchpadTestRequest() | ||
360 | 85 | event = FakeEvent() | ||
361 | 86 | subscriber = ILongPollSubscriber(request) | ||
362 | 87 | subscriber.subscribe(event) | ||
363 | 88 | message = '{"hello": 1234}' | ||
364 | 89 | routing_key = RabbitRoutingKey(event.event_key) | ||
365 | 90 | routing_key.send_now(message) | ||
366 | 91 | subscribe_queue = RabbitQueue(subscriber.subscribe_key) | ||
367 | 92 | self.assertEqual( | ||
368 | 93 | message, subscribe_queue.receive(timeout=5)) | ||
369 | 94 | |||
370 | 95 | def test_json_cache_not_populated_on_init(self): | ||
371 | 96 | # LongPollSubscriber does not put the name of the new queue into the | ||
372 | 97 | # JSON cache. | ||
373 | 98 | request = LaunchpadTestRequest() | ||
374 | 99 | cache = IJSONRequestCache(request) | ||
375 | 100 | self.assertThat(cache.objects, Not(Contains("longpoll"))) | ||
376 | 101 | ILongPollSubscriber(request) | ||
377 | 102 | self.assertThat(cache.objects, Not(Contains("longpoll"))) | ||
378 | 103 | |||
379 | 104 | def test_json_cache_populated_on_subscribe(self): | ||
380 | 105 | # To aid with debugging the event_key of subscriptions are added to | ||
381 | 106 | # the JSON cache. | ||
382 | 107 | request = LaunchpadTestRequest() | ||
383 | 108 | cache = IJSONRequestCache(request) | ||
384 | 109 | event1 = FakeEvent() | ||
385 | 110 | ILongPollSubscriber(request).subscribe(event1) # Side-effects! | ||
386 | 111 | self.assertThat(cache.objects, Contains("longpoll")) | ||
387 | 112 | self.assertThat(cache.objects["longpoll"], Contains("key")) | ||
388 | 113 | self.assertThat(cache.objects["longpoll"], Contains("subscriptions")) | ||
389 | 114 | self.assertEqual( | ||
390 | 115 | [event1.event_key], | ||
391 | 116 | cache.objects["longpoll"]["subscriptions"]) | ||
392 | 117 | # More events can be subscribed. | ||
393 | 118 | event2 = FakeEvent() | ||
394 | 119 | ILongPollSubscriber(request).subscribe(event2) | ||
395 | 120 | self.assertEqual( | ||
396 | 121 | [event1.event_key, event2.event_key], | ||
397 | 122 | cache.objects["longpoll"]["subscriptions"]) | ||
398 | 123 | |||
399 | 124 | |||
400 | 125 | class TestFunctions(TestCase): | ||
401 | 126 | |||
402 | 127 | layer = BaseLayer | ||
403 | 128 | |||
404 | 129 | def test_generate_subscribe_key(self): | ||
405 | 130 | subscribe_key = generate_subscribe_key() | ||
406 | 131 | expected_prefix = "longpoll.subscribe." | ||
407 | 132 | self.assertThat(subscribe_key, StartsWith(expected_prefix)) | ||
408 | 133 | # The key contains a 36 character UUID. | ||
409 | 134 | self.assertEqual(len(expected_prefix) + 36, len(subscribe_key)) | ||
410 | 0 | 135 | ||
411 | === added file 'lib/lp/app/longpoll/configure.zcml' | |||
412 | --- lib/lp/app/longpoll/configure.zcml 1970-01-01 00:00:00 +0000 | |||
413 | +++ lib/lp/app/longpoll/configure.zcml 2011-07-05 13:35:30 +0000 | |||
414 | @@ -0,0 +1,10 @@ | |||
415 | 1 | <!-- Copyright 2011 Canonical Ltd. This software is licensed under the | ||
416 | 2 | GNU Affero General Public License version 3 (see the file LICENSE). | ||
417 | 3 | --> | ||
418 | 4 | <configure | ||
419 | 5 | xmlns="http://namespaces.zope.org/zope" | ||
420 | 6 | xmlns:browser="http://namespaces.zope.org/browser" | ||
421 | 7 | xmlns:i18n="http://namespaces.zope.org/i18n" | ||
422 | 8 | i18n_domain="launchpad"> | ||
423 | 9 | <adapter factory=".adapters.subscriber.LongPollSubscriber" /> | ||
424 | 10 | </configure> | ||
425 | 0 | 11 | ||
426 | === added file 'lib/lp/app/longpoll/interfaces.py' | |||
427 | --- lib/lp/app/longpoll/interfaces.py 1970-01-01 00:00:00 +0000 | |||
428 | +++ lib/lp/app/longpoll/interfaces.py 2011-07-05 13:35:30 +0000 | |||
429 | @@ -0,0 +1,51 @@ | |||
430 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
431 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
432 | 3 | |||
433 | 4 | """Long-poll infrastructure interfaces.""" | ||
434 | 5 | |||
435 | 6 | __metaclass__ = type | ||
436 | 7 | __all__ = [ | ||
437 | 8 | "ILongPollEvent", | ||
438 | 9 | "ILongPollSubscriber", | ||
439 | 10 | ] | ||
440 | 11 | |||
441 | 12 | |||
442 | 13 | from zope.interface import ( | ||
443 | 14 | Attribute, | ||
444 | 15 | Interface, | ||
445 | 16 | ) | ||
446 | 17 | |||
447 | 18 | |||
448 | 19 | class ILongPollEvent(Interface): | ||
449 | 20 | |||
450 | 21 | source = Attribute("The event source.") | ||
451 | 22 | |||
452 | 23 | event = Attribute("An object indicating the type of event.") | ||
453 | 24 | |||
454 | 25 | event_key = Attribute( | ||
455 | 26 | "The key with which events will be emitted. Should be predictable " | ||
456 | 27 | "and stable.") | ||
457 | 28 | |||
458 | 29 | def emit(data): | ||
459 | 30 | """Emit the given data to `event_key`. | ||
460 | 31 | |||
461 | 32 | The data will be wrapped up into a `dict` with the keys `event_key` | ||
462 | 33 | and `event_data`, where `event_key` is a copy of `self.event_key` and | ||
463 | 34 | `event_data` is the `data` argument. | ||
464 | 35 | |||
465 | 36 | :param data: Any data structure that can be dumped as JSON. | ||
466 | 37 | """ | ||
467 | 38 | |||
468 | 39 | |||
469 | 40 | class ILongPollSubscriber(Interface): | ||
470 | 41 | |||
471 | 42 | subscribe_key = Attribute( | ||
472 | 43 | "The key which the subscriber must know in order to be able " | ||
473 | 44 | "to long-poll for subscribed events. Should be infeasible to " | ||
474 | 45 | "guess, a UUID for example.") | ||
475 | 46 | |||
476 | 47 | def subscribe(event): | ||
477 | 48 | """Subscribe to the given event. | ||
478 | 49 | |||
479 | 50 | :type event: ILongPollEvent | ||
480 | 51 | """ | ||
481 | 0 | 52 | ||
482 | === added directory 'lib/lp/app/longpoll/tests' | |||
483 | === added file 'lib/lp/app/longpoll/tests/__init__.py' | |||
484 | --- lib/lp/app/longpoll/tests/__init__.py 1970-01-01 00:00:00 +0000 | |||
485 | +++ lib/lp/app/longpoll/tests/__init__.py 2011-07-05 13:35:30 +0000 | |||
486 | @@ -0,0 +1,2 @@ | |||
487 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
488 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
489 | 0 | 3 | ||
490 | === added file 'lib/lp/app/longpoll/tests/test_longpoll.py' | |||
491 | --- lib/lp/app/longpoll/tests/test_longpoll.py 1970-01-01 00:00:00 +0000 | |||
492 | +++ lib/lp/app/longpoll/tests/test_longpoll.py 2011-07-05 13:35:30 +0000 | |||
493 | @@ -0,0 +1,104 @@ | |||
494 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
495 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
496 | 3 | |||
497 | 4 | """Tests for lp.app.longpoll.""" | ||
498 | 5 | |||
499 | 6 | __metaclass__ = type | ||
500 | 7 | |||
501 | 8 | from zope.component import adapts | ||
502 | 9 | from zope.interface import ( | ||
503 | 10 | Attribute, | ||
504 | 11 | implements, | ||
505 | 12 | Interface, | ||
506 | 13 | ) | ||
507 | 14 | |||
508 | 15 | from canonical.launchpad.webapp.servers import LaunchpadTestRequest | ||
509 | 16 | from canonical.testing.layers import LaunchpadFunctionalLayer | ||
510 | 17 | from lp.app.longpoll import ( | ||
511 | 18 | emit, | ||
512 | 19 | subscribe, | ||
513 | 20 | ) | ||
514 | 21 | from lp.app.longpoll.interfaces import ( | ||
515 | 22 | ILongPollEvent, | ||
516 | 23 | ILongPollSubscriber, | ||
517 | 24 | ) | ||
518 | 25 | from lp.services.messaging.queue import ( | ||
519 | 26 | RabbitQueue, | ||
520 | 27 | RabbitRoutingKey, | ||
521 | 28 | ) | ||
522 | 29 | from lp.testing import TestCase | ||
523 | 30 | from lp.testing.fixture import ZopeAdapterFixture | ||
524 | 31 | |||
525 | 32 | |||
526 | 33 | class IFakeObject(Interface): | ||
527 | 34 | |||
528 | 35 | ident = Attribute("ident") | ||
529 | 36 | |||
530 | 37 | |||
531 | 38 | class FakeObject: | ||
532 | 39 | |||
533 | 40 | implements(IFakeObject) | ||
534 | 41 | |||
535 | 42 | def __init__(self, ident): | ||
536 | 43 | self.ident = ident | ||
537 | 44 | |||
538 | 45 | |||
539 | 46 | class FakeEvent: | ||
540 | 47 | |||
541 | 48 | adapts(IFakeObject, Interface) | ||
542 | 49 | implements(ILongPollEvent) | ||
543 | 50 | |||
544 | 51 | def __init__(self, source, event): | ||
545 | 52 | self.source = source | ||
546 | 53 | self.event = event | ||
547 | 54 | |||
548 | 55 | @property | ||
549 | 56 | def event_key(self): | ||
550 | 57 | return "event-key-%s-%s" % ( | ||
551 | 58 | self.source.ident, self.event) | ||
552 | 59 | |||
553 | 60 | def emit(self, data): | ||
554 | 61 | # Don't cargo-cult this; see .adapters.event.LongPollEvent instead. | ||
555 | 62 | RabbitRoutingKey(self.event_key).send_now(data) | ||
556 | 63 | |||
557 | 64 | |||
558 | 65 | class TestFunctions(TestCase): | ||
559 | 66 | |||
560 | 67 | layer = LaunchpadFunctionalLayer | ||
561 | 68 | |||
562 | 69 | def test_subscribe(self): | ||
563 | 70 | # subscribe() gets the ILongPollEvent for the given (target, event) | ||
564 | 71 | # and the ILongPollSubscriber for the given request (or the current | ||
565 | 72 | # request is discovered). It subscribes the latter to the event, then | ||
566 | 73 | # returns the event. | ||
567 | 74 | request = LaunchpadTestRequest() | ||
568 | 75 | an_object = FakeObject(12345) | ||
569 | 76 | with ZopeAdapterFixture(FakeEvent): | ||
570 | 77 | event = subscribe(an_object, "foo", request=request) | ||
571 | 78 | self.assertIsInstance(event, FakeEvent) | ||
572 | 79 | self.assertEqual("event-key-12345-foo", event.event_key) | ||
573 | 80 | # Emitting an event-key-12345-foo event will put something on the | ||
574 | 81 | # subscriber's queue. | ||
575 | 82 | event_data = {"1234": 5678} | ||
576 | 83 | event.emit(event_data) | ||
577 | 84 | subscriber = ILongPollSubscriber(request) | ||
578 | 85 | subscribe_queue = RabbitQueue(subscriber.subscribe_key) | ||
579 | 86 | message = subscribe_queue.receive(timeout=5) | ||
580 | 87 | self.assertEqual(event_data, message) | ||
581 | 88 | |||
582 | 89 | def test_emit(self): | ||
583 | 90 | # subscribe() gets the ILongPollEvent for the given (target, event) | ||
584 | 91 | # and passes the given data to its emit() method. It then returns the | ||
585 | 92 | # event. | ||
586 | 93 | an_object = FakeObject(12345) | ||
587 | 94 | with ZopeAdapterFixture(FakeEvent): | ||
588 | 95 | event = emit(an_object, "bar", {}) | ||
589 | 96 | routing_key = RabbitRoutingKey(event.event_key) | ||
590 | 97 | subscribe_queue = RabbitQueue("whatever") | ||
591 | 98 | routing_key.associateConsumer(subscribe_queue) | ||
592 | 99 | # Emit the event again; the subscribe queue was not associated | ||
593 | 100 | # with the event before now. | ||
594 | 101 | event_data = {"8765": 4321} | ||
595 | 102 | event = emit(an_object, "bar", event_data) | ||
596 | 103 | message = subscribe_queue.receive(timeout=5) | ||
597 | 104 | self.assertEqual(event_data, message) | ||
598 | 0 | 105 | ||
599 | === modified file 'lib/lp/testing/fixture.py' | |||
600 | --- lib/lp/testing/fixture.py 2011-06-10 02:30:43 +0000 | |||
601 | +++ lib/lp/testing/fixture.py 2011-07-05 13:35:30 +0000 | |||
602 | @@ -5,7 +5,9 @@ | |||
603 | 5 | 5 | ||
604 | 6 | __metaclass__ = type | 6 | __metaclass__ = type |
605 | 7 | __all__ = [ | 7 | __all__ = [ |
606 | 8 | 'ZopeAdapterFixture', | ||
607 | 8 | 'ZopeEventHandlerFixture', | 9 | 'ZopeEventHandlerFixture', |
608 | 10 | 'ZopeViewReplacementFixture', | ||
609 | 9 | ] | 11 | ] |
610 | 10 | 12 | ||
611 | 11 | from fixtures import Fixture | 13 | from fixtures import Fixture |
612 | @@ -22,6 +24,22 @@ | |||
613 | 22 | ) | 24 | ) |
614 | 23 | 25 | ||
615 | 24 | 26 | ||
616 | 27 | class ZopeAdapterFixture(Fixture): | ||
617 | 28 | """A fixture to register and unregister an adapter.""" | ||
618 | 29 | |||
619 | 30 | def __init__(self, *args, **kwargs): | ||
620 | 31 | self._args, self._kwargs = args, kwargs | ||
621 | 32 | |||
622 | 33 | def setUp(self): | ||
623 | 34 | super(ZopeAdapterFixture, self).setUp() | ||
624 | 35 | site_manager = getGlobalSiteManager() | ||
625 | 36 | site_manager.registerAdapter( | ||
626 | 37 | *self._args, **self._kwargs) | ||
627 | 38 | self.addCleanup( | ||
628 | 39 | site_manager.unregisterAdapter, | ||
629 | 40 | *self._args, **self._kwargs) | ||
630 | 41 | |||
631 | 42 | |||
632 | 25 | class ZopeEventHandlerFixture(Fixture): | 43 | class ZopeEventHandlerFixture(Fixture): |
633 | 26 | """A fixture that provides and then unprovides a Zope event handler.""" | 44 | """A fixture that provides and then unprovides a Zope event handler.""" |
634 | 27 | 45 | ||
635 | 28 | 46 | ||
636 | === added file 'lib/lp/testing/tests/test_fixture.py' | |||
637 | --- lib/lp/testing/tests/test_fixture.py 1970-01-01 00:00:00 +0000 | |||
638 | +++ lib/lp/testing/tests/test_fixture.py 2011-07-05 13:35:30 +0000 | |||
639 | @@ -0,0 +1,63 @@ | |||
640 | 1 | # Copyright 2011 Canonical Ltd. This software is licensed under the | ||
641 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
642 | 3 | |||
643 | 4 | """Tests for lp.testing.fixture.""" | ||
644 | 5 | |||
645 | 6 | __metaclass__ = type | ||
646 | 7 | |||
647 | 8 | from zope.component import ( | ||
648 | 9 | adapts, | ||
649 | 10 | queryAdapter, | ||
650 | 11 | ) | ||
651 | 12 | from zope.interface import ( | ||
652 | 13 | implements, | ||
653 | 14 | Interface, | ||
654 | 15 | ) | ||
655 | 16 | |||
656 | 17 | from canonical.testing.layers import BaseLayer | ||
657 | 18 | from lp.testing import TestCase | ||
658 | 19 | from lp.testing.fixture import ZopeAdapterFixture | ||
659 | 20 | |||
660 | 21 | |||
661 | 22 | class IFoo(Interface): | ||
662 | 23 | pass | ||
663 | 24 | |||
664 | 25 | |||
665 | 26 | class IBar(Interface): | ||
666 | 27 | pass | ||
667 | 28 | |||
668 | 29 | |||
669 | 30 | class Foo: | ||
670 | 31 | implements(IFoo) | ||
671 | 32 | |||
672 | 33 | |||
673 | 34 | class Bar: | ||
674 | 35 | implements(IBar) | ||
675 | 36 | |||
676 | 37 | |||
677 | 38 | class FooToBar: | ||
678 | 39 | |||
679 | 40 | adapts(IFoo) | ||
680 | 41 | implements(IBar) | ||
681 | 42 | |||
682 | 43 | def __init__(self, foo): | ||
683 | 44 | self.foo = foo | ||
684 | 45 | |||
685 | 46 | |||
686 | 47 | class TestZopeAdapterFixture(TestCase): | ||
687 | 48 | |||
688 | 49 | layer = BaseLayer | ||
689 | 50 | |||
690 | 51 | def test_register_and_unregister(self): | ||
691 | 52 | # Entering ZopeAdapterFixture's context registers the given adapter, | ||
692 | 53 | # and exiting the context unregisters the adapter again. | ||
693 | 54 | context = Foo() | ||
694 | 55 | # No adapter from Foo to Bar is registered. | ||
695 | 56 | self.assertIs(None, queryAdapter(context, IBar)) | ||
696 | 57 | with ZopeAdapterFixture(FooToBar): | ||
697 | 58 | # Now there is an adapter from Foo to Bar. | ||
698 | 59 | adapter = queryAdapter(context, IBar) | ||
699 | 60 | self.assertIsNot(None, adapter) | ||
700 | 61 | self.assertIsInstance(adapter, FooToBar) | ||
701 | 62 | # The adapter is no longer registered. | ||
702 | 63 | self.assertIs(None, queryAdapter(context, IBar)) |
Hi Gavin,
Nice to see all the work from last week landing. This is a good branch; a few nits but nothing huge. r=me pending the changes below.
[1]
396 +class TestModule( TestCase) :
This seems like a bit of a generic name... Maybe TestSubscriberM odule for clarity?
[2]
446 + source = Attribute(
447 + "The event source.")
448 +
449 + event = Attribute(
450 + "An object indicating the type of event.")
Minor stylistic nit: I don't think these need to be spread over two lines.
[3]
556 +class TestModule( TestCase) :
Same as above. TestLongPollModule maybe?
[4]
560 + def test_subscribe( self):
576 + def test_emit(self):
681 + def test_register_ and_unregister( self):
DMMT. These could do with some leading comments explaining the expected behaviour so that I don't have to read the tests.