Fixed a corner case where parsing the response with dottedJSON (i.e., when not using...
[pyrtm.git] / rtm.py
blob34055564ced4cac20b194c95ac7b3d5f308944b4
1 # Python library for Remember The Milk API
3 __author__ = 'Sridhar Ratnakumar <http://nearfar.org/>'
4 __all__ = (
5 'API',
6 'createRTM',
7 'set_log_level',
11 import new
12 import warnings
13 import urllib
14 import logging
15 from md5 import md5
16 _use_simplejson = False
17 try:
18 import simplejson
19 _use_simplejson = True
20 except ImportError:
21 pass
23 logging.basicConfig()
24 LOG = logging.getLogger(__name__)
25 LOG.setLevel(logging.INFO)
27 SERVICE_URL = 'http://api.rememberthemilk.com/services/rest/'
28 AUTH_SERVICE_URL = 'http://www.rememberthemilk.com/services/auth/'
31 class RTMError(Exception): pass
33 class RTMAPIError(RTMError): pass
35 class AuthStateMachine(object):
37 class NoData(RTMError): pass
39 def __init__(self, states):
40 self.states = states
41 self.data = {}
43 def dataReceived(self, state, datum):
44 if state not in self.states:
45 raise RTMError, "Invalid state <%s>" % state
46 self.data[state] = datum
48 def get(self, state):
49 if state in self.data:
50 return self.data[state]
51 else:
52 raise AuthStateMachine.NoData, 'No data for <%s>' % state
55 class RTM(object):
57 def __init__(self, apiKey, secret, token=None):
58 self.apiKey = apiKey
59 self.secret = secret
60 self.authInfo = AuthStateMachine(['frob', 'token'])
62 # this enables one to do 'rtm.tasks.getList()', for example
63 for prefix, methods in API.items():
64 setattr(self, prefix,
65 RTMAPICategory(self, prefix, methods))
67 if token:
68 self.authInfo.dataReceived('token', token)
70 def _sign(self, params):
71 "Sign the parameters with MD5 hash"
72 pairs = ''.join(['%s%s' % (k,v) for k,v in sortedItems(params)])
73 return md5(self.secret+pairs).hexdigest()
75 def get(self, **params):
76 "Get the XML response for the passed `params`."
77 params['api_key'] = self.apiKey
78 params['format'] = 'json'
79 params['api_sig'] = self._sign(params)
81 json = openURL(SERVICE_URL, params).read()
83 LOG.debug("JSON response: \n%s" % json)
85 if _use_simplejson:
86 data = dottedDict('ROOT', simplejson.loads(json))
87 else:
88 data = dottedJSON(json)
89 rsp = data.rsp
91 if rsp.stat == 'fail':
92 raise RTMAPIError, 'API call failed - %s (%s)' % (
93 rsp.err.msg, rsp.err.code)
94 else:
95 return rsp
97 def getNewFrob(self):
98 rsp = self.get(method='rtm.auth.getFrob')
99 self.authInfo.dataReceived('frob', rsp.frob)
100 return rsp.frob
102 def getAuthURL(self):
103 try:
104 frob = self.authInfo.get('frob')
105 except AuthStateMachine.NoData:
106 frob = self.getNewFrob()
108 params = {
109 'api_key': self.apiKey,
110 'perms' : 'delete',
111 'frob' : frob
113 params['api_sig'] = self._sign(params)
114 return AUTH_SERVICE_URL + '?' + urllib.urlencode(params)
116 def getToken(self):
117 frob = self.authInfo.get('frob')
118 rsp = self.get(method='rtm.auth.getToken', frob=frob)
119 self.authInfo.dataReceived('token', rsp.auth.token)
120 return rsp.auth.token
122 class RTMAPICategory:
123 "See the `API` structure and `RTM.__init__`"
125 def __init__(self, rtm, prefix, methods):
126 self.rtm = rtm
127 self.prefix = prefix
128 self.methods = methods
130 def __getattr__(self, attr):
131 if attr in self.methods:
132 rargs, oargs = self.methods[attr]
133 if self.prefix == 'tasksNotes':
134 aname = 'rtm.tasks.notes.%s' % attr
135 else:
136 aname = 'rtm.%s.%s' % (self.prefix, attr)
137 return lambda **params: self.callMethod(
138 aname, rargs, oargs, **params)
139 else:
140 raise AttributeError, 'No such attribute: %s' % attr
142 def callMethod(self, aname, rargs, oargs, **params):
143 # Sanity checks
144 for requiredArg in rargs:
145 if requiredArg not in params:
146 raise TypeError, 'Required parameter (%s) missing' % requiredArg
148 for param in params:
149 if param not in rargs + oargs:
150 warnings.warn('Invalid parameter (%s)' % param)
152 return self.rtm.get(method=aname,
153 auth_token=self.rtm.authInfo.get('token'),
154 **params)
158 # Utility functions
160 def sortedItems(dictionary):
161 "Return a list of (key, value) sorted based on keys"
162 keys = dictionary.keys()
163 keys.sort()
164 for key in keys:
165 yield key, dictionary[key]
167 def openURL(url, queryArgs=None):
168 if queryArgs:
169 url = url + '?' + urllib.urlencode(queryArgs)
170 LOG.debug("URL> %s", url)
171 return urllib.urlopen(url)
173 class dottedDict(object):
174 "Make dictionary items accessible via the object-dot notation."
176 def __init__(self, name, dictionary):
177 self._name = name
179 if type(dictionary) is dict:
180 for key, value in dictionary.items():
181 if type(value) is dict:
182 value = dottedDict(key, value)
183 elif type(value) in (list, tuple) and key != 'tag':
184 value = [dottedDict('%s_%d' % (key, i), item)
185 for i, item in indexed(value)]
186 setattr(self, key, value)
188 def __repr__(self):
189 children = [c for c in dir(self) if not c.startswith('_')]
190 return 'dotted <%s> : %s' % (
191 self._name,
192 ', '.join(children))
195 def safeEval(string):
196 return eval(string, {}, {})
198 def dottedJSON(json):
199 return dottedDict('ROOT', safeEval(json))
201 def indexed(seq):
202 index = 0
203 for item in seq:
204 yield index, item
205 index += 1
208 # API spec
210 API = {
211 'auth': {
212 'checkToken':
213 [('auth_token'), ()],
214 'getFrob':
215 [(), ()],
216 'getToken':
217 [('frob'), ()]
219 'contacts': {
220 'add':
221 [('timeline', 'contact'), ()],
222 'delete':
223 [('timeline', 'contact_id'), ()],
224 'getList':
225 [(), ()]
227 'groups': {
228 'add':
229 [('timeline', 'group'), ()],
230 'addContact':
231 [('timeline', 'group_id', 'contact_id'), ()],
232 'delete':
233 [('timeline', 'group_id'), ()],
234 'getList':
235 [(), ()],
236 'removeContact':
237 [('timeline', 'group_id', 'contact_id'), ()],
239 'lists': {
240 'add':
241 [('timeline', 'name'), ('filter'), ()],
242 'archive':
243 [('timeline', 'list_id'), ()],
244 'delete':
245 [('timeline', 'list_id'), ()],
246 'getList':
247 [(), ()],
248 'setDefaultList':
249 [('timeline'), ('list_id'), ()],
250 'setName':
251 [('timeline', 'list_id', 'name'), ()],
252 'unarchive':
253 [('timeline'), ('list_id'), ()],
255 'locations': {
256 'getList':
257 [(), ()]
259 'reflection': {
260 'getMethodInfo':
261 [('methodName',), ()],
262 'getMethods':
263 [(), ()]
265 'settings': {
266 'getList':
267 [(), ()]
269 'tasks': {
270 'add':
271 [('timeline', 'name',), ('list_id', 'parse',)],
272 'addTags':
273 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
274 ()],
275 'complete':
276 [('timeline', 'list_id', 'taskseries_id', 'task_id',), ()],
277 'delete':
278 [('timeline', 'list_id', 'taskseries_id', 'task_id'), ()],
279 'getList':
280 [(),
281 ('list_id', 'filter', 'last_sync')],
282 'movePriority':
283 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'direction'),
284 ()],
285 'moveTo':
286 [('timeline', 'from_list_id', 'to_list_id', 'taskseries_id', 'task_id'),
287 ()],
288 'postpone':
289 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
290 ()],
291 'removeTags':
292 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
293 ()],
294 'setDueDate':
295 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
296 ('due', 'has_due_time', 'parse')],
297 'setEstimate':
298 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
299 ('estimate',)],
300 'setLocation':
301 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
302 ('location_id',)],
303 'setName':
304 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'name'),
305 ()],
306 'setPriority':
307 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
308 ('priority',)],
309 'setRecurrence':
310 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
311 ('repeat',)],
312 'setTags':
313 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
314 ('tags',)],
315 'setURL':
316 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
317 ('url',)],
318 'uncomplete':
319 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
320 ()],
322 'tasksNotes': {
323 'add':
324 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'note_title', 'note_text'), ()],
325 'delete':
326 [('timeline', 'note_id'), ()],
327 'edit':
328 [('timeline', 'note_id', 'note_title', 'note_text'), ()]
330 'test': {
331 'echo':
332 [(), ()],
333 'login':
334 [(), ()]
336 'time': {
337 'convert':
338 [('to_timezone',), ('from_timezone', 'to_timezone', 'time')],
339 'parse':
340 [('text',), ('timezone', 'dateformat')]
342 'timelines': {
343 'create':
344 [(), ()]
346 'timezones': {
347 'getList':
348 [(), ()]
350 'transactions': {
351 'undo':
352 [('timeline', 'transaction_id'), ()]
356 def createRTM(apiKey, secret, token=None):
357 rtm = RTM(apiKey, secret, token)
359 if token is None:
360 print 'No token found'
361 print 'Give me access here:', rtm.getAuthURL()
362 raw_input('Press enter once you gave access')
363 print 'Note down this token for future use:', rtm.getToken()
365 return rtm
367 def test(apiKey, secret, token=None):
368 rtm = createRTM(apiKey, secret, token)
370 rspTasks = rtm.tasks.getList(filter='dueWithin:"1 week of today"')
371 print [t.name for t in rspTasks.tasks.list.taskseries]
372 print rspTasks.tasks.list.id
374 rspLists = rtm.lists.getList()
375 # print rspLists.lists.list
376 print [(x.name, x.id) for x in rspLists.lists.list]
378 def set_log_level(level):
379 '''Sets the log level of the logger used by the module.
381 >>> import rtm
382 >>> import logging
383 >>> rtm.set_log_level(logging.INFO)
386 LOG.setLevel(level)