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