Added color highlighting + commands (update, view, remove)
authorDmitriy Morozov <morozov@cs.duke.edu>
Sat, 17 May 2008 04:25:28 -0400
changeset 2 b8013798cbfc
parent 1 b139e134d94a
child 3 1626ee683a86
Added color highlighting + commands (update, view, remove)
alexandria.py
terminal.py
--- a/alexandria.py	Wed Apr 30 10:41:18 2008 -0400
+++ b/alexandria.py	Sat May 17 04:25:28 2008 -0400
@@ -2,10 +2,20 @@
 
 import os, sys, os.path
 import hashlib
+import terminal
 from optparse import OptionParser
 from models import Author, Paper, Tag, AuthorNickname, initDatabase, session
 
 db_filename = 'alexandria.db'
+term = terminal.TerminalController()
+color = {'title':   term.GREEN + term.BOLD, 
+         'author':  term.YELLOW + term.BOLD,
+         'label':   term.CYAN + term.BOLD,
+         'path':    term.NORMAL,
+         'hash':    term.RED + term.BOLD,
+         'error':   term.RED + term.BOLD,
+         'normal':  term.NORMAL}
+default_viewer = '/usr/bin/evince'
 
 def find_database(starting_path = None):
     if not starting_path: starting_path = '.'
@@ -24,41 +34,77 @@
 def add(args, options):
     path = args[0]
     if not os.path.exists(path):
-        print "Path %s does not exist. Cannot add paper"
+        print _colorize_string('error', "Path %s does not exist. Cannot add paper" % path)
         return
 
-    path = os.path.abspath(path)
     m = hashlib.md5()
     fd = open(path, 'r')
     m.update(fd.read())
     fd.close()
-    p = Paper(title = unicode(options.title), path = path, md5 = m.hexdigest())
-
-    for label in options.labels:
-        t = Tag.get_by_or_init(name = unicode(label))
-        t.papers.append(p)
 
-    for author_with_commas in options.authors:
-        authors = author_with_commas.split(',')
-        for author in authors:
-            author = author.strip()
-            an = AuthorNickname.get_by(name = unicode(author))
-            if an: a = an.author
-            else:  a = Author.get_by_or_init(name = unicode(author))
-            a.papers.append(p)
+    path = os.path.abspath(path)
+    p = Paper.get_by(path = path) or Paper.get_by(md5 = m.hexdigest())
+    if p is not None:
+        print _colorize_string('error', "Paper already exists, use update")
+        print '--------------------------------'
+        _show_paper(p)
+        return
+
+    p = Paper(path = path, md5 = m.hexdigest())
+    _set_options(p, options, required = ['title'])
 
     session.flush()
     _show_paper(p)
 
+def update(args, options):
+    p = Paper.query.filter(Paper.md5.startswith(args[0])).one()
+    _set_options(p, options)
+    session.flush()
+    _show_paper(p)
+
+def view(args, options):
+    if len(args) < 1: return
+    p = Paper.query.filter(Paper.md5.startswith(args[0])).one()
+    if not p: return
+    if len(args) > 1:   viewer = args[1]
+    else:               viewer = default_viewer
+    os.system('%s %s' % (viewer, p.path))
+
+def remove(args, options):
+    if len(args) < 1: return
+    p = Paper.query.filter(Paper.md5.startswith(args[0])).one()
+    if not p: return
+    print "Removing"
+    _show_paper(p)
+    p.delete()
+    session.flush()
+
 def list(args, options):
-    papers = Paper.query.all()
-    for p in papers:
+    papers = Paper.query
+
+    # Refactor with _set_options()
+    for label_with_commas in (options.labels or []):
+        labels = label_with_commas.split(',')
+        for label in labels:
+            label = unicode(label.strip())
+            papers = papers.filter(Paper.tags.any(name = label))
+
+    for author_with_commas in (options.authors or []):
+        authors = author_with_commas.split(',')
+        for author in authors:
+            author = unicode(author.strip())
+            an = AuthorNickname.get_by(name = author)
+            if an: a = an.author
+            else:  a = Author.get_by_or_init(name = author)
+            papers = papers.filter(Paper.authors.any(name = unicode(a))) 
+
+    for p in papers.all():
         _show_paper(p)
         print
 
 def alias(args, options):
