Remember the Milk Plugin review



Attached (1) is the original version of the plugin as a patch towards
Kupfer git.

Review comments:
I think we can avoid using a keys.cfg configuration file (hopefully),
I think it's going to work if we put the API key and secrent in the
plugin python file, and we store the token using the system that I'm
proposing (see attachment 2 for a sketch of that, based on how the
Trigger plugin saves its configuration information. Attachment 2 is a
patch that applies on top of attachment 1).  The current hardcoded
paths used would have to be adjusted to use a sensible place (config
directory) if that approach was used.

I don't use RTM so I have not tested the plugin.

Code comments:

1) __init__.py
* Imports below the information variables please, also use the "from
kupfer.plugin.rtm.rtm_api" absolute path import style.
* The file should be inidented with tabs, simply because this is
current style used in Kupfer.
* The Continue stuff can be simplified if you just return a
RunnableLeaf. At least the Continue action is overkill, having
(Continue object, Continue Action) interface is something we have
opted away in the rest of kupfer, we use (Continue Object, Run)
instead..
* Descriptions are full sentences so we want them with initial capital
letter. Also, simply "Remember The Milk" does not sound like an action
name to me but it's up to the users to decide what you think.
* Alex has expressed specific wishes about attribution info. You can
decide to put that into the description (localized) or the author
(non-localized) fields (or fill in your own idea).

2) rtm_api.py
* This is an external file, will we update it when its updated bty its
original author? Source information could be handy for that.
* As externally imported file, it needs license and author
information. Is this code compatible with GPLv3+ ?
* As externally imported file we won't care about its coding style,
indentation etc. But can we update it to use the same json as Kupfer
does (rtm_api.py doesn't seem to know about 'json' module from
Python2.6)

Thanks a lot, I think many users are looking forward to an RTM plugin.
Ulrik
From bb239699bb11bec4fc72821066c6f290ac31a17e Mon Sep 17 00:00:00 2001
From: Chris Barnett <barney chrisj gmail com>
Date: Sat, 6 Nov 2010 13:33:57 +0100
Subject: [PATCH 1/2] Remember the Milk plugin

---
 kupfer/plugin/rtm/__init__.py |  104 +++++++++++
 kupfer/plugin/rtm/keys.cfg    |    5 +
 kupfer/plugin/rtm/rtm_api.py  |  403 +++++++++++++++++++++++++++++++++++++++++
 3 files changed, 512 insertions(+), 0 deletions(-)
 create mode 100644 kupfer/plugin/rtm/__init__.py
 create mode 100644 kupfer/plugin/rtm/keys.cfg
 create mode 100644 kupfer/plugin/rtm/rtm_api.py

