pyrtm is now under the WTFPL license
[pyrtm.git] / rtm.py
blob873e53bf6c04308d3235cfff9746658ff0aa0737
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
17 warnings.simplefilter('default', ImportWarning)
19 _use_simplejson = False
20 try:
21 import simplejson
22 _use_simplejson = True
23 except ImportError:
24 pass
26 if not _use_simplejson:
27 warnings.warn("simplejson module is not available, "
28 "falling back to the internal JSON parser. "
29 "Please consider installing the simplejson module from "
30 "http://pypi.python.org/pypi/simplejson.", ImportWarning,
31 stacklevel=2)
33 logging.basicConfig()
34 LOG = logging.getLogger(__name__)
35 LOG.setLevel(logging.INFO)
37 SERVICE_URL = 'http://api.rememberthemilk.com/services/rest/'
38 AUTH_SERVICE_URL = 'http://www.rememberthemilk.com/services/auth/'
41 class RTMError(Exception): pass
43 class RTMAPIError(RTMError): pass
45 class AuthStateMachine(object):
47 class NoData(RTMError): pass
49 def __init__(self, states):
50 self.states = states
51 self.data = {}
53 def dataReceived(self, state, datum):
54 if state not in self.states:
55 raise RTMError, "Invalid state <%s>" % state
56 self.data[state] = datum
58 def get(self, state):
59 if state in self.data:
60 return self.data[state]
61 else:
62 raise AuthStateMachine.NoData, 'No data for <%s>' % state
65 class RTM(object):
67 def __init__(self, apiKey, secret, token=None):
68 self.apiKey = apiKey
69 self.secret = secret
70 self.authInfo = AuthStateMachine(['frob', 'token'])
72 # this enables one to do 'rtm.tasks.getList()', for example
73 for prefix, methods in API.items():
74 setattr(self, prefix,
75 RTMAPICategory(self, prefix, methods))
77 if token:
78 self.authInfo.dataReceived('token', token)
80 def _sign(self, params):
81 "Sign the parameters with MD5 hash"
82 pairs = ''.join(['%s%s' % (k,v) for k,v in sortedItems(params)])
83 return md5(self.secret+pairs).hexdigest()
85 def get(self, **params):
86 "Get the XML response for the passed `params`."
87 params['api_key'] = self.apiKey
88 params['format'] = 'json'
89 params['api_sig'] = self._sign(params)
91 json = openURL(SERVICE_URL, params).read()
93 LOG.debug("JSON response: \n%s" % json)
95 if _use_simplejson:
96 data = dottedDict('ROOT', simplejson.loads(json))
97 else:
98 data = dottedJSON(json)
99 rsp = data.rsp
101 if rsp.stat == 'fail':
102 raise RTMAPIError, 'API call failed - %s (%s)' % (
103 rsp.err.msg, rsp.err.code)
104 else:
105 return rsp
107 def getNewFrob(self):
108 rsp = self.get(method='rtm.auth.getFrob')
109 self.authInfo.dataReceived('frob', rsp.frob)
110 return rsp.frob
112 def getAuthURL(self):
113 try:
114 frob = self.authInfo.get('frob')
115 except AuthStateMachine.NoData:
116 frob = self.getNewFrob()
118 params = {
119 'api_key': self.apiKey,
120 'perms' : 'delete',
121 'frob' : frob
123 params['api_sig'] = self._sign(params)
124 return AUTH_SERVICE_URL + '?' + urllib.urlencode(params)
126 def getToken(self):
127 frob = self.authInfo.get('frob')
128 rsp = self.get(method='rtm.auth.getToken', frob=frob)
129 self.authInfo.dataReceived('token', rsp.auth.token)
130 return rsp.auth.token
132 class RTMAPICategory:
133 "See the `API` structure and `RTM.__init__`"
135 def __init__(self, rtm, prefix, methods):
136 self.rtm = rtm
137 self.prefix = prefix
138 self.methods = methods
140 def __getattr__(self, attr):
141 if attr in self.methods:
142 rargs, oargs = self.methods[attr]
143 if self.prefix == 'tasksNotes':
144 aname = 'rtm.tasks.notes.%s' % attr
145 else:
146 aname = 'rtm.%s.%s' % (self.prefix, attr)
147 return lambda **params: self.callMethod(
148 aname, rargs, oargs, **params)
149 else:
150 raise AttributeError, 'No such attribute: %s' % attr
152 def callMethod(self, aname, rargs, oargs, **params):
153 # Sanity checks
154 for requiredArg in rargs:
155 if requiredArg not in params:
156 raise TypeError, 'Required parameter (%s) missing' % requiredArg
158 for param in params:
159 if param not in rargs + oargs:
160 warnings.warn('Invalid parameter (%s)' % param)
162 return self.rtm.get(method=aname,
163 auth_token=self.rtm.authInfo.get('token'),
164 **params)
168 # Utility functions
170 def sortedItems(dictionary):
171 "Return a list of (key, value) sorted based on keys"
172 keys = dictionary.keys()
173 keys.sort()
174 for key in keys:
175 yield key, dictionary[key]
177 def openURL(url, queryArgs=None):
178 if queryArgs:
179 url = url + '?' + urllib.urlencode(queryArgs)
180 LOG.debug("URL> %s", url)
181 return urllib.urlopen(url)
183 class dottedDict(object):
184 "Make dictionary items accessible via the object-dot notation."
186 def __init__(self, name, dictionary):
187 self._name = name
189 if type(dictionary) is dict:
190 for key, value in dictionary.items():
191 if type(value) is dict:
192 value = dottedDict(key, value)
193 elif type(value) in (list, tuple) and key != 'tag':
194 value = [dottedDict('%s_%d' % (key, i), item)
195 for i, item in indexed(value)]
196 setattr(self, key, value)
198 def __repr__(self):
199 children = [c for c in dir(self) if not c.startswith('_')]
200 return 'dotted <%s> : %s' % (
201 self._name,
202 ', '.join(children))
205 def safeEval(string):
206 return eval(string, {}, {})
208 def dottedJSON(json):
209 return dottedDict('ROOT', safeEval(json))
211 def indexed(seq):
212 index = 0
213 for item in seq:
214 yield index, item
215 index += 1
218 # API spec
220 API = {
221 'auth': {
222 'checkToken':
223 [('auth_token'), ()],
224 'getFrob':
225 [(), ()],
226 'getToken':
227 [('frob'), ()]
229 'contacts': {
230 'add':
231 [('timeline', 'contact'), ()],
232 'delete':
233 [('timeline', 'contact_id'), ()],
234 'getList':
235 [(), ()]
237 'groups': {
238 'add':
239 [('timeline', 'group'), ()],
240 'addContact':
241 [('timeline', 'group_id', 'contact_id'), ()],
242 'delete':
243 [('timeline', 'group_id'), ()],
244 'getList':
245 [(), ()],
246 'removeContact':
247 [('timeline', 'group_id', 'contact_id'), ()],
249 'lists': {
250 'add':
251 [('timeline', 'name'), ('filter'), ()],
252 'archive':
253 [('timeline', 'list_id'), ()],
254 'delete':
255 [('timeline', 'list_id'), ()],
256 'getList':
257 [(), ()],
258 'setDefaultList':
259 [('timeline'), ('list_id'), ()],
260 'setName':
261 [('timeline', 'list_id', 'name'), ()],
262 'unarchive':
263 [('timeline'), ('list_id'), ()],
265 'locations': {
266 'getList':
267 [(), ()]
269 'reflection': {
270 'getMethodInfo':
271 [('methodName',), ()],
272 'getMethods':
273 [(), ()]
275 'settings': {
276 'getList':
277 [(), ()]
279 'tasks': {
280 'add':
281 [('timeline', 'name',), ('list_id', 'parse',)],
282 'addTags':
283 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
284 ()],
285 'complete':
286 [('timeline', 'list_id', 'taskseries_id', 'task_id',), ()],
287 'delete':
288 [('timeline', 'list_id', 'taskseries_id', 'task_id'), ()],
289 'getList':
290 [(),
291 ('list_id', 'filter', 'last_sync')],
292 'movePriority':
293 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'direction'),
294 ()],
295 'moveTo':
296 [('timeline', 'from_list_id', 'to_list_id', 'taskseries_id', 'task_id'),
297 ()],
298 'postpone':
299 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
300 ()],
301 'removeTags':
302 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
303 ()],
304 'setDueDate':
305 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
306 ('due', 'has_due_time', 'parse')],
307 'setEstimate':
308 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
309 ('estimate',)],
310 'setLocation':
311 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
312 ('location_id',)],
313 'setName':
314 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'name'),
315 ()],
316 'setPriority':
317 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
318 ('priority',)],
319 'setRecurrence':
320 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
321 ('repeat',)],
322 'setTags':
323 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
324 ('tags',)],
325 'setURL':
326 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
327 ('url',)],
328 'uncomplete':
329 [('timeline', 'list_id', 'taskseries_id', 'task_id'),
330 ()],
332 'tasksNotes': {
333 'add':
334 [('timeline', 'list_id', 'taskseries_id', 'task_id', 'note_title', 'note_text'), ()],
335 'delete':
336 [('timeline', 'note_id'), ()],
337 'edit':
338 [('timeline', 'note_id', 'note_title', 'note_text'), ()]
340 'test': {
341 'echo':
342 [(), ()],
343 'login':
344 [(), ()]
346 'time': {
347 'convert':
348 [('to_timezone',), ('from_timezone', 'to_timezone', 'time')],
349 'parse':
350 [('text',), ('timezone', 'dateformat')]
352 'timelines': {
353 'create':
354 [(), ()]
356 'timezones': {
357 'getList':
358 [(), ()]
360 'transactions': {
361 'undo':
362 [('timeline', 'transaction_id'), ()]
366 def createRTM(apiKey, secret, token=None):
367 rtm = RTM(apiKey, secret, token)
369 if token is None:
370 print 'No token found'
371 print 'Give me access here:', rtm.getAuthURL()
372 raw_input('Press enter once you gave access')
373 print 'Note down this token for future use:', rtm.getToken()
375 return rtm
377 def test(apiKey, secret, token=None):
378 rtm = createRTM(apiKey, secret, token)
380 rspTasks = rtm.tasks.getList(filter='dueWithin:"1 week of today"')
381 print [t.name for t in rspTasks.tasks.list.taskseries]
382 print rspTasks.tasks.list.id
384 rspLists = rtm.lists.getList()
385 # print rspLists.lists.list
386 print [(x.name, x.id) for x in rspLists.lists.list]
388 def set_log_level(level):
389 '''Sets the log level of the logger used by the module.
391 >>> import rtm
392 >>> import logging
393 >>> rtm.set_log_level(logging.INFO)
396 LOG.setLevel(level)