-    if len(args) > 0 and len(options.authors) > 0:
-        a =  Author.get_by_or_init(name = unicode(options.authors[0]))
+    if len(args) > 1:
+        a =  Author.get_by_or_init(name = unicode(args[1]))
         an = AuthorNickname.get_by_or_init(name = unicode(args[0]))
         an.author = a
         session.flush()
@@ -67,34 +113,67 @@
     for an in AuthorNickname.query.all():
         print '  %s: %s' % (an.name, an.author.name)
 
+def _set_options(p, options, required = []):
+    title = options.title or ('title' in required) and raw_input("Enter title: ")
+    if title:
+        p.title = unicode(title)
+
+    for label_with_commas in (options.labels or []):
+        labels = label_with_commas.split(',')
+        for label in labels:
+            label = unicode(label.strip())
+            if label[0] == '-':         # remove label
+                t = Tag.get_by(name = label[1:])
+                t.papers.remove(p)
+            else:                       # add label
+                t = Tag.get_by_or_init(name = label)
+                t.papers.append(p)
+
+    for author_with_commas in (options.authors or []):
+        authors = author_with_commas.split(',')
+        for author in authors:
+            author = unicode(author.strip())
+            an = AuthorNickname.get_by(name = author)
+            if an: a = an.author
+            else:  a = Author.get_by_or_init(name = author)
+            a.papers.append(p)
+
 def _sort_authors(authors):
     authors.sort()          # FIXME: deal with firstname lastname issues
 
 def _show_paper(paper):
-    print paper.title
+    print _colorize_string('title', paper.title)
     authors = [str(a) for a in paper.authors]
     _sort_authors(authors)
     for author in authors[:-1]:
-        print '%s,' % author,
-    print '%s' % authors[-1]
-    print 'Labels:', 
+        print '%s,' % _colorize_string('author', author),
+    print '%s' % _colorize_string('author', authors[-1])
+    print 'Labels:',
     for tag in paper.tags:
-        print tag,
-    print
-    print "Path:   %s" % paper.path
+        print '+%s' % _colorize_string('label', tag),
+    print color['normal']
+    print "Path:   %s" % _colorize_string('path', paper.path)
+    print "Hash:   %s" % _colorize_string('hash', paper.md5)
+
+def _colorize_string(clr, str):
+    return '%s%s%s' % (color[clr], str, color['normal'])
 
 if __name__ == "__main__":
     usage =  '%s COMMAND OPTIONS\n' % sys.argv[0]
     usage += 'Commands:\n'
     usage += '  add        - add a paper to the database\n'
     usage += '  list       - list papers in the database\n'
-    usage += '  alias      - add or list author nicknames'
+    usage += '  alias      - add or list author nicknames\n'
+    usage += '  update     - update paper by hash\n'
+    usage += '  view       - view paper by hash\n'
+    usage += '  remove     - remove paper by hash'
     
     # Parse options
     parser = OptionParser(usage = usage)
     parser.add_option('-a', '--author', action='append', dest='authors', help='author')
     parser.add_option('-t', '--title', dest='title', help='title')
     parser.add_option('-l', '--label', action='append', dest='labels', help='label')
+    parser.add_option('-s', '--hash', dest='hash', help='hash (only for list)')
     parser.add_option('-D', '--database', dest='database', help='directory with the database')
     (options, args) = parser.parse_args()
     
@@ -103,9 +182,15 @@
     initDatabase(path, not found)
     
     if len(args) == 0: sys.exit()
-    if args[0] == 'add':
+    if 'add'.startswith(args[0]):
         add(args[1:], options)
-    elif args[0] == 'list':
+    elif 'list'.startswith(args[0]):
         list(args[1:], options)
-    elif args[0] == 'alias':
+    elif 'alias'.startswith(args[0]):
         alias(args[1:], options)