diff --git a/kupfer/plugin/rtm/__init__.py b/kupfer/plugin/rtm/__init__.py
new file mode 100644
index 0000000..ea1eb73
--- /dev/null
+++ b/kupfer/plugin/rtm/__init__.py
@@ -0,0 +1,104 @@
+from kupfer.plugin.rtm.rtm_api import RTM
+__kupfer_name__ = _("Remember The Milk")
+__kupfer_actions__ = ("AddTask",)
+__description__ = _("Add a task to Remember the Milk")
+__version__ = ""
+__author__ = "Chris Barnett <barney chrisj gmail com>"
+
+from rtm_api import createRTM, RTMAPIError
+from kupfer.objects import Action, Source, Leaf
+from kupfer.objects import TextLeaf, UrlLeaf
+from kupfer import utils, plugin_support
+
+import ConfigParser
+import os
+
+try:
+    import cjson
+    json_decoder = cjson.decode
+except ImportError:
+    import json
+    json_decoder = json.loads
+
+RTM_PLUGIN_DIR = '~/.local/share/kupfer/plugins/rtm'
+
+class AddTask (Action):
+    def __init__(self):
+        Action.__init__(self, _("Remember The Milk"))
+    def is_factory(self):
+        return True
+    def activate(self, leaf):
+        config = ConfigParser.RawConfigParser()
+        config_dir = RTM_PLUGIN_DIR.replace('~', os.getenv("HOME"))
+        
+        config.read('%s/keys.cfg' %(config_dir))
+        api_key = config.get('keys', 'api_key')
+        secret = config.get('keys', 'secret')
+        token = config.get('keys', 'token')
+        
+        rtm = createRTM(api_key, secret, token)
+        taskName = leaf.object
+        
+        try:
+            timeline = rtm.timelines.create().timeline
+            rspTasks = rtm.tasks.add(timeline=timeline,name=taskName,parse=1)
+        except RTMAPIError:
+            #show the REM Authentication page
+            authURL = rtm.getAuthURL()
+            utils.show_url(authURL)
+            #show a continue button
+            obj = {'taskName': taskName, 
+                   'rtm': rtm,
+                   'config': config,
+		   'config_dir':config_dir
+                   }
+            
+            return ContinueButtonSource(obj) 
+        
+    def item_types(self):
+        yield TextLeaf
+    def get_description(self):
+        return __description__
+  
+class ContinueButtonSource(Source):
+    def __init__(self,obj):
+        Source.__init__(self, 'Continue Button Source')
+        self.obj = obj
+
+    def get_items(self):
+        yield ContinueButton(self.obj,'Continue')
+
+    def provides(self):
+        yield Leaf
+        
+class ContinueButton (Leaf):
+    def __init__(self, obj, name):
+        Leaf.__init__(self, obj, name)
+        
+    def get_actions(self):
+        yield Continue()
+        
+    def get_description(self):
+        return _("authenticate using browser, then press enter")
+       
+class Continue (Action):
+    def __init__(self):
+        Action.__init__(self, _("Continue"))
+    def activate(self, leaf):
+        rtm = leaf.object['rtm']
+        config = leaf.object['config']
+        taskName = leaf.object['taskName']
+        config_dir = leaf.object['config_dir']
+
+        token = rtm.getToken()
+        #save Token for later
+        config.set('keys','token',token)
+
+        with open('%s/keys.cfg' %(config_dir),'wb') as configfile:
+            config.write(configfile)
+            
+        timeline = rtm.timelines.create().timeline
+        rspTasks = rtm.tasks.add(timeline=timeline,name=taskName)
+        
+    def get_description(self):
+        return _("continue creating your task")
diff --git a/kupfer/plugin/rtm/keys.cfg b/kupfer/plugin/rtm/keys.cfg
new file mode 100644
index 0000000..f40e64d
--- /dev/null
+++ b/kupfer/plugin/rtm/keys.cfg
@@ -0,0 +1,5 @@
+[keys]
+secret = e2ef82606ff703f4
+api_key = 3132e630b0aa10cfba4bb5736012c40e
+token = ac65f8bb020f67380cb04391641e7d49a88
+
diff --git a/kupfer/plugin/rtm/rtm_api.py b/kupfer/plugin/rtm/rtm_api.py
new file mode 100644
index 0000000..8a2d6aa
--- /dev/null
+++ b/kupfer/plugin/rtm/rtm_api.py
@@ -0,0 +1,403 @@
+# Python library for Remember The Milk API
+
+__author__ = 'Sridhar Ratnakumar <http://nearfar.org/>'
+__all__ = (
+    'API',
+    'createRTM',
+    'set_log_level',
+        )
+
+
+import warnings
+import urllib
+import logging
+from hashlib import md5
+
+warnings.simplefilter('default', ImportWarning)
+
+_use_simplejson = False
+try:
+    import simplejson
+    _use_simplejson = True
+except ImportError:
+    try:
+        from django.utils import simplejson
+        _use_simplejson = True
+    except ImportError:
+        pass
+    
+if not _use_simplejson:
+    warnings.warn("simplejson module is not available, "
+             "falling back to the internal JSON parser. "
+             "Please consider installing the simplejson module from "
+             "http://pypi.python.org/pypi/simplejson.";, ImportWarning,
+             stacklevel=2)
+
+logging.basicConfig()
+LOG = logging.getLogger(__name__)
+LOG.setLevel(logging.INFO)
+
+SERVICE_URL = 'http://api.rememberthemilk.com/services/rest/'
+AUTH_SERVICE_URL = 'http://www.rememberthemilk.com/services/auth/'
+
+
+class RTMError(Exception): pass
+
+class RTMAPIError(RTMError): pass
+
+class AuthStateMachine(object):
+
+    class NoData(RTMError): pass
+
+    def __init__(self, states):
+        self.states = states
+        self.data = {}
+
+    def dataReceived(self, state, datum):
+        if state not in self.states:
+            raise RTMError, "Invalid state <%s>" % state
+        self.data[state] = datum
+
+    def get(self, state):
+        if state in self.data:
+            return self.data[state]
+        else:
+            raise AuthStateMachine.NoData, 'No data for <%s>' % state
+
+
+class RTM(object):
+
+    def __init__(self, apiKey, secret, token=None):
+        self.apiKey = apiKey
+        self.secret = secret
+        self.authInfo = AuthStateMachine(['frob', 'token'])
+
+        # this enables one to do 'rtm.tasks.getList()', for example
+        for prefix, methods in API.items():
+            setattr(self, prefix,
+                    RTMAPICategory(self, prefix, methods))
+
+        if token:
+            self.authInfo.dataReceived('token', token)
+
+    def _sign(self, params):
+        "Sign the parameters with MD5 hash"
+        pairs = ''.join(['%s%s' % (k,v) for k,v in sortedItems(params)])
+        return md5(self.secret+pairs).hexdigest()
+
+    def get(self, **params):
+        "Get the XML response for the passed `params`."
+        params['api_key'] = self.apiKey
+        params['format'] = 'json'
+        params['api_sig'] = self._sign(params)
+
+        json = openURL(SERVICE_URL, params).read()
+
+        LOG.debug("JSON response: \n%s" % json)
+
+        if _use_simplejson:
+            data = dottedDict('ROOT', simplejson.loads(json))
+        else:
+            data = dottedJSON(json)
+        rsp = data.rsp
+
+        if rsp.stat == 'fail':
+            raise RTMAPIError, 'API call failed - %s (%s)' % (
+                rsp.err.msg, rsp.err.code)
+        else:
+            return rsp
+
+    def getNewFrob(self):
+        rsp = self.get(method='rtm.auth.getFrob')
+        self.authInfo.dataReceived('frob', rsp.frob)
+        return rsp.frob
+
+    def getAuthURL(self):
+        try:
+            frob = self.authInfo.get('frob')
+        except AuthStateMachine.NoData:
+            frob = self.getNewFrob()
+
+        params = {
+            'api_key': self.apiKey,
+            'perms'  : 'delete',
+            'frob'   : frob
+            }
+        params['api_sig'] = self._sign(params)
+        return AUTH_SERVICE_URL + '?' + urllib.urlencode(params)
+
+    def getToken(self):
+        frob = self.authInfo.get('frob')
+        rsp = self.get(method='rtm.auth.getToken', frob=frob)
+        self.authInfo.dataReceived('token', rsp.auth.token)
+        return rsp.auth.token
+
+class RTMAPICategory:
+    "See the `API` structure and `RTM.__init__`"
+
+    def __init__(self, rtm, prefix, methods):
+        self.rtm = rtm
+        self.prefix = prefix
+        self.methods = methods
+
+    def __getattr__(self, attr):
+        if attr in self.methods:
+            rargs, oargs = self.methods[attr]
+            if self.prefix == 'tasksNotes':
+                aname = 'rtm.tasks.notes.%s' % attr
+            else:
+                aname = 'rtm.%s.%s' % (self.prefix, attr)
+            return lambda **params: self.callMethod(
+                aname, rargs, oargs, **params)
+        else:
+            raise AttributeError, 'No such attribute: %s' % attr
+
+    def callMethod(self, aname, rargs, oargs, **params):
+        # Sanity checks
+        for requiredArg in rargs:
+            if requiredArg not in params:
+                raise TypeError, 'Required parameter (%s) missing' % requiredArg
+
+        for param in params:
+            if param not in rargs + oargs:
+                warnings.warn('Invalid parameter (%s)' % param)
+
+        return self.rtm.get(method=aname,
+                            auth_token=self.rtm.authInfo.get('token'),
+                            **params)
+
+
+
+# Utility functions
+
+def sortedItems(dictionary):
+    "Return a list of (key, value) sorted based on keys"
+    keys = dictionary.keys()
+    keys.sort()
+    for key in keys:
+        yield key, dictionary[key]
+
+def openURL(url, queryArgs=None):
+    if queryArgs:
+        url = url + '?' + urllib.urlencode(queryArgs)
+    LOG.debug("URL> %s", url)
+    return urllib.urlopen(url)
+
+class dottedDict(object):
+    """Make dictionary items accessible via the object-dot notation."""
+
+    def __init__(self, name, dictionary):
+        self._name = name
+
+        if type(dictionary) is dict:
+            for key, value in dictionary.items():
+                if type(value) is dict:
+                    value = dottedDict(key, value)
+                elif type(value) in (list, tuple) and key != 'tag':
+                    value = [dottedDict('%s_%d' % (key, i), item)
+                             for i, item in indexed(value)]
+                setattr(self, key, value)
+        else:
+            raise ValueError, 'not a dict: %s' % dictionary
+
+    def __repr__(self):
+        children = [c for c in dir(self) if not c.startswith('_')]
+        return 'dotted <%s> : %s' % (
+            self._name,
+            ', '.join(children))
+
+
+def safeEval(string):
+    return eval(string, {}, {})
+
+def dottedJSON(json):
+    return dottedDict('ROOT', safeEval(json))
+
+def indexed(seq):
+    index = 0
+    for item in seq:
+        yield index, item
+        index += 1
+
+
+# API spec
+
+API = {
+   'auth': {
+       'checkToken':
+           [('auth_token',), ()],
+       'getFrob':
+           [(), ()],
+       'getToken':
+           [('frob',), ()]
+       },
+    'contacts': {
+        'add':
+            [('timeline', 'contact'), ()],
+        'delete':
+            [('timeline', 'contact_id'), ()],
+        'getList':
+            [(), ()]
+        },
+    'groups': {
+        'add':
+            [('timeline', 'group'), ()],
+        'addContact':
+            [('timeline', 'group_id', 'contact_id'), ()],
+        'delete':
+            [('timeline', 'group_id'), ()],
+        'getList':
+            [(), ()],
+        'removeContact':
+            [('timeline', 'group_id', 'contact_id'), ()],
+        },
+    'lists': {
+        'add':
+            [('timeline', 'name',), ('filter',)],
+        'archive':
+            [('timeline', 'list_id'), ()],
+        'delete':
+            [('timeline', 'list_id'), ()],
+        'getList':
+            [(), ()],
+        'setDefaultList':
+            [('timeline'), ('list_id')],
+        'setName':
+            [('timeline', 'list_id', 'name'), ()],
+        'unarchive':
+            [('timeline',), ('list_id',)]
+        },
+    'locations': {
+        'getList':
+            [(), ()]
+        },
+    'reflection': {
+        'getMethodInfo':
+            [('methodName',), ()],
+        'getMethods':
+            [(), ()]
+        },
+    'settings': {
+        'getList':
+            [(), ()]
+        },
+    'tasks': {
+        'add':
+            [('timeline', 'name',), ('list_id', 'parse',)],
+        'addTags':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
+             ()],
+        'complete':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id',), ()],
+        'delete':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'), ()],
+        'getList':
+            [(),
+             ('list_id', 'filter', 'last_sync')],
+        'movePriority':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id', 'direction'),
+             ()],
+        'moveTo':
+            [('timeline', 'from_list_id', 'to_list_id', 'taskseries_id', 'task_id'),
+             ()],
+        'postpone':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ()],
+        'removeTags':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id', 'tags'),
+             ()],
+        'setDueDate':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ('due', 'has_due_time', 'parse')],
+        'setEstimate':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ('estimate',)],
+        'setLocation':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ('location_id',)],
+        'setName':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id', 'name'),
+             ()],
+        'setPriority':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ('priority',)],
+        'setRecurrence':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ('repeat',)],
+        'setTags':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ('tags',)],
+        'setURL':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ('url',)],
+        'uncomplete':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id'),
+             ()],
+        },
+    'tasksNotes': {
+        'add':
+            [('timeline', 'list_id', 'taskseries_id', 'task_id', 'note_title', 'note_text'), ()],
+        'delete':
+            [('timeline', 'note_id'), ()],
+        'edit':
+            [('timeline', 'note_id', 'note_title', 'note_text'), ()]
+        },
+    'test': {
+        'echo':
+            [(), ()],
+        'login':
+            [(), ()]
+        },
+    'time': {
+        'convert':
+            [('to_timezone',), ('from_timezone', 'to_timezone', 'time')],
+        'parse':
+            [('text',), ('timezone', 'dateformat')]
+        },
+    'timelines': {
+        'create':
+            [(), ()]
+        },
+    'timezones': {
+        'getList':
+            [(), ()]
+        },
+    'transactions': {
+        'undo':
+            [('timeline', 'transaction_id'), ()]
+        },
+    }
+
+def createRTM(apiKey, secret, token=None):
+    rtm = RTM(apiKey, secret, token)
+    
+    """
+    if token is None:
+        print 'No token found'
+        print 'Give me access here:', rtm.getAuthURL()
+        raw_input('Press enter once you gave access')
+        print 'Note down this token for future use:', rtm.getToken()
+    """
+    
+    return rtm
+
+def test(apiKey, secret, token=None):
+    rtm = createRTM(apiKey, secret, token)
+
+    rspTasks = rtm.tasks.getList(filter='dueWithin:"1 week of today"')
+    print [t.name for t in rspTasks.tasks.list.taskseries]
+    print rspTasks.tasks.list.id
+
+    rspLists = rtm.lists.getList()
+    # print rspLists.lists.list
+    print [(x.name, x.id) for x in rspLists.lists.list]
+
+def set_log_level(level):
+    '''Sets the log level of the logger used by the module.
+    
+    >>> import rtm
+    >>> import logging
+    >>> rtm.set_log_level(logging.INFO)
+    '''
+    
+    LOG.setLevel(level)
-- 
1.7.2.3

