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