+    elif 'update'.startswith(args[0]):
+        update(args[1:], options)
+    elif 'view'.startswith(args[0]):
+        view(args[1:], options)
+    elif 'remove'.startswith(args[0]):
+        remove(args[1:], options)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/terminal.py	Sat May 17 04:25:28 2008 -0400
@@ -0,0 +1,195 @@
+# TerminalController from ASPN. Submitted there by Edward Loper
+# http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/475116
+
+import sys, re
+
+class TerminalController:
+    """
+    A class that can be used to portably generate formatted output to
+    a terminal.  
+    
+    `TerminalController` defines a set of instance variables whose
+    values are initialized to the control sequence necessary to
+    perform a given action.  These can be simply included in normal
+    output to the terminal:
+
+        >>> term = TerminalController()
+        >>> print 'This is '+term.GREEN+'green'+term.NORMAL
+
+    Alternatively, the `render()` method can used, which replaces
+    '${action}' with the string required to perform 'action':
+
+        >>> term = TerminalController()
+        >>> print term.render('This is ${GREEN}green${NORMAL}')
+
+    If the terminal doesn't support a given action, then the value of
+    the corresponding instance variable will be set to ''.  As a
+    result, the above code will still work on terminals that do not
+    support color, except that their output will not be colored.
+    Also, this means that you can test whether the terminal supports a
+    given action by simply testing the truth value of the
+    corresponding instance variable:
+
+        >>> term = TerminalController()
+        >>> if term.CLEAR_SCREEN:
+        ...     print 'This terminal supports clearning the screen.'
+
+    Finally, if the width and height of the terminal are known, then
+    they will be stored in the `COLS` and `LINES` attributes.
+    """
+    # Cursor movement:
+    BOL = ''             #: Move the cursor to the beginning of the line
+    UP = ''              #: Move the cursor up one line
+    DOWN = ''            #: Move the cursor down one line
+    LEFT = ''            #: Move the cursor left one char
+    RIGHT = ''           #: Move the cursor right one char
+
+    # Deletion:
+    CLEAR_SCREEN = ''    #: Clear the screen and move to home position
+    CLEAR_EOL = ''       #: Clear to the end of the line.
+    CLEAR_BOL = ''       #: Clear to the beginning of the line.
+    CLEAR_EOS = ''       #: Clear to the end of the screen
+
+    # Output modes:
+    BOLD = ''            #: Turn on bold mode
+    BLINK = ''           #: Turn on blink mode
+    DIM = ''             #: Turn on half-bright mode
+    REVERSE = ''         #: Turn on reverse-video mode
+    NORMAL = ''          #: Turn off all modes
+
+    # Cursor display:
+    HIDE_CURSOR = ''     #: Make the cursor invisible
+    SHOW_CURSOR = ''     #: Make the cursor visible
+
+    # Terminal size:
+    COLS = None          #: Width of the terminal (None for unknown)
+    LINES = None         #: Height of the terminal (None for unknown)
+
+    # Foreground colors:
+    BLACK = BLUE = GREEN = CYAN = RED = MAGENTA = YELLOW = WHITE = ''
+    
+    # Background colors:
+    BG_BLACK = BG_BLUE = BG_GREEN = BG_CYAN = ''
+    BG_RED = BG_MAGENTA = BG_YELLOW = BG_WHITE = ''
+    
+    _STRING_CAPABILITIES = """
+    BOL=cr UP=cuu1 DOWN=cud1 LEFT=cub1 RIGHT=cuf1
+    CLEAR_SCREEN=clear CLEAR_EOL=el CLEAR_BOL=el1 CLEAR_EOS=ed BOLD=bold
+    BLINK=blink DIM=dim REVERSE=rev UNDERLINE=smul NORMAL=sgr0
+    HIDE_CURSOR=cinvis SHOW_CURSOR=cnorm""".split()
+    _COLORS = """BLACK BLUE GREEN CYAN RED MAGENTA YELLOW WHITE""".split()
+    _ANSICOLORS = "BLACK RED GREEN YELLOW BLUE MAGENTA CYAN WHITE".split()
+
+    def __init__(self, term_stream=sys.stdout):
+        """
+        Create a `TerminalController` and initialize its attributes
+        with appropriate values for the current terminal.
+        `term_stream` is the stream that will be used for terminal
+        output; if this stream is not a tty, then the terminal is
+        assumed to be a dumb terminal (i.e., have no capabilities).
+        """
+        # Curses isn't available on all platforms
+        try: import curses
+        except: return
+
+        # If the stream isn't a tty, then assume it has no capabilities.
+        if not term_stream.isatty(): return
+
+        # Check the terminal type.  If we fail, then assume that the
+        # terminal has no capabilities.
+        try: curses.setupterm()
+        except: return
+
+        # Look up numeric capabilities.
+        self.COLS = curses.tigetnum('cols')
+        self.LINES = curses.tigetnum('lines')
+        
+        # Look up string capabilities.
+        for capability in self._STRING_CAPABILITIES:
+            (attrib, cap_name) = capability.split('=')
+            setattr(self, attrib, self._tigetstr(cap_name) or '')
+
+        # Colors
+        set_fg = self._tigetstr('setf')
+        if set_fg:
+            for i,color in zip(range(len(self._COLORS)), self._COLORS):
+                setattr(self, color, curses.tparm(set_fg, i) or '')
+        set_fg_ansi = self._tigetstr('setaf')
+        if set_fg_ansi:
+            for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS):
+                setattr(self, color, curses.tparm(set_fg_ansi, i) or '')
+        set_bg = self._tigetstr('setb')
+        if set_bg:
+            for i,color in zip(range(len(self._COLORS)), self._COLORS):
+                setattr(self, 'BG_'+color, curses.tparm(set_bg, i) or '')
+        set_bg_ansi = self._tigetstr('setab')
+        if set_bg_ansi:
+            for i,color in zip(range(len(self._ANSICOLORS)), self._ANSICOLORS):
+                setattr(self, 'BG_'+color, curses.tparm(set_bg_ansi, i) or '')
+
+    def _tigetstr(self, cap_name):
+        # String capabilities can include "delays" of the form "$<2>".
+        # For any modern terminal, we should be able to just ignore
+        # these, so strip them out.
+        import curses
+        cap = curses.tigetstr(cap_name) or ''
+        return re.sub(r'\$<\d+>[/*]?', '', cap)
+
+    def render(self, template):
+        """
+        Replace each $-substitutions in the given template string with
+        the corresponding terminal control string (if it's defined) or
+        '' (if it's not).
+        """
+        return re.sub(r'\$\$|\${\w+}', self._render_sub, template)
+
+    def _render_sub(self, match):
+        s = match.group()
+        if s == '$$': return s
+        else: return getattr(self, s[2:-1])
+
+#######################################################################
+# Example use case: progress bar
+#######################################################################
+
+class ProgressBar:
+    """
+    A 3-line progress bar, which looks like::
+    
+                                Header
+        20% [===========----------------------------------]
+                           progress message
+
+    The progress bar is colored, if the terminal supports color
+    output; and adjusts to the width of the terminal.
+    """
+    BAR = '%3d%% ${GREEN}[${BOLD}%s%s${NORMAL}${GREEN}]${NORMAL}\n'
+    HEADER = '${BOLD}${CYAN}%s${NORMAL}\n\n'
+        
+    def __init__(self, term, header):
+        self.term = term
+        if not (self.term.CLEAR_EOL and self.term.UP and self.term.BOL):
+            raise ValueError("Terminal isn't capable enough -- you "
+                             "should use a simpler progress dispaly.")
+        self.width = self.term.COLS or 75
+        self.bar = term.render(self.BAR)
+        self.header = self.term.render(self.HEADER % header.center(self.width))
+        self.cleared = 1 #: true if we haven't drawn the bar yet.
+        self.update(0, '')
+
+    def update(self, percent, message):
+        if self.cleared:
+            sys.stdout.write(self.header)
+            self.cleared = 0
+        n = int((self.width-10)*percent)
+        sys.stdout.write(
+            self.term.BOL + self.term.UP + self.term.CLEAR_EOL +
+            (self.bar % (100*percent, '='*n, '-'*(self.width-10-n))) +
+            self.term.CLEAR_EOL + message.center(self.width))
+
+    def clear(self):
+        if not self.cleared:
+            sys.stdout.write(self.term.BOL + self.term.CLEAR_EOL +
+                             self.term.UP + self.term.CLEAR_EOL +
+                             self.term.UP + self.term.CLEAR_EOL)
+            self.cleared = 1