From 101d0ac3a28a29504596afafb374e1edacc87c17 Mon Sep 17 00:00:00 2001
From: Ulrik Sverdrup <ulrik sverdrup gmail com>
Date: Sat, 6 Nov 2010 13:35:53 +0100
Subject: [PATCH 2/2] rtm: (Untested) Sketch of how configuration keys can be saved

---
 kupfer/plugin/rtm/__init__.py |   70 +++++++++++++++++++++++++++-------------
 1 files changed, 47 insertions(+), 23 deletions(-)

diff --git a/kupfer/plugin/rtm/__init__.py b/kupfer/plugin/rtm/__init__.py
index ea1eb73..1dc4426 100644
--- a/kupfer/plugin/rtm/__init__.py
+++ b/kupfer/plugin/rtm/__init__.py
@@ -1,18 +1,16 @@
 from kupfer.plugin.rtm.rtm_api import RTM
 __kupfer_name__ = _("Remember The Milk")
 __kupfer_actions__ = ("AddTask",)
+__kupfer_sources__ = ("ConfigurationStore", )
 __description__ = _("Add a task to Remember the Milk")
 __version__ = ""
 __author__ = "Chris Barnett <barney chrisj gmail com>"
 
 from rtm_api import createRTM, RTMAPIError
 from kupfer.objects import Action, Source, Leaf
-from kupfer.objects import TextLeaf, UrlLeaf
+from kupfer.objects import TextLeaf, UrlLeaf, SourceLeaf
 from kupfer import utils, plugin_support
 
-import ConfigParser
-import os
-
 try:
     import cjson
     json_decoder = cjson.decode
@@ -20,7 +18,49 @@ except ImportError:
     import json
     json_decoder = json.loads
 
-RTM_PLUGIN_DIR = '~/.local/share/kupfer/plugins/rtm'
+API_KEY = ""
+SECRET = ""
+TOKEN = None
+
+class InvisibleSourceLeaf (SourceLeaf):
+	def is_valid(self):
+		return False
+
+class ConfigurationStore (Source):
+	"""Hidden source simply to store (non-user) configuration"""
+	instance = None
+
+	def __init__(self):
+		Source.__init__(self, __kupfer_name__)
+		self.token = None
+
+	def config_save(self):
+		if self.token:
+			return {"token": self.token}
+
+	def config_save_name(self):
+		return __name__
+
+	def config_restore(self, state):
+		self.token = state["token"]
+		return True
+
+	def initialize(self):
+		ConfigurationStore.instance = self
+
+	def finalize(self):
+		pass
+
+	def get_leaf_repr(self):
+		return InvisibleSourceLeaf(self)
+
+	@classmethod
+	def set_token(cls, token):
+		cls.instance.token = token
+
+	@classmethod
+	def get_token(cls):
+		return cls.instance.token
 
 class AddTask (Action):
     def __init__(self):
