Merge lp:~txconvore-maint/txconvore/initial-tests into lp:txconvore

Proposed by Jonathan Jacobs
Status: Merged
Merged at revision: 2
Proposed branch: lp:~txconvore-maint/txconvore/initial-tests
Merge into: lp:txconvore
Diff against target: 1867 lines (+1591/-73)
22 files modified
.bzrignore (+1/-0)
txconvore/client.py (+157/-73)
txconvore/test/data/account_groups.json (+64/-0)
txconvore/test/data/account_groups_create.json (+18/-0)
txconvore/test/data/account_mentions.json (+51/-0)
txconvore/test/data/account_online.json (+15/-0)
txconvore/test/data/account_verify.json (+6/-0)
txconvore/test/data/groups.json (+18/-0)
txconvore/test/data/groups_members.json (+22/-0)
txconvore/test/data/groups_online.json (+25/-0)
txconvore/test/data/groups_topics.json (+37/-0)
txconvore/test/data/groups_topics_create.json (+16/-0)
txconvore/test/data/messages_star.json (+11/-0)
txconvore/test/data/messages_unstar.json (+11/-0)
txconvore/test/data/topics_edit.json (+16/-0)
txconvore/test/data/topics_info.json (+16/-0)
txconvore/test/data/topics_messages.json (+45/-0)
txconvore/test/data/topics_messages_create.json (+15/-0)
txconvore/test/data/users_info.json (+12/-0)
txconvore/test/test_client.py (+697/-0)
txconvore/test/test_util.py (+148/-0)
txconvore/util.py (+190/-0)
To merge this branch: bzr merge lp:~txconvore-maint/txconvore/initial-tests
Reviewer Review Type Date Requested Status
txConvore maintainers Pending
Review via email: mp+57532@code.launchpad.net
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=== added file '.bzrignore'
2--- .bzrignore 1970-01-01 00:00:00 +0000
3+++ .bzrignore 2011-04-13 16:28:00 +0000
4@@ -0,0 +1,1 @@
5+_trial_temp
6
7=== modified file 'txconvore/client.py'
8--- txconvore/client.py 2011-03-25 16:49:16 +0000
9+++ txconvore/client.py 2011-04-13 16:28:00 +0000
10@@ -1,12 +1,13 @@
11+import urllib
12 from base64 import b64encode
13-from urllib import quote, urlencode
14
15 try:
16 import json
17+ json # For Pyflakes.
18 except:
19 import simplejson as json
20
21-from twisted.web.client import getPage
22+from txconvore.util import PerseverantDownloader
23
24
25
26@@ -37,6 +38,93 @@
27
28
29
30+class Endpoint(object):
31+ """
32+ A Convore endpoint.
33+
34+ Used to submit requests to Convore.
35+
36+ @type debug: C{bool}
37+ @cvar debug: Enable debug logging.
38+
39+ @type baseURL: C{str}
40+ @ivar baseURL: Base URL of this endpoint, including the trailing slash.
41+
42+ @type version: C{str} or C{None}
43+ @ivar version: API version to use, or C{None} for the latest.
44+
45+ @ivar creds: A credentials object, or C{None} to authenticate anonymously.
46+ """
47+ debug = False
48+
49+
50+ def __init__(self, baseURL='https://convore.com/api/', version=None,
51+ creds=None):
52+ if creds is not None and not isinstance(creds, BasicCreds):
53+ raise ValueError('Only basic authentication is supported')
54+ self.baseURL = baseURL
55+ self.version = version
56+ self.creds = creds
57+
58+
59+ def getRootURL(self):
60+ """
61+ Get the root endpoint URL, including trailing slash.
62+ """
63+ url = self.baseURL
64+ if self.version is not None:
65+ url += self.version + '/'
66+ return url
67+
68+
69+ def getPage(self, *a, **kw):
70+ downloader = PerseverantDownloader(*a, **kw)
71+ downloader.debug = self.debug
72+ return downloader.download()
73+
74+
75+ def submit(self, url, method, data=None, headers=None):
76+ """
77+ Submit a request to a Convore endpoint.
78+
79+ @type url: C{str}
80+ @param url: URL to submit to.
81+
82+ @type method: C{str}
83+ @param method: HTTP method to use when submitting a request.
84+
85+ @type data: C{dict}
86+ @param data: HTTP POST data, or C{None}.
87+
88+ @type headers: C{dict}
89+ @param headers: HTTP headers to include when submitting a request.
90+
91+ @rtype: C{Deferred}
92+ """
93+ if headers is None:
94+ headers = {}
95+
96+ headers['Accept'] = 'application/json'
97+ if data is not None and not isinstance(data, str):
98+ data = urllib.urlencode(data)
99+ headers['Content-Type'] = 'application/x-www-form-urlencoded'
100+
101+ if self.creds is not None:
102+ headers['Authorization'] = self.creds.encode()
103+
104+ def _parse((res, headers)):
105+ if res:
106+ return json.loads(unicode(res, 'utf-8'))
107+
108+ d = self.getPage(url=url,
109+ method=method,
110+ postdata=data,
111+ headers=headers)
112+ d.addCallback(_parse)
113+ return d
114+
115+
116+
117 class _ConvoreAPI(object):
118 collectionName = None
119
120@@ -45,41 +133,57 @@
121 self.components = []
122
123
124+ def _translateValue(self, value):
125+ if isinstance(value, bool):
126+ return [u'false', u'true'][value]
127+ elif not isinstance(value, unicode):
128+ return unicode(value, 'ascii')
129+ return value
130+
131+
132 def getURL(self, endpoint, prefix=None, suffix=[], params={}):
133 """
134 Get the URL for this path as accessed through the given endpoint.
135
136 @type endpoint: L{Endpoint}
137- @param endpoint: The endpoint to operate through.
138+ @param endpoint: Endpoint to operate through.
139
140 @type prefix: C{list} of C{unicode}
141- @param prefix: A list of components to prepend before this path.
142+ @param prefix: List of components to prepend before this path, or
143+ C{None}.
144
145 @type suffix: C{list} of C{unicode}
146- @param suffix: A list of components to append after this path.
147+ @param suffix: List of components to append after this path, or
148+ C{None}.
149+
150+ @type params: C{dict} mapping C{unicode} to C{unicode}
151+ @param params: Mapping of URL query parameter names and values, or
152+ C{None}.
153
154 @rtype: C{str}
155 """
156- if self.collectionName is None:
157- raise NotImplementedError(
158- 'Must override collectionName on _ConvoreAPI subclasses')
159-
160 components = []
161 if prefix is None:
162+ if self.collectionName is None:
163+ raise NotImplementedError(
164+ 'Must override collectionName on _ConvoreAPI subclasses')
165 components.append(self.collectionName)
166 else:
167 components.extend(prefix)
168 components.extend(self.components)
169 components.extend(suffix)
170
171- def _quote(xs):
172- return [quote(x.encode('utf-8'), safe='')
173- for x in xs]
174+ def _quote(v):
175+ return urllib.quote(
176+ self._translateValue(v).encode('utf-8'), safe='')
177
178- components = [quote(component.encode('utf-8'), safe='')
179- for component in components]
180- params = [(k, v) for k, v in params.iteritems() if v is not None]
181- return endpoint.getRootURL() + '/'.join(components) + '?' + urlencode(params)
182+ components = [_quote(component) for component in components]
183+ url = endpoint.getRootURL() + '/'.join(components)
184+ params = [(_quote(k), _quote(v))
185+ for k, v in params.iteritems() if v is not None]
186+ if params:
187+ url = url + '?' + urllib.urlencode(params)
188+ return url
189
190
191
192@@ -143,6 +247,24 @@
193
194
195
196+class User(_ConvoreAPI):
197+ collectionName = 'users'
198+
199+
200+ def __init__(self, userID):
201+ super(User, self).__init__()
202+ self.components = [userID]
203+
204+
205+ def info(self, endpoint):
206+ """
207+ Get detailed information about the user.
208+ """
209+ url = self.getURL(endpoint) + '.json'
210+ return endpoint.submit(url, 'GET')
211+
212+
213+
214 class Group(_ConvoreAPI):
215 collectionName = 'groups'
216
217@@ -191,8 +313,8 @@
218 """
219 Mark all messages in the group as read.
220 """
221- url = self.geturl(endpoint, suffix=['mark_read.json'])
222- return endpoint.submit(url, 'post')
223+ url = self.getURL(endpoint, suffix=['mark_read.json'])
224+ return endpoint.submit(url, 'POST')
225
226
227 def members(self, endpoint, filter=None):
228@@ -250,15 +372,19 @@
229 self.components = [topicID]
230
231
232- def createMessage(self, endpoint, message):
233+ def createMessage(self, endpoint, message, pasted=False):
234 """
235 Post a new message.
236+
237+ @type pasted: C{bool}
238+ @param pasted: Is the message preformatted?
239 """
240 url = self.getURL(endpoint, suffix=['messages', 'create.json'])
241 return endpoint.submit(
242 url,
243 'POST',
244- data=dict(message=message))
245+ data=dict(message=message,
246+ pasted=pasted))
247
248
249 def delete(self, endpoint):
250@@ -270,6 +396,15 @@
251 return endpoint.submit(url, 'POST')
252
253
254+ def edit(self, endpoint, name):
255+ """
256+ Edit a topic. You must be the creator of the topic or a group admin in
257+ order to edit the topic.
258+ """
259+ url = self.getURL(endpoint, suffix=['edit.json'])
260+ return endpoint.submit(url, 'POST', data=dict(name=name))
261+
262+
263 def info(self, endpoint):
264 """
265 Get detailed information about the topic.
266@@ -282,8 +417,8 @@
267 """
268 Mark all messages in a topic as read.
269 """
270- url = self.geturl(endpoint, suffix=['mark_read.json'])
271- return endpoint.submit(url, 'post')
272+ url = self.getURL(endpoint, suffix=['mark_read.json'])
273+ return endpoint.submit(url, 'POST')
274
275
276 def messages(self, endpoint, untilMessageID=None, markRead=False):
277@@ -331,54 +466,3 @@
278 """
279 url = self.getURL(endpoint, suffix=['delete.json'])
280 return endpoint.submit(url, 'POST')
281-
282-
283-
284-class Endpoint(object):
285- def __init__(self, baseURL='https://convore.com/api/', version=None,
286- creds=None):
287- if creds is not None and not isinstance(creds, BasicCreds):
288- raise ValueError('Only basic authentication is supported')
289-
290- self.baseURL = baseURL
291- self.version = version
292- self.creds = creds
293-
294-
295- def getRootURL(self):
296- """
297- Get the root endpoint URL, including trailing slash.
298- """
299- url = self.baseURL
300- if self.version is not None:
301- url += self.version + '/'
302- return url
303-
304-
305- def getPage(self, *a, **kw):
306- return getPage(*a, **kw)
307-
308-
309- def submit(self, url, method, data=None, headers=None):
310- print 'Submitting %s (%s: %r)...' % (url, method, data)
311- if headers is None:
312- headers = {}
313-
314- headers['Accept'] = 'application/json'
315- if data is not None and not isinstance(data, str):
316- data = urlencode(data)
317- headers['Content-Type'] = 'application/x-www-form-urlencoded'
318-
319- if self.creds is not None:
320- headers['Authorization'] = self.creds.encode()
321-
322- def _parse(res):
323- if res:
324- return json.loads(unicode(res, 'utf-8'))
325-
326- d = self.getPage(url=url,
327- method=method,
328- postdata=data,
329- headers=headers,
330- agent='txConvore')
331- return d.addCallback(_parse)
332
333=== added directory 'txconvore/test'
334=== added file 'txconvore/test/__init__.py'
335=== added directory 'txconvore/test/data'
336=== added file 'txconvore/test/data/account_groups.json'
337--- txconvore/test/data/account_groups.json 1970-01-01 00:00:00 +0000
338+++ txconvore/test/data/account_groups.json 2011-04-13 16:28:00 +0000
339@@ -0,0 +1,64 @@
340+{
341+ "groups": [
342+ {
343+ "creator": {
344+ "id": "XXX",
345+ "img": "XXX",
346+ "url": "XXX",
347+ "username": "fdrake"
348+ },
349+ "date_created": 1297277511.4270909,
350+ "date_latest_message": 1301140974.9002681,
351+ "id": "XXX",
352+ "is_admin": false,
353+ "is_member": true,
354+ "kind": "public",
355+ "members_count": 2051,
356+ "name": "Python",
357+ "slug": "python",
358+ "topics_count": 103,
359+ "unread": 1,
360+ "url": "XXX"
361+ },
362+ {
363+ "creator": {
364+ "id": "XXX",
365+ "img": "XXX",
366+ "url": "XXX",
367+ "username": "djchall"
368+ },
369+ "date_created": 1296454650.842808,
370+ "date_latest_message": 1301122342.4191129,
371+ "id": "XXX",
372+ "is_admin": false,
373+ "is_member": true,
374+ "kind": "public",
375+ "members_count": 378,
376+ "name": "Feedback",
377+ "slug": "feedback",
378+ "topics_count": 446,
379+ "unread": 0,
380+ "url": "XXX"
381+ },
382+ {
383+ "creator": {
384+ "id": "XXX",
385+ "img": "XXX",
386+ "url": "XXX",
387+ "username": "mithrandi"
388+ },
389+ "date_created": 1300960771.1402569,
390+ "date_latest_message": 1301099446.2922111,
391+ "id": "XXX",
392+ "is_admin": false,
393+ "is_member": true,
394+ "kind": "public",
395+ "members_count": 13,
396+ "name": "Twisted",
397+ "slug": "twisted",
398+ "topics_count": 1,
399+ "unread": 0,
400+ "url": "XXX"
401+ }
402+ ]
403+}
404
405=== added file 'txconvore/test/data/account_groups_create.json'
406--- txconvore/test/data/account_groups_create.json 1970-01-01 00:00:00 +0000
407+++ txconvore/test/data/account_groups_create.json 2011-04-13 16:28:00 +0000
408@@ -0,0 +1,18 @@
409+{
410+ "group": {
411+ "creator": {
412+ "id": "XXX",
413+ "img": "XXX",
414+ "url": "XXX",
415+ "username": "XXX"
416+ },
417+ "date_created": 1302707186.200645,
418+ "date_latest_message": 1302707186.2006581,
419+ "id": "XXX",
420+ "kind": "private",
421+ "members_count": 1,
422+ "name": "xxx",
423+ "slug": "xxx",
424+ "url": "XXX"
425+ }
426+}
427
428=== added file 'txconvore/test/data/account_mentions.json'
429--- txconvore/test/data/account_mentions.json 1970-01-01 00:00:00 +0000
430+++ txconvore/test/data/account_mentions.json 2011-04-13 16:28:00 +0000
431@@ -0,0 +1,51 @@
432+{
433+ "mentions": [
434+ {
435+ "message": {
436+ "date_created": 1301096699.259259,
437+ "embeds": [],
438+ "id": "XXX",
439+ "message": "@jonathanj Does it do static inspection though",
440+ "stars": [],
441+ "user": {
442+ "id": "XXX",
443+ "img": "XXX",
444+ "url": "XXX",
445+ "username": "lvh"
446+ }
447+ },
448+ "topic": {
449+ "date_created": 1301007329.5264051,
450+ "date_latest_message": 1301109138.3067639,
451+ "id": "XXX",
452+ "name": "Programmatically building __all__ with a decorator",
453+ "slug": "programmatically-building-__all__-with-a-decorator",
454+ "url": "XXX"
455+ }
456+ },
457+ {
458+ "message": {
459+ "date_created": 1301072572.6535151,
460+ "embeds": [],
461+ "id": "XXX",
462+ "message": "(also @jonathanj is awesome)",
463+ "stars": [],
464+ "user": {
465+ "id": "XXX",
466+ "img": "XXX",
467+ "url": "XXX",
468+ "username": "mithrandi"
469+ }
470+ },
471+ "topic": {
472+ "date_created": 1301072549.0416789,
473+ "date_latest_message": 1301099446.2922111,
474+ "id": "XXX",
475+ "name": "txConvore",
476+ "slug": "txconvore",
477+ "url": "XXX"
478+ }
479+ }
480+ ],
481+ "unread": 0
482+}
483
484=== added file 'txconvore/test/data/account_online.json'
485--- txconvore/test/data/account_online.json 1970-01-01 00:00:00 +0000
486+++ txconvore/test/data/account_online.json 2011-04-13 16:28:00 +0000
487@@ -0,0 +1,15 @@
488+{
489+ "count": 1,
490+ "online": [
491+ {
492+ "bio": "Foo",
493+ "id": "XXX",
494+ "img": "XXX",
495+ "location": "Earth",
496+ "name": "Chuck Whatshisface",
497+ "url": "XXX",
498+ "username": "XXX",
499+ "web": ""
500+ }
501+ ]
502+}
503
504=== added file 'txconvore/test/data/account_verify.json'
505--- txconvore/test/data/account_verify.json 1970-01-01 00:00:00 +0000
506+++ txconvore/test/data/account_verify.json 2011-04-13 16:28:00 +0000
507@@ -0,0 +1,6 @@
508+{
509+ "id": "XXX",
510+ "img": "XXX",
511+ "url": "XXX",
512+ "username": "jonathanj"
513+}
514
515=== added file 'txconvore/test/data/groups.json'
516--- txconvore/test/data/groups.json 1970-01-01 00:00:00 +0000
517+++ txconvore/test/data/groups.json 2011-04-13 16:28:00 +0000
518@@ -0,0 +1,18 @@
519+{
520+ "group": {
521+ "creator": {
522+ "id": "XXX",
523+ "img": "XXX",
524+ "url": "XXX",
525+ "username": "x"
526+ },
527+ "date_created": 1300949568.68558,
528+ "date_latest_message": 1301574176.861922,
529+ "id": "4321",
530+ "kind": "public",
531+ "members_count": 42,
532+ "name": "agroup",
533+ "slug": "agroup",
534+ "url": "XXX"
535+ }
536+}
537
538=== added file 'txconvore/test/data/groups_members.json'
539--- txconvore/test/data/groups_members.json 1970-01-01 00:00:00 +0000
540+++ txconvore/test/data/groups_members.json 2011-04-13 16:28:00 +0000
541@@ -0,0 +1,22 @@
542+{
543+ "members": [
544+ {
545+ "admin": false,
546+ "user": {
547+ "id": "1",
548+ "img": "XXX",
549+ "url": "XXX",
550+ "username": "aye"
551+ }
552+ },
553+ {
554+ "admin": true,
555+ "user": {
556+ "id": "2",
557+ "img": "XXX",
558+ "url": "XXX",
559+ "username": "bee"
560+ }
561+ }
562+ ]
563+}
564
565=== added file 'txconvore/test/data/groups_online.json'
566--- txconvore/test/data/groups_online.json 1970-01-01 00:00:00 +0000
567+++ txconvore/test/data/groups_online.json 2011-04-13 16:28:00 +0000
568@@ -0,0 +1,25 @@
569+{
570+ "count": 2,
571+ "online": [
572+ {
573+ "bio": "",
574+ "id": "1",
575+ "img": "",
576+ "location": "",
577+ "name": "Aye",
578+ "url": "XXX",
579+ "username": "aye",
580+ "web": ""
581+ },
582+ {
583+ "bio": "",
584+ "id": "2",
585+ "img": "",
586+ "location": "",
587+ "name": "Bee",
588+ "url": "XXX",
589+ "username": "bee",
590+ "web": ""
591+ }
592+ ]
593+}
594
595=== added file 'txconvore/test/data/groups_topics.json'
596--- txconvore/test/data/groups_topics.json 1970-01-01 00:00:00 +0000
597+++ txconvore/test/data/groups_topics.json 2011-04-13 16:28:00 +0000
598@@ -0,0 +1,37 @@
599+{
600+ "topics": [
601+ {
602+ "creator": {
603+ "id": "XXX",
604+ "img": "XXX",
605+ "url": "XXX",
606+ "username": "XXX"
607+ },
608+ "date_created": 1300959156.58742,
609+ "date_latest_message": 1301585498.7861631,
610+ "id": "1",
611+ "message_count": 935,
612+ "name": "General Chat",
613+ "slug": "general-chat",
614+ "unread": 0,
615+ "url": "XXX"
616+ },
617+ {
618+ "creator": {
619+ "id": "XXX",
620+ "img": "XXX",
621+ "url": "XXX",
622+ "username": "XXX"
623+ },
624+ "date_created": 1301574442.4583809,
625+ "date_latest_message": 1301574442.4583919,
626+ "id": "2",
627+ "message_count": 1,
628+ "name": "txConvore Testing",
629+ "slug": "txconvore-testing",
630+ "unread": 1,
631+ "url": "XXX"
632+ }
633+ ],
634+ "until_id": null
635+}
636
637=== added file 'txconvore/test/data/groups_topics_create.json'
638--- txconvore/test/data/groups_topics_create.json 1970-01-01 00:00:00 +0000
639+++ txconvore/test/data/groups_topics_create.json 2011-04-13 16:28:00 +0000
640@@ -0,0 +1,16 @@
641+{
642+ "topic": {
643+ "creator": {
644+ "id": "XXX",
645+ "img": "XXX",
646+ "url": "XXX",
647+ "username": "XXX"
648+ },
649+ "date_created": 1301574442.4583809,
650+ "date_latest_message": 1301574442.4583919,
651+ "id": "1234",
652+ "name": "txConvore Testing",
653+ "slug": "txconvore-testing",
654+ "url": "XXX"
655+ }
656+}
657
658=== added file 'txconvore/test/data/messages_star.json'
659--- txconvore/test/data/messages_star.json 1970-01-01 00:00:00 +0000
660+++ txconvore/test/data/messages_star.json 2011-04-13 16:28:00 +0000
661@@ -0,0 +1,11 @@
662+{
663+ "star": {
664+ "date_created": 1302597756.098841,
665+ "user": {
666+ "id": "1",
667+ "img": "XXX",
668+ "url": "XXX",
669+ "username": "XXX"
670+ }
671+ }
672+}
673
674=== added file 'txconvore/test/data/messages_unstar.json'
675--- txconvore/test/data/messages_unstar.json 1970-01-01 00:00:00 +0000
676+++ txconvore/test/data/messages_unstar.json 2011-04-13 16:28:00 +0000
677@@ -0,0 +1,11 @@
678+{
679+ "unstar": {
680+ "date_created": 1302597756.098841,
681+ "user": {
682+ "id": "1",
683+ "img": "XXX",
684+ "url": "XXX",
685+ "username": "XXX"
686+ }
687+ }
688+}
689
690=== added file 'txconvore/test/data/topics_edit.json'
691--- txconvore/test/data/topics_edit.json 1970-01-01 00:00:00 +0000
692+++ txconvore/test/data/topics_edit.json 2011-04-13 16:28:00 +0000
693@@ -0,0 +1,16 @@
694+{
695+ "topic": {
696+ "creator": {
697+ "id": "XXX",
698+ "img": "XXX",
699+ "url": "XXX",
700+ "username": "XXX"
701+ },
702+ "date_created": 1301574442.4583809,
703+ "date_latest_message": 1301574442.4583919,
704+ "id": "XXX",
705+ "name": "New Name",
706+ "slug": "test",
707+ "url": "XXX"
708+ }
709+}
710
711=== added file 'txconvore/test/data/topics_info.json'
712--- txconvore/test/data/topics_info.json 1970-01-01 00:00:00 +0000
713+++ txconvore/test/data/topics_info.json 2011-04-13 16:28:00 +0000
714@@ -0,0 +1,16 @@
715+{
716+ "topic": {
717+ "creator": {
718+ "id": "XXX",
719+ "img": "XXX",
720+ "url": "XXX",
721+ "username": "XXX"
722+ },
723+ "date_created": 1300959156.58742,
724+ "date_latest_message": 1302596569.9464591,
725+ "id": "1",
726+ "name": "General Chat",
727+ "slug": "general-chat",
728+ "url": "XXX"
729+ }
730+}
731
732=== added file 'txconvore/test/data/topics_messages.json'
733--- txconvore/test/data/topics_messages.json 1970-01-01 00:00:00 +0000
734+++ txconvore/test/data/topics_messages.json 2011-04-13 16:28:00 +0000
735@@ -0,0 +1,45 @@
736+
737+{
738+ "messages": [
739+ {
740+ "date_created": 1302596453.564373,
741+ "embeds": [],
742+ "id": "XXX",
743+ "message": "Test message",
744+ "stars": [],
745+ "user": {
746+ "id": "XXX",
747+ "img": "XXX",
748+ "url": "XXX",
749+ "username": "XXX"
750+ }
751+ },
752+ {
753+ "date_created": 1302596569.9341509,
754+ "embeds": [],
755+ "id": "XXX",
756+ "message": "Test response",
757+ "stars": [],
758+ "user": {
759+ "id": "XXX",
760+ "img": "XXX",
761+ "url": "XXX",
762+ "username": "XXX"
763+ }
764+ },
765+ {
766+ "date_created": 1302596724.3432169,
767+ "embeds": [],
768+ "id": "XXX",
769+ "message": "oops, got the wrong ID",
770+ "stars": [],
771+ "user": {
772+ "id": "XXX",
773+ "img": "XXX",
774+ "url": "XXX",
775+ "username": "XXX"
776+ }
777+ }
778+ ],
779+ "until_id": "639139"
780+}
781
782=== added file 'txconvore/test/data/topics_messages_create.json'
783--- txconvore/test/data/topics_messages_create.json 1970-01-01 00:00:00 +0000
784+++ txconvore/test/data/topics_messages_create.json 2011-04-13 16:28:00 +0000
785@@ -0,0 +1,15 @@
786+{
787+ "message": {
788+ "date_created": 1302596453.564373,
789+ "embeds": [],
790+ "id": "XXX",
791+ "message": "Test message",
792+ "stars": [],
793+ "user": {
794+ "id": "XXX",
795+ "img": "XXX",
796+ "url": "XXX",
797+ "username": "XXX"
798+ }
799+ }
800+}
801
802=== added file 'txconvore/test/data/users_info.json'
803--- txconvore/test/data/users_info.json 1970-01-01 00:00:00 +0000
804+++ txconvore/test/data/users_info.json 2011-04-13 16:28:00 +0000
805@@ -0,0 +1,12 @@
806+{
807+ "user": {
808+ "bio": "",
809+ "id": "1",
810+ "img": "XXX",
811+ "location": "",
812+ "name": "Chuck Finley",
813+ "url": "XXX",
814+ "username": "Y",
815+ "web": "http://web.com/"
816+ }
817+}
818
819=== added file 'txconvore/test/test_client.py'
820--- txconvore/test/test_client.py 1970-01-01 00:00:00 +0000
821+++ txconvore/test/test_client.py 2011-04-13 16:28:00 +0000
822@@ -0,0 +1,697 @@
823+from twisted.internet.defer import succeed
824+from twisted.python.filepath import FilePath
825+from twisted.trial.unittest import TestCase
826+
827+from txconvore.client import (
828+ Endpoint, BasicCreds, Account, _ConvoreAPI, Group, Topic, Message, User)
829+
830+
831+
832+class MockEndpoint(Endpoint):
833+ """
834+ Endpoint with submit mocked out.
835+ """
836+ def getPage(self, url, method, postdata=None, headers=None, *a, **kw):
837+ """
838+ Store our parameters instead of submitting a request.
839+ """
840+ self.url = url
841+ self.method = method
842+ self.data = postdata
843+ self.headers = headers
844+ return succeed((self.response, {}))
845+
846+
847+
848+class EndpointTests(TestCase):
849+ """
850+ Tests for L{txconvore.client.Endpoint}.
851+ """
852+ def test_defaultEndpoint(self):
853+ """
854+ L{Endpoint} defaults to the real global Convore endpoint.
855+ """
856+ endpoint = Endpoint()
857+ self.assertEqual(endpoint.baseURL, 'https://convore.com/api/')
858+
859+
860+ def test_getDefaultRoot(self):
861+ """
862+ The default root URL has no API version.
863+ """
864+ endpoint = Endpoint()
865+ self.assertEqual(endpoint.getRootURL(), endpoint.baseURL)
866+
867+
868+ def test_versionedRoot(self):
869+ """
870+ The root URL must include the API version if specified.
871+ """
872+ endpoint = Endpoint('http://convore.test.url/', '20090817')
873+ self.assertEqual(endpoint.getRootURL(),
874+ 'http://convore.test.url/20090817/')
875+
876+
877+ def test_basicAuth(self):
878+ """
879+ An endpoint constructed with basic auth credentials sends them along
880+ with every request.
881+ """
882+ creds = BasicCreds('testuser', 'password')
883+ endpoint = MockEndpoint('http://convore.test.url/', creds=creds)
884+ endpoint.response = ''
885+ endpoint.submit('http://convore.test.url/blah', 'GET')
886+ self.assertEqual(endpoint.headers['Authorization'],
887+ 'Basic dGVzdHVzZXI6cGFzc3dvcmQ=')
888+
889+
890+ def test_unsuppportedAuth(self):
891+ """
892+ Constructing an endpoint with an unsupported credentials object raises
893+ an exception.
894+ """
895+ self.assertRaises(ValueError, Endpoint, creds=42)
896+
897+
898+
899+class ConvoreAPITests(TestCase):
900+ """
901+ Tests for L{txconvore.client._ConvoreAPI}.
902+ """
903+ def setUp(self):
904+ creds = BasicCreds('testuser', 'password')
905+ self.endpoint = MockEndpoint('http://convore.test.url/api/',
906+ creds=creds)
907+
908+
909+ def test_getURL(self):
910+ """
911+ Constructing an endpoint URL uses the I{collectionName} attribute as a
912+ prefix unless the I{prefix} parameter is given. If the I{suffix}
913+ parameter is given those paths are appended to the URL. The I{params}
914+ parameter specifies URL query parameters.
915+ """
916+ c = _ConvoreAPI()
917+ c.collectionName = 'a'
918+ self.assertEqual(
919+ c.getURL(self.endpoint),
920+ 'http://convore.test.url/api/a')
921+ self.assertEqual(
922+ c.getURL(self.endpoint,
923+ prefix=['b'],
924+ suffix=['c'],
925+ params=dict(quux='baz')),
926+ 'http://convore.test.url/api/b/c?quux=baz')
927+
928+
929+ def test_getURLWithPrefix(self):
930+ """
931+ Construct an endpoint URL overriding the URL prefix.
932+ """
933+ c = _ConvoreAPI()
934+ c.collectionName = 'a'
935+ self.assertEqual(
936+ c.getURL(self.endpoint, prefix=['b']),
937+ 'http://convore.test.url/api/b')
938+ self.assertEqual(
939+ c.getURL(self.endpoint, prefix=['c', 'd']),
940+ 'http://convore.test.url/api/c/d')
941+
942+
943+ def test_getURLWithSuffix(self):
944+ """
945+ Construct an endpoint URL specifying the URL suffix.
946+ """
947+ c = _ConvoreAPI()
948+ c.collectionName = 'a'
949+ self.assertEqual(
950+ c.getURL(self.endpoint, suffix=['b']),
951+ 'http://convore.test.url/api/a/b')
952+ self.assertEqual(
953+ c.getURL(self.endpoint, suffix=['b', 'c']),
954+ 'http://convore.test.url/api/a/b/c')
955+
956+
957+ def test_getURLWithPrefixAndSuffix(self):
958+ """
959+ Construct an endpoint URL overriding the URL prefix and specifying the
960+ URL suffix.
961+ """
962+ c = _ConvoreAPI()
963+ c.collectionName = 'a'
964+ self.assertEqual(
965+ c.getURL(self.endpoint, prefix=['b', 'c'], suffix=['d']),
966+ 'http://convore.test.url/api/b/c/d')
967+ self.assertEqual(
968+ c.getURL(self.endpoint, prefix=['b'], suffix=['c', 'd']),
969+ 'http://convore.test.url/api/b/c/d')
970+ self.assertEqual(
971+ c.getURL(self.endpoint,
972+ prefix=[u'\N{DAGGER}'],
973+ suffix=[u'\N{BULLET}']),
974+ 'http://convore.test.url/api/%E2%80%A0/%E2%80%A2')
975+
976+
977+ def test_getURLWithoutCollectionName(self):
978+ """
979+ Constructing a URL without specifying I{collectionName} or I{prefix}
980+ raises C{NotImplementedError}.
981+ """
982+ c = _ConvoreAPI()
983+ self.assertRaises(
984+ NotImplementedError, c.getURL, self.endpoint)
985+ self.assertEqual(
986+ c.getURL(self.endpoint, prefix=['a']),
987+ 'http://convore.test.url/api/a')
988+
989+
990+ def test_getURLParams(self):
991+ """
992+ Construct an endpoint URL specifying URL query parameters.
993+ """
994+ c = _ConvoreAPI()
995+ c.collectionName = 'a'
996+ self.assertEqual(
997+ c.getURL(
998+ self.endpoint,
999+ params=dict(
1000+ foo='bar')),
1001+ 'http://convore.test.url/api/a?foo=bar')
1002+ self.assertEqual(
1003+ c.getURL(
1004+ self.endpoint,
1005+ params={
1006+ u'k': u'\N{DAGGER}',
1007+ u'\N{BULLET}': u'v'}),
1008+ 'http://convore.test.url/api/a?k=%25E2%2580%25A0&%25E2%2580%25A2=v')
1009+
1010+
1011+
1012+class ConvoreAPITestCase(TestCase):
1013+ def setUp(self):
1014+ creds = BasicCreds('testuser', 'password')
1015+ self.endpoint = MockEndpoint(
1016+ 'http://convore.test.url/api/', creds=creds)
1017+
1018+
1019+ def getTestData(self, filename):
1020+ """
1021+ Open a test data file, in the C{test/data} directory, by name and
1022+ return its contents.
1023+ """
1024+ dataPath = FilePath(__file__).sibling('data').child(filename)
1025+ return dataPath.open('rb').read()
1026+
1027+
1028+ def callAPI(self, method, url, func, *a, **kw):
1029+ """
1030+ Call C{func}, a txConvore API function, verifying the HTTP method on
1031+ the endpoint and the URL the endpoint contacted match C{method} and
1032+ C{url} respectively.
1033+ """
1034+ d = func(self.endpoint, *a, **kw)
1035+ self.assertEqual(self.endpoint.method, method)
1036+ self.assertEqual(self.endpoint.url, url)
1037+ return d
1038+
1039+
1040+
1041+class AccountTests(ConvoreAPITestCase):
1042+ """
1043+ Tests for L{txconvore.client.Account}.
1044+ """
1045+ def setUp(self):
1046+ super(AccountTests, self).setUp()
1047+ self.account = Account()
1048+
1049+
1050+ def test_createGroup(self):
1051+ """
1052+ Create a new group.
1053+ """
1054+ self.endpoint.response = self.getTestData('account_groups_create.json')
1055+ d = self.callAPI(
1056+ 'POST',
1057+ 'http://convore.test.url/api/groups/create.json',
1058+ self.account.createGroup, u'xxx', u'private')
1059+
1060+ @d.addCallback
1061+ def gotData(data):
1062+ self.assertEqual(data[u'group'][u'name'], u'xxx')
1063+ self.assertEqual(data[u'group'][u'kind'], u'private')
1064+
1065+ return d
1066+
1067+
1068+ def test_groups(self):
1069+ """
1070+ Get a list of the current user's groups.
1071+ """
1072+ self.endpoint.response = self.getTestData('account_groups.json')
1073+ d = self.callAPI(
1074+ 'GET',
1075+ 'http://convore.test.url/api/groups.json',
1076+ self.account.groups)
1077+
1078+ @d.addCallback
1079+ def gotData(data):
1080+ self.assertEqual(len(data['groups']), 3)
1081+
1082+ return d
1083+
1084+
1085+ def test_markRead(self):
1086+ """
1087+ Mark all messages as read.
1088+ """
1089+ self.endpoint.response = ''
1090+ return self.callAPI(
1091+ 'GET',
1092+ 'http://convore.test.url/api/account/mark_read.json',
1093+ self.account.markRead)
1094+
1095+
1096+ def test_mentions(self):
1097+ """
1098+ Get the user's mentions.
1099+ """
1100+ self.endpoint.response = self.getTestData('account_mentions.json')
1101+ d = self.callAPI(
1102+ 'GET',
1103+ 'http://convore.test.url/api/account/mentions.json',
1104+ self.account.mentions)
1105+
1106+ @d.addCallback
1107+ def gotData(data):
1108+ self.assertEqual(len(data['mentions']), 2)
1109+
1110+ return d
1111+
1112+
1113+ def test_online(self):
1114+ """
1115+ Get members online now.
1116+ """
1117+ self.endpoint.response = self.getTestData('account_online.json')
1118+ d = self.callAPI(
1119+ 'GET',
1120+ 'http://convore.test.url/api/account/online.json',
1121+ self.account.online)
1122+
1123+ @d.addCallback
1124+ def gotData(data):
1125+ self.assertEqual(len(data['online']), 1)
1126+ self.assertEqual(data['count'], 1)
1127+
1128+ return d
1129+
1130+
1131+ def test_verify(self):
1132+ """
1133+ Determine whether the user is properly logged in.
1134+ """
1135+ self.endpoint.response = self.getTestData('account_verify.json')
1136+ d = self.callAPI(
1137+ 'GET',
1138+ 'http://convore.test.url/api/account/verify.json',
1139+ self.account.verify)
1140+
1141+ @d.addCallback
1142+ def gotData(data):
1143+ self.assertEqual(data['username'], u'jonathanj')
1144+
1145+ return d
1146+
1147+
1148+
1149+class UserTests(ConvoreAPITestCase):
1150+ """
1151+ Tests for L{txconvore.client.User}.
1152+ """
1153+ def setUp(self):
1154+ super(UserTests, self).setUp()
1155+ self.user = User(u'1')
1156+
1157+
1158+ def test_info(self):
1159+ """
1160+ Get detailed information about the user.
1161+ """
1162+ self.endpoint.response = self.getTestData('users_info.json')
1163+ d = self.callAPI(
1164+ 'GET',
1165+ 'http://convore.test.url/api/users/1.json',
1166+ self.user.info)
1167+
1168+ @d.addCallback
1169+ def gotData(data):
1170+ self.assertEqual(data['user']['id'], u'1')
1171+ self.assertEqual(data['user']['name'], u'Chuck Finley')
1172+ self.assertEqual(data['user']['username'], u'Y')
1173+
1174+ return d
1175+
1176+
1177+
1178+class GroupTests(ConvoreAPITestCase):
1179+ def setUp(self):
1180+ super(GroupTests, self).setUp()
1181+ self.group = Group(u'4321')
1182+
1183+
1184+ def test_createTopic(self):
1185+ """
1186+ Create a new topic.
1187+ """
1188+ self.endpoint.response = self.getTestData('groups_topics_create.json')
1189+ d = self.callAPI(
1190+ 'POST',
1191+ 'http://convore.test.url/api/groups/4321/topics/create.json',
1192+ self.group.createTopic, u'txConvore Testing')
1193+
1194+ @d.addCallback
1195+ def gotData(data):
1196+ self.assertEqual(data['topic']['id'], u'1234')
1197+ self.assertEqual(data['topic']['name'], u'txConvore Testing')
1198+
1199+ return d
1200+
1201+
1202+ def test_info(self):
1203+ """
1204+ Get detailed information about the group.
1205+ """
1206+ self.endpoint.response = self.getTestData('groups.json')
1207+ d = self.callAPI(
1208+ 'GET',
1209+ 'http://convore.test.url/api/groups/4321.json',
1210+ self.group.info)
1211+
1212+ @d.addCallback
1213+ def gotData(data):
1214+ self.assertEqual(data['group']['creator']['username'], u'x')
1215+ self.assertEqual(data['group']['id'], u'4321')
1216+ self.assertEqual(data['group']['name'], u'agroup')
1217+ self.assertEqual(data['group']['slug'], u'agroup')
1218+
1219+ return d
1220+
1221+
1222+ def test_join(self):
1223+ """
1224+ Join a public group.
1225+ """
1226+ self.endpoint.response = '{"message": "Group joined."}'
1227+ d = self.callAPI(
1228+ 'POST',
1229+ 'http://convore.test.url/api/groups/4321/join.json',
1230+ self.group.join)
1231+ return d
1232+
1233+
1234+ def test_leave(self):
1235+ """
1236+ Leave a group.
1237+ """
1238+ self.endpoint.response = '{"message": "Group left."}'
1239+ d = self.callAPI(
1240+ 'POST',
1241+ 'http://convore.test.url/api/groups/4321/leave.json',
1242+ self.group.leave)
1243+ return d
1244+
1245+
1246+ def test_markRead(self):
1247+ """
1248+ Mark all messages in the group as read.
1249+ """
1250+ self.endpoint.response = '{"message": "Group marked as read."}'
1251+ d = self.callAPI(
1252+ 'POST',
1253+ 'http://convore.test.url/api/groups/4321/mark_read.json',
1254+ self.group.markRead)
1255+ return d
1256+
1257+
1258+ def test_members(self):
1259+ """
1260+ Get all the members of a group.
1261+ """
1262+ self.endpoint.response = self.getTestData('groups_members.json')
1263+ d = self.callAPI(
1264+ 'GET',
1265+ 'http://convore.test.url/api/groups/4321/members.json',
1266+ self.group.members)
1267+
1268+ @d.addCallback
1269+ def gotData(data):
1270+ members = data['members']
1271+ self.assertEqual(len(members), 2)
1272+ self.assertEqual(members[0]['admin'], False)
1273+ self.assertEqual(members[0]['user']['id'], u'1')
1274+ self.assertEqual(members[1]['admin'], True)
1275+ self.assertEqual(members[1]['user']['id'], u'2')
1276+
1277+ return d
1278+
1279+
1280+ def test_membersFilter(self):
1281+ """
1282+ Get all the members of a group, requesting only certain member types.
1283+ """
1284+ self.endpoint.response = ''
1285+ d = self.callAPI(
1286+ 'GET',
1287+ 'http://convore.test.url/api/groups/4321/members.json?filter=admin',
1288+ self.group.members, filter=u'admin')
1289+ return d
1290+
1291+
1292+ def test_onlineMembers(self):
1293+ """
1294+ Get group members online now.
1295+ """
1296+ self.endpoint.response = self.getTestData('groups_online.json')
1297+ d = self.callAPI(
1298+ 'GET',
1299+ 'http://convore.test.url/api/groups/4321/online.json',
1300+ self.group.onlineMembers)
1301+
1302+ @d.addCallback
1303+ def gotData(data):
1304+ online = data['online']
1305+ self.assertEqual(len(online), 2)
1306+ self.assertEqual(data['count'], len(online))
1307+ self.assertEqual(online[0]['id'], u'1')
1308+ self.assertEqual(online[1]['id'], u'2')
1309+
1310+ return d
1311+
1312+
1313+ def test_request(self):
1314+ """
1315+ Request to join a private group.
1316+ """
1317+ self.endpoint.response = '{"message": "Something something."}'
1318+ d = self.callAPI(
1319+ 'POST',
1320+ 'http://convore.test.url/api/groups/4321/request.json',
1321+ self.group.request)
1322+ return d
1323+
1324+
1325+ def test_topics(self):
1326+ """
1327+ Get the latest topics in a group.
1328+ """
1329+ self.endpoint.response = self.getTestData('groups_topics.json')
1330+ d = self.callAPI(
1331+ 'GET',
1332+ 'http://convore.test.url/api/groups/4321/topics.json',
1333+ self.group.topics)
1334+
1335+ @d.addCallback
1336+ def gotData(data):
1337+ topics = data['topics']
1338+ self.assertEqual(len(topics), 2)
1339+ self.assertEqual(topics[0]['id'], u'1')
1340+ self.assertEqual(topics[1]['id'], u'2')
1341+
1342+ return d
1343+
1344+
1345+
1346+class TopicTests(ConvoreAPITestCase):
1347+ def setUp(self):
1348+ super(TopicTests, self).setUp()
1349+ self.topic = Topic(u'1')
1350+
1351+
1352+ def test_createMessage(self):
1353+ """
1354+ Post a new message.
1355+ """
1356+ self.endpoint.response = self.getTestData('topics_messages_create.json')
1357+ d = self.callAPI(
1358+ 'POST',
1359+ 'http://convore.test.url/api/topics/1/messages/create.json',
1360+ self.topic.createMessage, u'Test message')
1361+
1362+ @d.addCallback
1363+ def gotData(data):
1364+ message = data[u'message']
1365+ self.assertEqual(message[u'message'], u'Test message')
1366+
1367+ return d
1368+
1369+
1370+ def test_delete(self):
1371+ """
1372+ Delete a topic.
1373+ """
1374+ self.endpoint.response = '{"message": "Something something."}'
1375+ d = self.callAPI(
1376+ 'POST',
1377+ 'http://convore.test.url/api/topics/1/delete.json',
1378+ self.topic.delete)
1379+ return d
1380+
1381+
1382+ def test_edit(self):
1383+ """
1384+ Delete a topic.
1385+ """
1386+ self.endpoint.response = self.getTestData('topics_edit.json')
1387+ d = self.callAPI(
1388+ 'POST',
1389+ 'http://convore.test.url/api/topics/1/edit.json',
1390+ self.topic.edit, name=u'New Name')
1391+ return d
1392+
1393+
1394+ def test_info(self):
1395+ """
1396+ Get detailed information about the topic.
1397+ """
1398+ self.endpoint.response = self.getTestData('topics_info.json')
1399+ d = self.callAPI(
1400+ 'GET',
1401+ 'http://convore.test.url/api/topics/1.json',
1402+ self.topic.info)
1403+
1404+ @d.addCallback
1405+ def gotData(data):
1406+ self.assertEqual(data[u'topic'][u'id'], u'1')
1407+
1408+ return d
1409+
1410+
1411+ def test_markRead(self):
1412+ """
1413+ Mark all messages in a topic as read.
1414+ """
1415+ self.endpoint.response = '{"message": "Something something."}'
1416+ d = self.callAPI(
1417+ 'POST',
1418+ 'http://convore.test.url/api/topics/1/mark_read.json',
1419+ self.topic.markRead)
1420+ return d
1421+
1422+
1423+ def test_messages(self):
1424+ """
1425+ Get the latest messages in a topic.
1426+ """
1427+ self.endpoint.response = self.getTestData('topics_messages.json')
1428+ d = self.callAPI(
1429+ 'GET',
1430+ 'http://convore.test.url/api/topics/1/messages.json?mark_read=false',
1431+ self.topic.messages)
1432+
1433+ @d.addCallback
1434+ def gotData(data):
1435+ self.assertEqual(len(data[u'messages']), 3)
1436+ self.assertEqual(data[u'until_id'], u'639139')
1437+
1438+ return d
1439+
1440+
1441+ def test_messagesMarkRead(self):
1442+ """
1443+ Get the latest messages in a topic.
1444+ """
1445+ self.endpoint.response = '{"message": "Something something."}'
1446+ d = self.callAPI(
1447+ 'GET',
1448+ 'http://convore.test.url/api/topics/1/messages.json?mark_read=true',
1449+ self.topic.messages, markRead=True)
1450+ return d
1451+
1452+
1453+ def test_messagesUntilMessageID(self):
1454+ """
1455+ Get the latest messages in a topic.
1456+ """
1457+ self.endpoint.response = '{"message": "Something something."}'
1458+ d = self.callAPI(
1459+ 'GET',
1460+ 'http://convore.test.url/api/topics/1/messages.json?until_id=42&mark_read=false',
1461+ self.topic.messages, untilMessageID=u'42')
1462+ return d
1463+
1464+
1465+
1466+class MessageTests(ConvoreAPITestCase):
1467+ def setUp(self):
1468+ super(MessageTests, self).setUp()
1469+ self.message = Message(u'21')
1470+
1471+
1472+ def test_star(self):
1473+ """
1474+ Star a message. If the message has already been starred by this user,
1475+ this endpoint will then unstar the message.
1476+ """
1477+ self.endpoint.response = self.getTestData('messages_star.json')
1478+ d = self.callAPI(
1479+ 'POST',
1480+ 'http://convore.test.url/api/messages/21/star.json',
1481+ self.message.star)
1482+
1483+ @d.addCallback
1484+ def gotData(data):
1485+ self.assertTrue(data.get(u'star') is not None)
1486+ self.assertEqual(data[u'star'][u'user'][u'id'], u'1')
1487+
1488+ return d
1489+
1490+
1491+ def test_unstar(self):
1492+ """
1493+ Star a message. If the message has already been starred by this user,
1494+ this endpoint will then unstar the message.
1495+ """
1496+ self.endpoint.response = self.getTestData('messages_unstar.json')
1497+ d = self.callAPI(
1498+ 'POST',
1499+ 'http://convore.test.url/api/messages/21/star.json',
1500+ self.message.star)
1501+
1502+ @d.addCallback
1503+ def gotData(data):
1504+ self.assertTrue(data.get(u'unstar') is not None)
1505+ self.assertEqual(data['unstar'][u'user'][u'id'], u'1')
1506+
1507+ return d
1508+
1509+
1510+ def test_delete(self):
1511+ """
1512+ Delete a topic.
1513+ """
1514+ self.endpoint.response = '{"message": "Message deleted."}'
1515+ d = self.callAPI(
1516+ 'POST',
1517+ 'http://convore.test.url/api/messages/21/delete.json',
1518+ self.message.delete)
1519+ return d
1520
1521=== added file 'txconvore/test/test_util.py'
1522--- txconvore/test/test_util.py 1970-01-01 00:00:00 +0000
1523+++ txconvore/test/test_util.py 2011-04-13 16:28:00 +0000
1524@@ -0,0 +1,148 @@
1525+from twisted.internet.defer import fail, succeed
1526+from twisted.trial.unittest import TestCase
1527+from twisted.web import client, http, error as weberror
1528+
1529+from txconvore.util import PerseverantDownloader
1530+
1531+
1532+
1533+class PerseverantDownloaderTests(TestCase):
1534+ """
1535+ Tests for L{txconvore.util.PerseverantDownloader}.
1536+ """
1537+ def wrapPageGetter(self, func, response_headers=None):
1538+ """
1539+ Wrap a function returning a C{Deferred} with something suitable to
1540+ replace L{PerseverantDownloader._getPage}.
1541+ """
1542+ def _getPage(*a, **kw):
1543+ fact = client.HTTPClientFactory('a_url')
1544+ fact.response_headers = response_headers
1545+ return func(), fact
1546+ return _getPage
1547+
1548+
1549+ def test_partialContent(self):
1550+ """
1551+ Return any partial content when HTTP 206 is returned.
1552+ """
1553+ def _partialContent():
1554+ return fail(weberror.Error(
1555+ http.PARTIAL_CONTENT,
1556+ response='partial content'))
1557+
1558+ downloader = PerseverantDownloader('a_url')
1559+ downloader._getPage = self.wrapPageGetter(_partialContent)
1560+ d = downloader.download()
1561+
1562+ @d.addCallback
1563+ def gotData((data, headers)):
1564+ self.assertEqual(data, 'partial content')
1565+
1566+ return d
1567+
1568+
1569+ def test_brokenError(self):
1570+ """
1571+ If a C{twisted.web.error.Error} with a non-stringified integer as its
1572+ C{status} occurs then no retrying or partial content handling occurs,
1573+ the Failure is simply returned along the Deferred chain.
1574+ """
1575+ def _brokenThing():
1576+ self.called += 1
1577+ return fail(weberror.Error(''))
1578+
1579+ self.called = 0
1580+ downloader = PerseverantDownloader('a_url')
1581+ downloader._getPage = self.wrapPageGetter(_brokenThing)
1582+ d = downloader.download()
1583+
1584+ @d.addCallback
1585+ def gotData((data, headers)):
1586+ self.fail('Expected errback')
1587+
1588+ @d.addErrback
1589+ def gotFailure(f):
1590+ f.trap(weberror.Error)
1591+ self.assertEqual(f.value.status, '')
1592+ self.assertEqual(self.called, 1)
1593+
1594+ return d
1595+
1596+
1597+ def test_retryableHTTPError(self):
1598+ """
1599+ If a fetch fails with an error code present in
1600+ L{PerseverantDownloader.retryableHTTPCodes} then the fetch is retried
1601+ L{PerseverantDownloader.tries} times, eventually failing.
1602+ """
1603+ def _retryableWebError():
1604+ self.called += 1
1605+ return fail(weberror.Error(errorCode))
1606+
1607+ self.called = 0
1608+ tries = 3
1609+ downloader = PerseverantDownloader('a_url', tries=tries)
1610+ errorCode = http.INTERNAL_SERVER_ERROR
1611+ self.assertIn(errorCode, downloader.retryableHTTPCodes)
1612+ # Let's not make the tests take longer than they have to.
1613+ downloader.factor = 0
1614+ downloader._getPage = self.wrapPageGetter(_retryableWebError)
1615+ d = self.assertFailure(downloader.download(), weberror.Error)
1616+
1617+ @d.addCallback
1618+ def checkError(e):
1619+ failures = self.flushLoggedErrors(weberror.Error)
1620+ self.assertEqual(len(failures), tries)
1621+ self.assertEqual(self.called, tries)
1622+ self.assertEqual(e.status, errorCode)
1623+
1624+ return d
1625+
1626+
1627+ def test_notRetryableHTTPError(self):
1628+ """
1629+ If a fetch fails with an error code B{not} present in
1630+ L{PerseverantDownloader.retryableHTTPCodes} then no retry attempt is
1631+ made.
1632+ """
1633+ def _notRetryableWeberror():
1634+ self.called += 1
1635+ return fail(weberror.Error(errorCode))
1636+
1637+ self.called = 0
1638+ tries = 3
1639+ downloader = PerseverantDownloader('a_url', tries=tries)
1640+ errorCode = http.NOT_FOUND
1641+ self.assertNotIn(errorCode, downloader.retryableHTTPCodes)
1642+ downloader._getPage = self.wrapPageGetter(_notRetryableWeberror)
1643+ d = self.assertFailure(downloader.download(), weberror.Error)
1644+
1645+ @d.addCallback
1646+ def checkError(e):
1647+ self.assertEqual(self.called, 1)
1648+ self.assertEqual(e.status, errorCode)
1649+
1650+ return d
1651+
1652+
1653+ def test_download(self):
1654+ """
1655+ L{PerseverantDownloader.download} fetches the data at the given URL and
1656+ returns it along with the response headers.
1657+ """
1658+ def _getPage():
1659+ return succeed('some data')
1660+
1661+ downloader = PerseverantDownloader('a_url')
1662+ downloader._getPage = self.wrapPageGetter(
1663+ _getPage,
1664+ response_headers=dict(foo=['bar']))
1665+ d = downloader.download()
1666+
1667+ @d.addCallback
1668+ def checkData((data, headers)):
1669+ self.assertEqual(data, 'some data')
1670+ self.assertEqual(headers.get('foo'), ['bar'])
1671+
1672+ return d
1673
1674=== added file 'txconvore/util.py'
1675--- txconvore/util.py 1970-01-01 00:00:00 +0000
1676+++ txconvore/util.py 2011-04-13 16:28:00 +0000
1677@@ -0,0 +1,190 @@
1678+import time
1679+import urllib
1680+
1681+from twisted.internet import reactor, task, error as ineterror
1682+from twisted.python import log
1683+from twisted.web import client, http, error as weberror
1684+
1685+
1686+
1687+class PerseverantDownloader(object):
1688+ """
1689+ Perseverantly attempt to download a URL.
1690+
1691+ Each retry attempt is delayed by L{factor} up to a maximum of L{maxDelay},
1692+ starting at L{initialDelay}.
1693+
1694+ @type url: C{str}
1695+ @ivar url: HTTP URL to download.
1696+
1697+ @type maxDelay: C{float}
1698+ @cvar maxDelay: Maximum delay, in seconds, between retry attempts.
1699+
1700+ @type initialDelay: C{float}
1701+ @cvar initialDelay: Delay before the first retry attempt.
1702+
1703+ @type factor: C{float}
1704+ @cvar factor: Factor to increase the delay by after each attempt.
1705+
1706+ @type retryableHTTPCodes: C{list}
1707+ @cvar retryableHTTPCodes: HTTP error codes that suggest the error is
1708+ intermittent and that a retry should be attempted.
1709+
1710+ @type defaultTimeout: C{float}
1711+ @cvar defaultTimeout: Default fetch timeout value.
1712+ """
1713+ maxDelay = 3600
1714+ initialDelay = 1.0
1715+ factor = 1.6180339887498948
1716+
1717+ retryableHTTPCodes = [408, 500, 502, 503, 504]
1718+
1719+ defaultTimeout = 300.0
1720+
1721+ debug = False
1722+
1723+
1724+ def __init__(self, url, tries=10, timeout=defaultTimeout, *args, **kwargs):
1725+ """
1726+ Prepare the download information.
1727+
1728+ Any additional positional or keyword arguments are passed on to
1729+ C{twisted.web.client.HTTPPageGetter}.
1730+
1731+ @type url: C{nevow.url.URL} or C{unicode} or C{str}
1732+ @param url: The HTTP URL to attempt to download
1733+
1734+ @type tries: C{int}
1735+ @param tries: The maximum number of retry attempts before giving up
1736+
1737+ @type timeout: C{float}
1738+ @param timeout: Timeout value, in seconds, for the page fetch;
1739+ defaults to L{defaultTimeout}
1740+
1741+ @param *args: Positional arguments to pass to the page getter, see
1742+ L{twisted.web.client.getPage}.
1743+
1744+ @param **kwargs: Keyword arguments to pass to the page getter, see
1745+ L{twisted.web.client.getPage}.
1746+ """
1747+ self.url = url
1748+ self.delay = self.initialDelay
1749+ self.tries = tries
1750+ self.timeout = timeout
1751+ self.args = args
1752+ self.kwargs = kwargs
1753+
1754+
1755+ def _writeLog(self, result, factory):
1756+ """
1757+ Log an HTTP request or response.
1758+ """
1759+ if factory.response_headers is None:
1760+ suffix = 'req'
1761+ headers = factory.headers
1762+ body = factory.postdata
1763+ else:
1764+ suffix = 'res'
1765+ headers = factory.response_headers
1766+ body = result
1767+
1768+ path = urllib.splitquery(factory.path)[0]
1769+ path = '%s_%s_%s.log' % (
1770+ time.time(),
1771+ path[1:].replace('/', '_').replace('.', '_'),
1772+ suffix)
1773+ fd = file(path, 'wb')
1774+ fd.write('%s %s\n' % (factory.method, factory.url))
1775+
1776+ if headers:
1777+ fd.write('\n')
1778+ for k, v in headers.iteritems():
1779+ if not isinstance(v, list):
1780+ v = [v]
1781+ v = map(str, v)
1782+ fd.write('%s: %s\n' % (k, ';'.join(v)))
1783+
1784+ fd.write('\n')
1785+ if body:
1786+ fd.write(body)
1787+ fd.close()
1788+ return result
1789+
1790+
1791+ def _handle206(self, f):
1792+ """
1793+ Return any partial content when HTTP 206 is returned.
1794+ """
1795+ f.trap(weberror.Error)
1796+ err = f.value
1797+ try:
1798+ if int(err.status) == http.PARTIAL_CONTENT:
1799+ return err.response
1800+ except (TypeError, ValueError):
1801+ pass
1802+ return f
1803+
1804+
1805+ def _getPage(self, *args, **kwargs):
1806+ """
1807+ Fetch data from a URL over HTTP.
1808+ """
1809+ kwargs.setdefault('agent', 'txConvore')
1810+ factory = client._makeGetterFactory(
1811+ self.url,
1812+ client.HTTPClientFactory,
1813+ timeout=self.timeout,
1814+ *args, **kwargs)
1815+ return factory.deferred, factory
1816+
1817+
1818+ def download(self):
1819+ """
1820+ Download L{self.url}, retrying if necessary.
1821+
1822+ @rtype: C{Deferred<response, headers>}
1823+ """
1824+ d, f = self._getPage(*self.args, **self.kwargs)
1825+ if self.debug:
1826+ self._writeLog(None, f)
1827+ d.addCallback(self._writeLog, f)
1828+ d.addErrback(self._handle206)
1829+ d.addErrback(self.retryWeb)
1830+ d.addCallback(lambda data: (data, f.response_headers))
1831+ return d
1832+
1833+
1834+ def retryWeb(self, f):
1835+ """
1836+ Retry failed downloads in the case of "web errors."
1837+
1838+ Only errors that are web related are considered for a retry attempt
1839+ and then only when the HTTP status code is one of those in
1840+ L{self.retryableHTTPCodes}.
1841+
1842+ Other errors are not trapped.
1843+ """
1844+ f.trap(weberror.Error, ineterror.ConnectionDone)
1845+ err = f.value
1846+ try:
1847+ if int(err.status) in self.retryableHTTPCodes:
1848+ return self.retry(f)
1849+ except (TypeError, ValueError):
1850+ pass
1851+ return f
1852+
1853+
1854+ def retry(self, f):
1855+ """
1856+ The retry machinery.
1857+
1858+ If C{self.tries} is greater than zero, a retry is attempted for
1859+ C{self.delay} seconds in the future.
1860+ """
1861+ self.tries -= 1
1862+ log.err(f, 'PerseverantDownloader is retrying, %d attempts left.' % (
1863+ self.tries,))
1864+ self.delay = min(self.delay * self.factor, self.maxDelay)
1865+ if self.tries == 0:
1866+ return f
1867+ return task.deferLater(reactor, self.delay, self.download)

Subscribers

People subscribed via source and target branches

to all changes: