Breaking the read into a seperate function make it look cleaner
[doneit] / src / rtm_api.py
1
2 """
3 Python library for Remember The Milk API
4
5 @note For help, see http://www.rememberthemilk.com/services/api/methods/
6 """
7
8 import weakref
9 import warnings
10 import urllib
11 import urllib2
12 import hashlib
13 import time
14
15 _use_simplejson = False
16 try:
17         import simplejson
18         _use_simplejson = True
19 except ImportError:
20         pass
21
22
23 __author__ = 'Sridhar Ratnakumar <http://nearfar.org/>'
24
25 SERVICE_URL = 'http://api.rememberthemilk.com/services/rest/'
26 AUTH_SERVICE_URL = 'http://www.rememberthemilk.com/services/auth/'
27
28
29 class RTMError(StandardError):
30         pass
31
32
33 class RTMAPIError(RTMError):
34         pass
35
36
37 class RTMParseError(RTMError):
38         pass
39
40
41 class AuthStateMachine(object):
42         """If the state is in those setup for the machine, then return
43         the datum sent.  Along the way, it is an automatic call if the
44         datum is a method.
45         """
46
47         class NoData(RTMError):
48                 pass
49
50         def __init__(self, states):
51                 self.states = states
52                 self.data = {}
53
54         def dataReceived(self, state, datum):
55                 if state not in self.states:
56                         raise RTMError, "Invalid state <%s>" % state
57                 self.data[state] = datum
58
59         def get(self, state):
60                 if state in self.data:
61                         return self.data[state]
62                 else:
63                         raise AuthStateMachine.NoData('No data for <%s>' % state)
64
65
66 class RTMapi(object):
67
68         def __init__(self, userID, apiKey, secret, token=None):
69                 self._userID = userID
70                 self._apiKey = apiKey
71                 self._secret = secret
72                 self._authInfo = AuthStateMachine(['frob', 'token'])
73
74                 # this enables one to do 'rtm.tasks.getList()', for example
75                 for prefix, methods in API.items():
76                         setattr(self, prefix,
77                                         RTMAPICategory(self, prefix, methods))
78
79                 if token:
80                         self._authInfo.dataReceived('token', token)
81
82         def _sign(self, params):
83                 "Sign the parameters with MD5 hash"
84                 pairs = ''.join(['%s%s' % (k, v) for (k, v) in sortedItems(params)])
85                 return hashlib.md5(self._secret+pairs).hexdigest()
86
87         @staticmethod
88         def open_url(url, queryArgs=None):
89                 if queryArgs:
90                         url += '?' + urllib.urlencode(queryArgs)
91                 warnings.warn("Performing download of %s" % url, stacklevel=5)
92                 return urllib2.urlopen(url)
93
94         @staticmethod
95         def read(connection, timeout):
96                 # It appears that urllib uses the non-blocking variant of file objects
97                 # which means reads might not always be complete, so grabbing as much
98                 # of the data as possible with a sleep in between to give it more time
99                 # to grab data.
100                 contentLengthField = "Content-Length"
101
102                 chunks = []
103                 chunk = connection.read()
104                 while chunk:
105                         chunks.append(chunk)
106                         time.sleep(1)
107                         chunk = connection.read()
108                 json = "".join(chunks)
109
110                 if "Content-Length" in connection.info():
111                         assert len(json) == int(connection.info()["Content-Length"]), "The packet header promised %s of data but only was able to read %s of data" % (
112                                 connection.info()["Content-Length"],
113                                 len(json),
114                         )
115
116                 return json
117
118         def get(self, **params):
119                 "Get the XML response for the passed `params`."
120                 params['api_key'] = self._apiKey
121                 params['format'] = 'json'
122                 params['api_sig'] = self._sign(params)
123
124                 connection = self.open_url(SERVICE_URL, params)
125                 json = self.read(connection, 5)
126
127                 data = DottedDict('ROOT', parse_json(json))
128                 rsp = data.rsp
129
130                 if rsp.stat == 'fail':
131                         raise RTMAPIError, 'API call failed - %s (%s)' % (
132                                 rsp.err.msg, rsp.err.code)
133                 else:
134                         return rsp
135
136         def getNewFrob(self):
137                 rsp = self.get(method='rtm.auth.getFrob')
138                 self._authInfo.dataReceived('frob', rsp.frob)
139                 return rsp.frob
140
141         def getAuthURL(self):
142                 try:
143                         frob = self._authInfo.get('frob')
144                 except AuthStateMachine.NoData:
145                         frob = self.getNewFrob()
146
147                 params = {
148                         'api_key': self._apiKey,
149                         'perms': 'delete',
150                         'frob': frob
151                 }
152                 params['api_sig'] = self._sign(params)
153                 return AUTH_SERVICE_URL + '?' + urllib.urlencode(params)
154
155         def getToken(self):
156                 frob = self._authInfo.get('frob')
157                 rsp = self.get(method='rtm.auth.getToken', frob=frob)
158                 self._authInfo.dataReceived('token', rsp.auth.token)
159                 return rsp.auth.token
160
161
162 class RTMAPICategory(object):
163         "See the `API` structure and `RTM.__init__`"
164
165         def __init__(self, rtm, prefix, methods):
166                 self._rtm = weakref.ref(rtm)
167                 self._prefix = prefix
168                 self._methods = methods
169
170         def __getattr__(self, attr):
171                 if attr not in self._methods:
172                         raise AttributeError, 'No such attribute: %s' % attr
173
174                 rargs, oargs = self._methods[attr]
175                 if self._prefix == 'tasksNotes':
176                         aname = 'rtm.tasks.notes.%s' % attr
177                 else:
178                         aname = 'rtm.%s.%s' % (self._prefix, attr)
179                 return lambda **params: self.callMethod(
180                         aname, rargs, oargs, **params
181                 )
182
183         def callMethod(self, aname, rargs, oargs, **params):
184                 # Sanity checks
185                 for requiredArg in rargs:
186                         if requiredArg not in params:
187                                 raise TypeError, 'Required parameter (%s) missing' % requiredArg
188
189                 for param in params:
190                         if param not in rargs + oargs:
191                                 warnings.warn('Invalid parameter (%s)' % param)
192
193                 return self._rtm().get(method=aname,
194                                                         auth_token=self._rtm()._authInfo.get('token'),
195                                                         **params)
196
197
198 def sortedItems(dictionary):
199         "Return a list of (key, value) sorted based on keys"
200         keys = dictionary.keys()
201         keys.sort()
202         for key in keys:
203                 yield key, dictionary[key]
204
205
206 class DottedDict(object):
207         "Make dictionary items accessible via the object-dot notation."
208
209         def __init__(self, name, dictionary):
210                 self._name = name
211
212                 if isinstance(dictionary, dict):
213                         for key, value in dictionary.items():
214                                 if isinstance(value, dict):
215                                         value = DottedDict(key, value)
216                                 elif isinstance(value, (list, tuple)):
217                                         value = [DottedDict('%s_%d' % (key, i), item)
218                                                          for i, item in enumerate(value)]
219                                 setattr(self, key, value)
220
221         def __repr__(self):
222                 children = [c for c in dir(self) if not c.startswith('_')]
223                 return '<dotted %s: %s>' % (
224                         self._name,
225                         ', '.join(children))
226
227         def __str__(self):
228                 children = [(c, getattr(self, c)) for c in dir(self) if not c.startswith('_')]
229                 return '{dotted %s: %s}' % (
230                         self._name,
231                         ', '.join(
232                                 ('%s: "%s"' % (k, str(v)))
233                                 for (k, v) in children)
234                 )
235
236
237 def safer_eval(string):
238         try:
239                 return eval(string, {}, {})
240         except SyntaxError, e:
241                 print "="*60
242                 print string
243                 print "="*60
244                 newE = RTMParseError("Error parseing json")
245                 newE.error = e
246                 raise newE
247
248
249 if _use_simplejson:
250         parse_json = simplejson.loads
251 else:
252         parse_json = safer_eval
253
254
255 API = {
256         'auth': {
257                 'checkToken':
258                         [('auth_token'), ()],
259                 'getFrob':
260                         [(), ()],
261                 'getToken':
262                         [('frob'), ()]
263         },
264         'contacts': {
265                 'add':
266                         [('timeline', 'contact'), ()],
267                 'delete':
268                         [('timeline', 'contact_id'), ()],
269                 'getList':
270                         [(), ()],
271         },
272         'groups': {
273                 'add':
274                         [('timeline', 'group'), ()],
275                 'addContact':
276                         [('timeline', 'group_id', 'contact_id'), ()],
277                 'delete':
278                         [('timeline', 'group_id'), ()],
279                 'getList':
280                         [(), ()],
281                 'removeContact':
282                         [('timeline', 'group_id', 'contact_id'), ()],
283         },
284         'lists': {
285                 'add':
286                         [('timeline', 'name'), ('filter'), ()],
287                 'archive':
288                         [('timeline', 'list_id'), ()],
289                 'delete':
290                         [('timeline', 'list_id'), ()],
291                 'getList':
292                         [(), ()],
293                 'setDefaultList':
294                         [('timeline'), ('list_id'), ()],
295                 'setName':
296                         [('timeline', 'list_id', 'name'), ()],
297                 'unarchive':
298                         [('timeline'), ('list_id'), ()],
299         },
300         'locations': {
301                 'getList':
302                         [(), ()],
303         },
304         'reflection': {
305                 'getMethodInfo':
306                         [('methodName',), ()],
307                 'getMethods':
308                         [(), ()],
309         },
310         'settings': {
311                 'getList':
312                         [(), ()],
313         },
314         'tasks': {
315                 'add':
316                         [('timeline', 'name',), ('list_id', 'parse',)],
317                 'addTags':
318                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
319                          ()],
320                 'complete':
321                         [('timeline', 'list_id', 'taskseries_id', 'task_id',), ()],
322                 'delete':
323                         [('timeline', 'list_id', 'taskseries_id', 'task_id'), ()],
324                 'getList':
325                         [(),
326                          ('list_id', 'filter', 'last_sync')],
327                 'movePriority':
328                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'direction'),
329                          ()],
330                 'moveTo':
331                         [('timeline', 'from_list_id', 'to_list_id', 'taskseries_id', 'task_id'),
332                          ()],
333                 'postpone':
334                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
335                          ()],
336                 'removeTags':
337                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
338                          ()],
339                 'setDueDate':
340                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
341                          ('due', 'has_due_time', 'parse')],
342                 'setEstimate':
343                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
344                          ('estimate',)],
345                 'setLocation':
346                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
347                          ('location_id',)],
348                 'setName':
349                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'name'),
350                          ()],
351                 'setPriority':
352                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
353                          ('priority',)],
354                 'setRecurrence':
355                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
356                          ('repeat',)],
357                 'setTags':
358                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
359                          ('tags',)],
360                 'setURL':
361                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
362                          ('url',)],
363                 'uncomplete':
364                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
365                          ()],
366         },
367         'tasksNotes': {
368                 'add':
369                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'note_title', 'note_text'), ()],
370                 'delete':
371                         [('timeline', 'note_id'), ()],
372                 'edit':
373                         [('timeline', 'note_id', 'note_title', 'note_text'), ()],
374         },
375         'test': {
376                 'echo':
377                         [(), ()],
378                 'login':
379                         [(), ()],
380         },
381         'time': {
382                 'convert':
383                         [('to_timezone',), ('from_timezone', 'to_timezone', 'time')],
384                 'parse':
385                         [('text',), ('timezone', 'dateformat')],
386         },
387         'timelines': {
388                 'create':
389                         [(), ()],
390         },
391         'timezones': {
392                 'getList':
393                         [(), ()],
394         },
395         'transactions': {
396                 'undo':
397                         [('timeline', 'transaction_id'), ()],
398         },
399 }