@@ -28,15 +68,7 @@ class AddTask (Action):
     def is_factory(self):
         return True
     def activate(self, leaf):
-        config = ConfigParser.RawConfigParser()
-        config_dir = RTM_PLUGIN_DIR.replace('~', os.getenv("HOME"))
-        
-        config.read('%s/keys.cfg' %(config_dir))
-        api_key = config.get('keys', 'api_key')
-        secret = config.get('keys', 'secret')
-        token = config.get('keys', 'token')
-        
-        rtm = createRTM(api_key, secret, token)
+        rtm = createRTM(API_KEY, SECRET, ConfigurationStore.get_token())
         taskName = leaf.object
         
         try:
@@ -49,8 +81,6 @@ class AddTask (Action):
             #show a continue button
             obj = {'taskName': taskName, 
                    'rtm': rtm,
-                   'config': config,
-		   'config_dir':config_dir
                    }
             
             return ContinueButtonSource(obj) 
@@ -86,16 +116,10 @@ class Continue (Action):
         Action.__init__(self, _("Continue"))
     def activate(self, leaf):
         rtm = leaf.object['rtm']
-        config = leaf.object['config']
         taskName = leaf.object['taskName']
-        config_dir = leaf.object['config_dir']
 
         token = rtm.getToken()
-        #save Token for later
-        config.set('keys','token',token)
-
-        with open('%s/keys.cfg' %(config_dir),'wb') as configfile:
-            config.write(configfile)
+        ConfigurationStore.set_token(token)
             
         timeline = rtm.timelines.create().timeline
         rspTasks = rtm.tasks.add(timeline=timeline,name=taskName)
-- 
1.7.2.3



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]