b748b38b4645bdc636c03ac76511124785e7359e
[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_by_length(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                 assert contentLengthField in connection.info(), "Connection didn't provide content length info"
102                 specifiedLength = int(connection.info()["Content-Length"])
103
104                 actuallyRead = 0
105                 chunks = []
106                 chunk = connection.read()
107                 while 0 < timeout:
108                         actuallyRead += len(chunk)
109                         if actuallyRead == specifiedLength:
110                                 break
111                         chunks.append(chunk)
112                         time.sleep(1)
113                         timeout -= 1
114                         chunk = connection.read()
115                 chunks.append(chunk)
116                 json = "".join(chunks)
117
118                 if "Content-Length" in connection.info():
119                         assert len(json) == int(connection.info()["Content-Length"]), "The packet header promised %s of data but only was able to read %s of data" % (
120                                 connection.info()["Content-Length"],
121                                 len(json),
122                         )
123
124                 return json
125
126         @staticmethod
127         def read_by_guess(connection, timeout):
128                 # It appears that urllib uses the non-blocking variant of file objects
129                 # which means reads might not always be complete, so grabbing as much
130                 # of the data as possible with a sleep in between to give it more time
131                 # to grab data.
132
133                 chunks = []
134                 chunk = connection.read()
135                 while chunk and 0 < timeout:
136                         chunks.append(chunk)
137                         time.sleep(1)
138                         timeout -= 1
139                         chunk = connection.read()
140                 chunks.append(chunk)
141                 json = "".join(chunks)
142
143                 if "Content-Length" in connection.info():
144                         assert len(json) == int(connection.info()["Content-Length"]), "The packet header promised %s of data but only was able to read %s of data" % (
145                                 connection.info()["Content-Length"],
146                                 len(json),
147                         )
148
149                 return json
150
151         def get(self, **params):
152                 "Get the XML response for the passed `params`."
153                 params['api_key'] = self._apiKey
154                 params['format'] = 'json'
155                 params['api_sig'] = self._sign(params)
156
157                 connection = self.open_url(SERVICE_URL, params)
158                 json = self.read_by_guess(connection, 5)
159                 # json = self.read_by_length(connection, 5)
160
161                 data = DottedDict('ROOT', parse_json(json))
162                 rsp = data.rsp
163
164                 if rsp.stat == 'fail':
165                         raise RTMAPIError, 'API call failed - %s (%s)' % (
166                                 rsp.err.msg, rsp.err.code)
167                 else:
168                         return rsp
169
170         def getNewFrob(self):
171                 rsp = self.get(method='rtm.auth.getFrob')
172                 self._authInfo.dataReceived('frob', rsp.frob)
173                 return rsp.frob
174
175         def getAuthURL(self):
176                 try:
177                         frob = self._authInfo.get('frob')
178                 except AuthStateMachine.NoData:
179                         frob = self.getNewFrob()
180
181                 params = {
182                         'api_key': self._apiKey,
183                         'perms': 'delete',
184                         'frob': frob
185                 }
186                 params['api_sig'] = self._sign(params)
187                 return AUTH_SERVICE_URL + '?' + urllib.urlencode(params)
188
189         def getToken(self):
190                 frob = self._authInfo.get('frob')
191                 rsp = self.get(method='rtm.auth.getToken', frob=frob)
192                 self._authInfo.dataReceived('token', rsp.auth.token)
193                 return rsp.auth.token
194
195
196 class RTMAPICategory(object):
197         "See the `API` structure and `RTM.__init__`"
198
199         def __init__(self, rtm, prefix, methods):
200                 self._rtm = weakref.ref(rtm)
201                 self._prefix = prefix
202                 self._methods = methods
203
204         def __getattr__(self, attr):
205                 if attr not in self._methods:
206                         raise AttributeError, 'No such attribute: %s' % attr
207
208                 rargs, oargs = self._methods[attr]
209                 if self._prefix == 'tasksNotes':
210                         aname = 'rtm.tasks.notes.%s' % attr
211                 else:
212                         aname = 'rtm.%s.%s' % (self._prefix, attr)
213                 return lambda **params: self.callMethod(
214                         aname, rargs, oargs, **params
215                 )
216
217         def callMethod(self, aname, rargs, oargs, **params):
218                 # Sanity checks
219                 for requiredArg in rargs:
220                         if requiredArg not in params:
221                                 raise TypeError, 'Required parameter (%s) missing' % requiredArg
222
223                 for param in params:
224                         if param not in rargs + oargs:
225                                 warnings.warn('Invalid parameter (%s)' % param)
226
227                 return self._rtm().get(method=aname,
228                                                         auth_token=self._rtm()._authInfo.get('token'),
229                                                         **params)
230
231
232 def sortedItems(dictionary):
233         "Return a list of (key, value) sorted based on keys"
234         keys = dictionary.keys()
235         keys.sort()
236         for key in keys:
237                 yield key, dictionary[key]
238
239
240 class DottedDict(object):
241         "Make dictionary items accessible via the object-dot notation."
242
243         def __init__(self, name, dictionary):
244                 self._name = name
245
246                 if isinstance(dictionary, dict):
247                         for key, value in dictionary.items():
248                                 if isinstance(value, dict):
249                                         value = DottedDict(key, value)
250                                 elif isinstance(value, (list, tuple)):
251                                         value = [DottedDict('%s_%d' % (key, i), item)
252                                                          for i, item in enumerate(value)]
253                                 setattr(self, key, value)
254
255         def __repr__(self):
256                 children = [c for c in dir(self) if not c.startswith('_')]
257                 return '<dotted %s: %s>' % (
258                         self._name,
259                         ', '.join(children))
260
261         def __str__(self):
262                 children = [(c, getattr(self, c)) for c in dir(self) if not c.startswith('_')]
263                 return '{dotted %s: %s}' % (
264                         self._name,
265                         ', '.join(
266                                 ('%s: "%s"' % (k, str(v)))
267                                 for (k, v) in children)
268                 )
269
270
271 def safer_eval(string):
272         try:
273                 return eval(string, {}, {})
274         except SyntaxError, e:
275                 print "="*60
276                 print string
277                 print "="*60
278                 newE = RTMParseError("Error parseing json")
279                 newE.error = e
280                 raise newE
281
282
283 if _use_simplejson:
284         parse_json = simplejson.loads
285 else:
286         parse_json = safer_eval
287
288
289 API = {
290         'auth': {
291                 'checkToken':
292                         [('auth_token'), ()],
293                 'getFrob':
294                         [(), ()],
295                 'getToken':
296                         [('frob'), ()]
297         },
298         'contacts': {
299                 'add':
300                         [('timeline', 'contact'), ()],
301                 'delete':
302                         [('timeline', 'contact_id'), ()],
303                 'getList':
304                         [(), ()],
305         },
306         'groups': {
307                 'add':
308                         [('timeline', 'group'), ()],
309                 'addContact':
310                         [('timeline', 'group_id', 'contact_id'), ()],
311                 'delete':
312                         [('timeline', 'group_id'), ()],
313                 'getList':
314                         [(), ()],
315                 'removeContact':
316                         [('timeline', 'group_id', 'contact_id'), ()],
317         },
318         'lists': {
319                 'add':
320                         [('timeline', 'name'), ('filter'), ()],
321                 'archive':
322                         [('timeline', 'list_id'), ()],
323                 'delete':
324                         [('timeline', 'list_id'), ()],
325                 'getList':
326                         [(), ()],
327                 'setDefaultList':
328                         [('timeline'), ('list_id'), ()],
329                 'setName':
330                         [('timeline', 'list_id', 'name'), ()],
331                 'unarchive':
332                         [('timeline'), ('list_id'), ()],
333         },
334         'locations': {
335                 'getList':
336                         [(), ()],
337         },
338         'reflection': {
339                 'getMethodInfo':
340                         [('methodName',), ()],
341                 'getMethods':
342                         [(), ()],
343         },
344         'settings': {
345                 'getList':
346                         [(), ()],
347         },
348         'tasks': {
349                 'add':
350                         [('timeline', 'name',), ('list_id', 'parse',)],
351                 'addTags':
352                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
353                          ()],
354                 'complete':
355                         [('timeline', 'list_id', 'taskseries_id', 'task_id',), ()],
356                 'delete':
357                         [('timeline', 'list_id', 'taskseries_id', 'task_id'), ()],
358                 'getList':
359                         [(),
360                          ('list_id', 'filter', 'last_sync')],
361                 'movePriority':
362                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'direction'),
363                          ()],
364                 'moveTo':
365                         [('timeline', 'from_list_id', 'to_list_id', 'taskseries_id', 'task_id'),
366                          ()],
367                 'postpone':
368                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
369                          ()],
370                 'removeTags':
371                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
372                          ()],
373                 'setDueDate':
374                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
375                          ('due', 'has_due_time', 'parse')],
376                 'setEstimate':
377                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
378                          ('estimate',)],
379                 'setLocation':
380                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
381                          ('location_id',)],
382                 'setName':
383                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'name'),
384                          ()],
385                 'setPriority':
386                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
387                          ('priority',)],
388                 'setRecurrence':
389                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
390                          ('repeat',)],
391                 'setTags':
392                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
393                          ('tags',)],
394                 'setURL':
395                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
396                          ('url',)],
397                 'uncomplete':
398                         [('timeline', 'list_id', 'taskseries_id', 'task_id'),
399                          ()],
400         },
401         'tasksNotes': {
402                 'add':
403                         [('timeline', 'list_id', 'taskseries_id', 'task_id', 'note_title', 'note_text'), ()],
404                 'delete':
405                         [('timeline', 'note_id'), ()],
406                 'edit':
407                         [('timeline', 'note_id', 'note_title', 'note_text'), ()],
408         },
409         'test': {
410                 'echo':
411                         [(), ()],
412                 'login':
413                         [(), ()],
414         },
415         'time': {
416                 'convert':
417                         [('to_timezone',), ('from_timezone', 'to_timezone', 'time')],
418                 'parse':
419                         [('text',), ('timezone', 'dateformat')],
420         },
421         'timelines': {
422                 'create':
423                         [(), ()],
424         },
425         'timezones': {
426                 'getList':
427                         [(), ()],
428         },
429         'transactions': {
430                 'undo':
431                         [('timeline', 'transaction_id'), ()],
432         },
433 }