Code

Updated to version 1.4 (python 2.2) version of pygettext
[roundup.git] / roundup-admin
index de01109ee44e030e804be0514614fa0c5ddd589c..6a1d5c078efa1716ccc4a5e9e17d209857a4a983 100755 (executable)
-#! /usr/bin/python
-# $Id: roundup-admin,v 1.5 2001-07-30 00:04:48 richard Exp $
-
-import sys
-if int(sys.version[0]) < 2:
-    print 'Roundup requires python 2.0 or later.'
-    sys.exit(1)
-
-import string, os, getpass
-from roundup import date, roundupdb, init
-
-def determineLogin(instance, argv, n = 2):
-    name = password = ''
-    if argv[2] == '-u':
-        l = argv[3].split(':')
-        name = l[0]
-        if len(l) > 1:
-            password = l[1]
-        n = 4
-    elif os.environ.has_key('ROUNDUP_LOGIN'):
-        l = os.environ['ROUNDUP_LOGIN'].split(':')
-        name = l[0]
-        if len(l) > 1:
-            password = l[1]
-    while not name:
-        name = raw_input('Login name: ')
-    while not password:
-        password = getpass.getpass('  password: ')
-    # TODO use the password...
-    return n, instance.open(name)
-
-def usage(message=''):
-    if message: message = 'Problem: '+message+'\n'
-    print '''%sUsage:
-
- roundup [-i instance] init [template backend]
-   -- initialise the database
- roundup [-i instance] spec classname
-   -- show the properties for a classname
- roundup [-i instance] create [-u login] classname propname=value ...
-   -- create a new entry of a given class
- roundup [-i instance] list [-c] classname
-   -- list the instances of a class
- roundup [-i instance] history [-c] designator
-   -- show the history entries of a designator
- roundup [-i instance] get [-c] designator[,designator,...] propname
-   -- get the given property of one or more designator(s)
- roundup [-i instance] set [-u login] designator[,designator,...] propname=value ...
-   -- set the given property of one or more designator(s)
- roundup [-i instance] find [-c] classname propname=value ...
-   -- find the class instances with a given property
- roundup [-i instance] retire designator[,designator,...]
-   -- "retire" a designator
- roundup help    
-   -- this help
- roundup morehelp
-   -- even more detailed help
-'''%message
-
-def moreusage(message=''):
-    usage(message)
-    print '''
+#! /usr/bin/env python
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: roundup-admin,v 1.55 2001-12-17 03:52:47 richard Exp $
+
+# python version check
+from roundup import version_check
+
+import sys, os, getpass, getopt, re, UserDict
+try:
+    import csv
+except ImportError:
+    csv = None
+from roundup import date, hyperdb, roundupdb, init, password
+import roundup.instance
+
+class CommandDict(UserDict.UserDict):
+    '''Simple dictionary that lets us do lookups using partial keys.
+
+    Original code submitted by Engelbert Gruber.
+    '''
+    _marker = []
+    def get(self, key, default=_marker):
+        if self.data.has_key(key):
+            return [(key, self.data[key])]
+        keylist = self.data.keys()
+        keylist.sort()
+        l = []
+        for ki in keylist:
+            if ki.startswith(key):
+                l.append((ki, self.data[ki]))
+        if not l and default is self._marker:
+            raise KeyError, key
+        return l
+
+class UsageError(ValueError):
+    pass
+
+class AdminTool:
+
+    def __init__(self):
+        self.commands = CommandDict()
+        for k in AdminTool.__dict__.keys():
+            if k[:3] == 'do_':
+                self.commands[k[3:]] = getattr(self, k)
+        self.help = {}
+        for k in AdminTool.__dict__.keys():
+            if k[:5] == 'help_':
+                self.help[k[5:]] = getattr(self, k)
+        self.instance_home = ''
+        self.db = None
+
+    def usage(self, message=''):
+        if message: message = 'Problem: '+message+'\n\n'
+        print '''%sUsage: roundup-admin [-i instance home] [-u login] [-c] <command> <arguments>
+
+Help:
+ roundup-admin -h
+ roundup-admin help                       -- this help
+ roundup-admin help <command>             -- command-specific help
+ roundup-admin help all                   -- all available help
+Options:
+ -i instance home  -- specify the issue tracker "home directory" to administer
+ -u                -- the user[:password] to use for commands
+ -c                -- when outputting lists of data, just comma-separate them'''%message
+        self.help_commands()
+
+    def help_commands(self):
+        print 'Commands:',
+        commands = ['']
+        for command in self.commands.values():
+            h = command.__doc__.split('\n')[0]
+            commands.append(' '+h[7:])
+        commands.sort()
+        commands.append(
+'Commands may be abbreviated as long as the abbreviation matches only one')
+        commands.append('command, e.g. l == li == lis == list.')
+        print '\n'.join(commands)
+        print
+
+    def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
+       commands = self.commands.values()
+        def sortfun(a, b):
+            return cmp(a.__name__, b.__name__)
+        commands.sort(sortfun)
+       for command in commands:
+            h = command.__doc__.split('\n')
+            name = command.__name__[3:]
+            usage = h[0]
+            print '''
+<tr><td valign=top><strong>%(name)s</strong></td>
+    <td><tt>%(usage)s</tt><p>
+<pre>'''%locals()
+            indent = indent_re.match(h[3])
+            if indent: indent = len(indent.group(1))
+            for line in h[3:]:
+                if indent:
+                    print line[indent:]
+                else:
+                    print line
+            print '</pre></td></tr>\n'
+
+    def help_all(self):
+        print '''
 All commands (except help) require an instance specifier. This is just the path
-to the roundup instance you're working with. It may be specified in the environment
-variable ROUNDUP_INSTANCE or on the command line as "-i instance".
+to the roundup instance you're working with. A roundup instance is where 
+roundup keeps the database and configuration file that defines an issue
+tracker. It may be thought of as the issue tracker's "home directory". It may
+be specified in the environment variable ROUNDUP_INSTANCE or on the command
+line as "-i instance".
 
 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
 
@@ -102,54 +162,78 @@ Date format examples:
   "14:25" means <Date yyyy-mm-dd.19:25:00>
   "8:47:11" means <Date yyyy-mm-dd.13:47:11>
   "." means "right now"
+
+Command help:
 '''
+        for name, command in self.commands.items():
+            print '%s:'%name
+            print '   ',command.__doc__
 
-def main():
-    argv = sys.argv
+    def do_help(self, args, nl_re=re.compile('[\r\n]'),
+            indent_re=re.compile(r'^(\s+)\S+')):
+        '''Usage: help topic
+        Give help about topic.
 
-    if len(argv) == 1:
-        usage('No command specified')
-        return 1
+        commands  -- list commands
+        <command> -- help specific to a command
+        initopts  -- init command options
+        all       -- all available help
+        '''
+        topic = args[0]
 
-    # handle help now
-    if argv[1] == 'help':
-        usage()
-        return 0
-    if argv[1] == 'morehelp':
-        moreusage()
-        return 0
+        # try help_ methods
+        if self.help.has_key(topic):
+            self.help[topic]()
+            return 0
 
-    # figure the instance home
-    n = 1
-    if argv[1] == '-i':
-        if len(argv) == 2:
-            usage()
+        # try command docstrings
+        try:
+            l = self.commands.get(topic)
+        except KeyError:
+            print 'Sorry, no help for "%s"'%topic
             return 1
-        instance_home = argv[2]
-        n = 3
-    else:
-        instance_home = os.environ.get('ROUNDUP_INSTANCE', '')
-
-    # now figure the command
-    command = argv[n]
-    n = n + 1
-
-    if command == 'init':
-        adminpw = ''
-        confirm = 'x'
-        if len(argv) > n:
-            template = argv[n]
-            backend = argv[n+1]
-        else:
-            template = backend = ''
-        while not instance_home:
-            instance_home = raw_input('Enter instance home: ').strip()
 
-        # select template
+        # display the help for each match, removing the docsring indent
+        for name, help in l:
+            lines = nl_re.split(help.__doc__)
+            print lines[0]
+            indent = indent_re.match(lines[1])
+            if indent: indent = len(indent.group(1))
+            for line in lines[1:]:
+                if indent:
+                    print line[indent:]
+                else:
+                    print line
+        return 0
+
+    def help_initopts(self):
         import roundup.templates
         templates = roundup.templates.listTemplates()
         print 'Templates:', ', '.join(templates)
-        template = ''
+        import roundup.backends
+        backends = roundup.backends.__all__
+        print 'Back ends:', ', '.join(backends)
+
+
+    def do_initialise(self, instance_home, args):
+        '''Usage: initialise [template [backend [admin password]]]
+        Initialise a new Roundup instance.
+
+        The command will prompt for the instance home directory (if not supplied
+        through INSTANCE_HOME or the -i option. The template, backend and admin
+        password may be specified on the command-line as arguments, in that
+        order.
+
+        See also initopts help.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        # select template
+        import roundup.templates
+        templates = roundup.templates.listTemplates()
+        template = len(args) > 1 and args[1] or ''
+        if template not in templates:
+            print 'Templates:', ', '.join(templates)
         while template not in templates:
             template = raw_input('Select template [classic]: ').strip()
             if not template:
@@ -157,152 +241,998 @@ def main():
 
         import roundup.backends
         backends = roundup.backends.__all__
-        backend = ''
+        backend = len(args) > 2 and args[2] or ''
+        if backend not in backends:
+            print 'Back ends:', ', '.join(backends)
         while backend not in backends:
             backend = raw_input('Select backend [anydbm]: ').strip()
             if not backend:
                 backend = 'anydbm'
+        if len(args) > 3:
+            adminpw = confirm = args[3]
+        else:
+            adminpw = ''
+            confirm = 'x'
         while adminpw != confirm:
             adminpw = getpass.getpass('Admin Password: ')
             confirm = getpass.getpass('       Confirm: ')
         init.init(instance_home, template, backend, adminpw)
         return 0
 
-    # from here on, we need an instance_home
-    if not instance_home:
-        usage('No instance home specified')
-        return 1
-
-    # get the instance
-    path, instance = os.path.split(instance_home)
-    sys.path.insert(0, path)
-    try:
-        instance = __import__(instance)
-    finally:
-        del sys.path[0]
-
-    if command == 'get':
-        db = instance.open()
-        designators = string.split(argv[n], ',')
-        propname = argv[n+1]
-        # TODO: handle the -c option
+
+    def do_get(self, args):
+        '''Usage: get property designator[,designator]*
+        Get the given property of one or more designator(s).
+
+        Retrieves the property value of the nodes specified by the designators.
+        '''
+        if len(args) < 2:
+            raise UsageError, 'Not enough arguments supplied'
+        propname = args[0]
+        designators = args[1].split(',')
+        l = []
         for designator in designators:
-            classname, nodeid = roundupdb.splitDesignator(designator)
-            print db.getclass(classname).get(nodeid, propname)
+            # decode the node designator
+            try:
+                classname, nodeid = roundupdb.splitDesignator(designator)
+            except roundupdb.DesignatorError, message:
+                raise UsageError, message
+
+            # get the class
+            try:
+                cl = self.db.getclass(classname)
+            except KeyError:
+                raise UsageError, 'invalid class "%s"'%classname
+            try:
+                if self.comma_sep:
+                    l.append(cl.get(nodeid, propname))
+                else:
+                    print cl.get(nodeid, propname)
+            except IndexError:
+                raise UsageError, 'no such %s node "%s"'%(classname, nodeid)
+            except KeyError:
+                raise UsageError, 'no such %s property "%s"'%(classname,
+                    propname)
+        if self.comma_sep:
+            print ','.join(l)
+        return 0
+
 
-    elif command == 'set':
-        n, db = determineLogin(instance, argv, n)
-        designators = string.split(argv[n], ',')
+    def do_set(self, args):
+        '''Usage: set designator[,designator]* propname=value ...
+        Set the given property of one or more designator(s).
+
+        Sets the property to the value for all designators given.
+        '''
+        if len(args) < 2:
+            raise UsageError, 'Not enough arguments supplied'
+        from roundup import hyperdb
+
+        designators = args[0].split(',')
         props = {}
-        for prop in argv[n+1:]:
-            key, value = prop.split('=')
+        for prop in args[1:]:
+            if prop.find('=') == -1:
+                raise UsageError, 'argument "%s" not propname=value'%prop
+            try:
+                key, value = prop.split('=')
+            except ValueError:
+                raise UsageError, 'argument "%s" not propname=value'%prop
             props[key] = value
         for designator in designators:
-            classname, nodeid = roundupdb.splitDesignator(designator)
-            cl = db.getclass(classname)
+            # decode the node designator
+            try:
+                classname, nodeid = roundupdb.splitDesignator(designator)
+            except roundupdb.DesignatorError, message:
+                raise UsageError, message
+
+            # get the class
+            try:
+                cl = self.db.getclass(classname)
+            except KeyError:
+                raise UsageError, 'invalid class "%s"'%classname
+
             properties = cl.getprops()
             for key, value in props.items():
-                type =  properties[key]
-                if type.isStringType:
+                proptype =  properties[key]
+                if isinstance(proptype, hyperdb.String):
                     continue
-                elif type.isDateType:
-                    props[key] = date.Date(value)
-                elif type.isIntervalType:
-                    props[key] = date.Interval(value)
-                elif type.isLinkType:
+                elif isinstance(proptype, hyperdb.Password):
+                    props[key] = password.Password(value)
+                elif isinstance(proptype, hyperdb.Date):
+                    try:
+                        props[key] = date.Date(value)
+                    except ValueError, message:
+                        raise UsageError, '"%s": %s'%(value, message)
+                elif isinstance(proptype, hyperdb.Interval):
+                    try:
+                        props[key] = date.Interval(value)
+                    except ValueError, message:
+                        raise UsageError, '"%s": %s'%(value, message)
+                elif isinstance(proptype, hyperdb.Link):
                     props[key] = value
-                elif type.isMultilinkType:
+                elif isinstance(proptype, hyperdb.Multilink):
                     props[key] = value.split(',')
-            apply(cl.set, (nodeid, ), props)
-
-    elif command == 'find':
-        db = instance.open()
-        classname = argv[n]
-        cl = db.getclass(classname)
-
-        # look up the linked-to class and get the nodeid that has the value
-        propname, value = argv[n+1:].split('=')
-        propcl = cl[propname].classname
-        nodeid = propcl.lookup(value)
-
-        # now do the find
-        # TODO: handle the -c option
-        print cl.find(propname, nodeid)
-
-    elif command == 'spec':
-        db = instance.open()
-        classname = argv[n]
-        cl = db.getclass(classname)
+
+            # try the set
+            try:
+                apply(cl.set, (nodeid, ), props)
+            except (TypeError, IndexError, ValueError), message:
+                raise UsageError, message
+        return 0
+
+    def do_find(self, args):
+        '''Usage: find classname propname=value ...
+        Find the nodes of the given class with a given link property value.
+
+        Find the nodes of the given class with a given link property value. The
+        value may be either the nodeid of the linked node, or its key value.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        classname = args[0]
+        # get the class
+        try:
+            cl = self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'invalid class "%s"'%classname
+
+        # TODO: handle > 1 argument
+        # handle the propname=value argument
+        if args[1].find('=') == -1:
+            raise UsageError, 'argument "%s" not propname=value'%prop
+        try:
+            propname, value = args[1].split('=')
+        except ValueError:
+            raise UsageError, 'argument "%s" not propname=value'%prop
+
+        # if the value isn't a number, look up the linked class to get the
+        # number
+        num_re = re.compile('^\d+$')
+        if not num_re.match(value):
+            # get the property
+            try:
+                property = cl.properties[propname]
+            except KeyError:
+                raise UsageError, '%s has no property "%s"'%(classname,
+                    propname)
+
+            # make sure it's a link
+            if (not isinstance(property, hyperdb.Link) and not
+                    isinstance(property, hyperdb.Multilink)):
+                raise UsageError, 'You may only "find" link properties'
+
+            # get the linked-to class and look up the key property
+            link_class = self.db.getclass(property.classname)
+            try:
+                value = link_class.lookup(value)
+            except TypeError:
+                raise UsageError, '%s has no key property"'%link_class.classname
+            except KeyError:
+                raise UsageError, '%s has no entry "%s"'%(link_class.classname,
+                    propname)
+
+        # now do the find 
+        try:
+            if self.comma_sep:
+                print ','.join(apply(cl.find, (), {propname: value}))
+            else:
+                print apply(cl.find, (), {propname: value})
+        except KeyError:
+            raise UsageError, '%s has no property "%s"'%(classname,
+                propname)
+        except (ValueError, TypeError), message:
+            raise UsageError, message
+        return 0
+
+    def do_specification(self, args):
+        '''Usage: specification classname
+        Show the properties for a classname.
+
+        This lists the properties for a given class.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        classname = args[0]
+        # get the class
+        try:
+            cl = self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'invalid class "%s"'%classname
+
+        # get the key property
+        keyprop = cl.getkey()
         for key, value in cl.properties.items():
+            if keyprop == key:
+                print '%s: %s (key property)'%(key, value)
+            else:
+                print '%s: %s'%(key, value)
+
+    def do_display(self, args):
+        '''Usage: display designator
+        Show the property values for the given node.
+
+        This lists the properties and their associated values for the given
+        node.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+
+        # decode the node designator
+        try:
+            classname, nodeid = roundupdb.splitDesignator(args[0])
+        except roundupdb.DesignatorError, message:
+            raise UsageError, message
+
+        # get the class
+        try:
+            cl = self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'invalid class "%s"'%classname
+
+        # display the values
+        for key in cl.properties.keys():
+            value = cl.get(nodeid, key)
             print '%s: %s'%(key, value)
 
-    elif command == 'create':
-        n, db = determineLogin(instance, argv, n)
-        classname = argv[n]
-        cl = db.getclass(classname)
+    def do_create(self, args):
+        '''Usage: create classname property=value ...
+        Create a new entry of a given class.
+
+        This creates a new entry of the given class using the property
+        name=value arguments provided on the command line after the "create"
+        command.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        from roundup import hyperdb
+
+        classname = args[0]
+
+        # get the class
+        try:
+            cl = self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'invalid class "%s"'%classname
+
+        # now do a create
         props = {}
-        properties = cl.getprops()
-        for prop in argv[n+1:]:
-            key, value = prop.split('=')
-            type =  properties[key]
-            if type.isStringType:
+        properties = cl.getprops(protected = 0)
+        if len(args) == 1:
+            # ask for the properties
+            for key, value in properties.items():
+                if key == 'id': continue
+                name = value.__class__.__name__
+                if isinstance(value , hyperdb.Password):
+                    again = None
+                    while value != again:
+                        value = getpass.getpass('%s (Password): '%key.capitalize())
+                        again = getpass.getpass('   %s (Again): '%key.capitalize())
+                        if value != again: print 'Sorry, try again...'
+                    if value:
+                        props[key] = value
+                else:
+                    value = raw_input('%s (%s): '%(key.capitalize(), name))
+                    if value:
+                        props[key] = value
+        else:
+            # use the args
+            for prop in args[1:]:
+                if prop.find('=') == -1:
+                    raise UsageError, 'argument "%s" not propname=value'%prop
+                try:
+                    key, value = prop.split('=')
+                except ValueError:
+                    raise UsageError, 'argument "%s" not propname=value'%prop
                 props[key] = value 
-            elif type.isDateType:
-                props[key] = date.Date(value)
-            elif type.isIntervalType:
-                props[key] = date.Interval(value)
-            elif type.isLinkType:
-                props[key] = value
-            elif type.isMultilinkType:
+
+        # convert types
+        for key in props.keys():
+            # get the property
+            try:
+                proptype = properties[key]
+            except KeyError:
+                raise UsageError, '%s has no property "%s"'%(classname, key)
+
+            if isinstance(proptype, hyperdb.Date):
+                try:
+                    props[key] = date.Date(value)
+                except ValueError, message:
+                    raise UsageError, '"%s": %s'%(value, message)
+            elif isinstance(proptype, hyperdb.Interval):
+                try:
+                    props[key] = date.Interval(value)
+                except ValueError, message:
+                    raise UsageError, '"%s": %s'%(value, message)
+            elif isinstance(proptype, hyperdb.Password):
+                props[key] = password.Password(value)
+            elif isinstance(proptype, hyperdb.Multilink):
                 props[key] = value.split(',')
-        print apply(cl.create, (), props)
-
-    elif command == 'list':
-        db = instance.open()
-        classname = argv[n]
-        cl = db.getclass(classname)
-        key = cl.getkey() or cl.properties.keys()[0]
-        # TODO: handle the -c option
+
+        # check for the key property
+        if cl.getkey() and not props.has_key(cl.getkey()):
+            raise UsageError, "you must provide the '%s' property."%cl.getkey()
+
+        # do the actual create
+        try:
+            print apply(cl.create, (), props)
+        except (TypeError, IndexError, ValueError), message:
+            raise UsageError, message
+        return 0
+
+    def do_list(self, args):
+        '''Usage: list classname [property]
+        List the instances of a class.
+
+        Lists all instances of the given class. If the property is not
+        specified, the  "label" property is used. The label property is tried
+        in order: the key, "name", "title" and then the first property,
+        alphabetically.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        classname = args[0]
+
+        # get the class
+        try:
+            cl = self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'invalid class "%s"'%classname
+
+        # figure the property
+        if len(args) > 1:
+            key = args[1]
+        else:
+            key = cl.labelprop()
+
+        if self.comma_sep:
+            print ','.join(cl.list())
+        else:
+            for nodeid in cl.list():
+                try:
+                    value = cl.get(nodeid, key)
+                except KeyError:
+                    raise UsageError, '%s has no property "%s"'%(classname, key)
+                print "%4s: %s"%(nodeid, value)
+        return 0
+
+    def do_table(self, args):
+        '''Usage: table classname [property[,property]*]
+        List the instances of a class in tabular form.
+
+        Lists all instances of the given class. If the properties are not
+        specified, all properties are displayed. By default, the column widths
+        are the width of the property names. The width may be explicitly defined
+        by defining the property as "name:width". For example::
+          roundup> table priority id,name:10
+          Id Name
+          1  fatal-bug 
+          2  bug       
+          3  usability 
+          4  feature   
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        classname = args[0]
+
+        # get the class
+        try:
+            cl = self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'invalid class "%s"'%classname
+
+        # figure the property names to display
+        if len(args) > 1:
+            prop_names = args[1].split(',')
+            all_props = cl.getprops()
+            for prop_name in prop_names:
+                if not all_props.has_key(prop_name):
+                    raise UsageError, '%s has no property "%s"'%(classname,
+                        prop_name)
+        else:
+            prop_names = cl.getprops().keys()
+
+        # now figure column widths
+        props = []
+        for spec in prop_names:
+            if ':' in spec:
+                try:
+                    name, width = spec.split(':')
+                except (ValueError, TypeError):
+                    raise UsageError, '"%s" not name:width'%spec
+                props.append((spec, int(width)))
+            else:
+                props.append((spec, len(spec)))
+
+        # now display the heading
+        print ' '.join([name.capitalize() for name, width in props])
+
+        # and the table data
         for nodeid in cl.list():
-            value = cl.get(nodeid, key)
-            print "%4s: %s"%(nodeid, value)
+            l = []
+            for name, width in props:
+                if name != 'id':
+                    try:
+                        value = str(cl.get(nodeid, name))
+                    except KeyError:
+                        # we already checked if the property is valid - a
+                        # KeyError here means the node just doesn't have a
+                        # value for it
+                        value = ''
+                else:
+                    value = str(nodeid)
+                f = '%%-%ds'%width
+                l.append(f%value[:width])
+            print ' '.join(l)
+        return 0
+
+    def do_history(self, args):
+        '''Usage: history designator
+        Show the history entries of a designator.
+
+        Lists the journal entries for the node identified by the designator.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        try:
+            classname, nodeid = roundupdb.splitDesignator(args[0])
+        except roundupdb.DesignatorError, message:
+            raise UsageError, message
+
+        # TODO: handle the -c option?
+        try:
+            print self.db.getclass(classname).history(nodeid)
+        except KeyError:
+            raise UsageError, 'no such class "%s"'%classname
+        except IndexError:
+            raise UsageError, 'no such %s node "%s"'%(classname, nodeid)
+        return 0
+
+    def do_commit(self, args):
+        '''Usage: commit
+        Commit all changes made to the database.
 
-    elif command == 'history':
-        db = instance.open()
-        classname, nodeid = roundupdb.splitDesignator(argv[n])
-        # TODO: handle the -c option
-        print db.getclass(classname).history(nodeid)
+        The changes made during an interactive session are not
+        automatically written to the database - they must be committed
+        using this command.
 
-    elif command == 'retire':
-        n, db = determineLogin(instance, argv, n)
-        designators = string.split(argv[n], ',')
+        One-off commands on the command-line are automatically committed if
+        they are successful.
+        '''
+        self.db.commit()
+        return 0
+
+    def do_rollback(self, args):
+        '''Usage: rollback
+        Undo all changes that are pending commit to the database.
+
+        The changes made during an interactive session are not
+        automatically written to the database - they must be committed
+        manually. This command undoes all those changes, so a commit
+        immediately after would make no changes to the database.
+        '''
+        self.db.rollback()
+        return 0
+
+    def do_retire(self, args):
+        '''Usage: retire designator[,designator]*
+        Retire the node specified by designator.
+
+        This action indicates that a particular node is not to be retrieved by
+        the list or find commands, and its key value may be re-used.
+        '''
+        if len(args) < 1:
+            raise UsageError, 'Not enough arguments supplied'
+        designators = args[0].split(',')
         for designator in designators:
-            classname, nodeid = roundupdb.splitDesignator(designator)
-            db.getclass(classname).retire(nodeid)
+            try:
+                classname, nodeid = roundupdb.splitDesignator(designator)
+            except roundupdb.DesignatorError, message:
+                raise UsageError, message
+            try:
+                self.db.getclass(classname).retire(nodeid)
+            except KeyError:
+                raise UsageError, 'no such class "%s"'%classname
+            except IndexError:
+                raise UsageError, 'no such %s node "%s"'%(classname, nodeid)
+        return 0
 
-    elif command == 'freshen':
-        n, db = determineLogin(instance, argv, n)
-        for classname, cl in db.classes.items():
-            properties = cl.properties.keys()
+    def do_export(self, args):
+        '''Usage: export class[,class] destination_dir
+        Export the database to tab-separated-value files.
+
+        This action exports the current data from the database into
+        tab-separated-value files that are placed in the nominated destination
+        directory. The journals are not exported.
+        '''
+        if len(args) < 2:
+            raise UsageError, 'Not enough arguments supplied'
+        classes = args[0].split(',')
+        dir = args[1]
+
+        # use the csv parser if we can - it's faster
+        if csv is not None:
+            p = csv.parser(field_sep=':')
+
+        # do all the classes specified
+        for classname in classes:
+            try:
+                cl = self.db.getclass(classname)
+            except KeyError:
+                raise UsageError, 'no such class "%s"'%classname
+            f = open(os.path.join(dir, classname+'.csv'), 'w')
+            f.write(':'.join(cl.properties.keys()) + '\n')
+
+            # all nodes for this class
+            properties = cl.properties.items()
             for nodeid in cl.list():
-                node = {}
-                for name in properties:
-                    node[name] = cl.get(nodeid, name)
-                db.setnode(classname, nodeid, node)
+                l = []
+                for prop, proptype in properties:
+                    value = cl.get(nodeid, prop)
+                    # convert data where needed
+                    if isinstance(proptype, hyperdb.Date):
+                        value = value.get_tuple()
+                    elif isinstance(proptype, hyperdb.Interval):
+                        value = value.get_tuple()
+                    elif isinstance(proptype, hyperdb.Password):
+                        value = str(value)
+                    l.append(repr(value))
+
+                # now write
+                if csv is not None:
+                   f.write(p.join(l) + '\n')
+                else:
+                   # escape the individual entries to they're valid CSV
+                   m = []
+                   for entry in l:
+                      if '"' in entry:
+                          entry = '""'.join(entry.split('"'))
+                      if ':' in entry:
+                          entry = '"%s"'%entry
+                      m.append(entry)
+                   f.write(':'.join(m) + '\n')
+        return 0
 
-    else:
-        print "Unknown command '%s'"%command
-        usage()
-        return 1
+    def do_import(self, args):
+        '''Usage: import class file
+        Import the contents of the tab-separated-value file.
+
+        The file must define the same properties as the class (including having
+        a "header" line with those property names.) The new nodes are added to
+        the existing database - if you want to create a new database using the
+        imported data, then create a new database (or, tediously, retire all
+        the old data.)
+        '''
+        if len(args) < 2:
+            raise UsageError, 'Not enough arguments supplied'
+        if csv is None:
+            raise UsageError, \
+                'Sorry, you need the csv module to use this function.\n'\
+                'Get it from: http://www.object-craft.com.au/projects/csv/'
+
+        from roundup import hyperdb
+
+        # ensure that the properties and the CSV file headings match
+        classname = args[0]
+        try:
+            cl = self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'no such class "%s"'%classname
+        f = open(args[1])
+        p = csv.parser(field_sep=':')
+        file_props = p.parse(f.readline())
+        props = cl.properties.keys()
+        m = file_props[:]
+        m.sort()
+        props.sort()
+        if m != props:
+            raise UsageError, 'Import file doesn\'t define the same '\
+                'properties as "%s".'%args[0]
+
+        # loop through the file and create a node for each entry
+        n = range(len(props))
+        while 1:
+            line = f.readline()
+            if not line: break
+
+            # parse lines until we get a complete entry
+            while 1:
+                l = p.parse(line)
+                if l: break
+
+            # make the new node's property map
+            d = {}
+            for i in n:
+                # Use eval to reverse the repr() used to output the CSV
+                value = eval(l[i])
+                # Figure the property for this column
+                key = file_props[i]
+                proptype = cl.properties[key]
+                # Convert for property type
+                if isinstance(proptype, hyperdb.Date):
+                    value = date.Date(value)
+                elif isinstance(proptype, hyperdb.Interval):
+                    value = date.Interval(value)
+                elif isinstance(proptype, hyperdb.Password):
+                    pwd = password.Password()
+                    pwd.unpack(value)
+                    value = pwd
+                if value is not None:
+                    d[key] = value
+
+            # and create the new node
+            apply(cl.create, (), d)
+        return 0
+
+    def run_command(self, args):
+        '''Run a single command
+        '''
+        command = args[0]
+
+        # handle help now
+        if command == 'help':
+            if len(args)>1:
+                self.do_help(args[1:])
+                return 0
+            self.do_help(['help'])
+            return 0
+        if command == 'morehelp':
+            self.do_help(['help'])
+            self.help_commands()
+            self.help_all()
+            return 0
+
+        # figure what the command is
+        try:
+            functions = self.commands.get(command)
+        except KeyError:
+            # not a valid command
+            print 'Unknown command "%s" ("help commands" for a list)'%command
+            return 1
+
+        # check for multiple matches
+        if len(functions) > 1:
+            print 'Multiple commands match "%s": %s'%(command,
+                ', '.join([i[0] for i in functions]))
+            return 1
+        command, function = functions[0]
+
+        # make sure we have an instance_home
+        while not self.instance_home:
+            self.instance_home = raw_input('Enter instance home: ').strip()
+
+        # before we open the db, we may be doing an init
+        if command == 'initialise':
+            return self.do_initialise(self.instance_home, args)
+
+        # get the instance
+        try:
+            instance = roundup.instance.open(self.instance_home)
+        except ValueError, message:
+            self.instance_home = ''
+            print "Couldn't open instance: %s"%message
+            return 1
+
+        # only open the database once!
+        if not self.db:
+            self.db = instance.open('admin')
+
+        # do the command
+        ret = 0
+        try:
+            ret = function(args[1:])
+        except UsageError, message:
+            print 'Error: %s'%message
+            print function.__doc__
+            ret = 1
+        except:
+            import traceback
+            traceback.print_exc()
+            ret = 1
+        return ret
+
+    def interactive(self, ws_re=re.compile(r'\s+')):
+        '''Run in an interactive mode
+        '''
+        print 'Roundup {version} ready for input.'
+        print 'Type "help" for help.'
+        try:
+            import readline
+        except ImportError:
+            print "Note: command history and editing not available"
+
+        while 1:
+            try:
+                command = raw_input('roundup> ')
+            except EOFError:
+                print 'exit...'
+                break
+            if not command: continue
+            args = ws_re.split(command)
+            if not args: continue
+            if args[0] in ('quit', 'exit'): break
+            self.run_command(args)
+
+        # exit.. check for transactions
+        if self.db and self.db.transactions:
+            commit = raw_input("There are unsaved changes. Commit them (y/N)? ")
+            if commit[0].lower() == 'y':
+                self.db.commit()
+        return 0
+
+    def main(self):
+        try:
+            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
+        except getopt.GetoptError, e:
+            self.usage(str(e))
+            return 1
+
+        # handle command-line args
+        self.instance_home = os.environ.get('ROUNDUP_INSTANCE', '')
+        name = password = ''
+        if os.environ.has_key('ROUNDUP_LOGIN'):
+            l = os.environ['ROUNDUP_LOGIN'].split(':')
+            name = l[0]
+            if len(l) > 1:
+                password = l[1]
+        self.comma_sep = 0
+        for opt, arg in opts:
+            if opt == '-h':
+                self.usage()
+                return 0
+            if opt == '-i':
+                self.instance_home = arg
+            if opt == '-c':
+                self.comma_sep = 1
+
+        # if no command - go interactive
+        ret = 0
+        if not args:
+            self.interactive()
+        else:
+            ret = self.run_command(args)
+            if self.db: self.db.commit()
+        return ret
 
-    db.close()
-    return 0
 
 if __name__ == '__main__':
-    sys.exit(main())
+    tool = AdminTool()
+    sys.exit(tool.main())
 
 #
 # $Log: not supported by cvs2svn $
+# Revision 1.54  2001/12/15 23:09:23  richard
+# Some cleanups in roundup-admin, also made it work again...
+#
+# Revision 1.53  2001/12/13 00:20:00  richard
+#  . Centralised the python version check code, bumped version to 2.1.1 (really
+#    needs to be 2.1.2, but that isn't released yet :)
+#
+# Revision 1.52  2001/12/12 21:47:45  richard
+#  . Message author's name appears in From: instead of roundup instance name
+#    (which still appears in the Reply-To:)
+#  . envelope-from is now set to the roundup-admin and not roundup itself so
+#    delivery reports aren't sent to roundup (thanks Patrick Ohly)
+#
+# Revision 1.51  2001/12/10 00:57:38  richard
+# From CHANGES:
+#  . Added the "display" command to the admin tool - displays a node's values
+#  . #489760 ] [issue] only subject
+#  . fixed the doc/index.html to include the quoting in the mail alias.
+#
+# Also:
+#  . fixed roundup-admin so it works with transactions
+#  . disabled the back_anydbm module if anydbm tries to use dumbdbm
+#
+# Revision 1.50  2001/12/02 05:06:16  richard
+# . We now use weakrefs in the Classes to keep the database reference, so
+#   the close() method on the database is no longer needed.
+#   I bumped the minimum python requirement up to 2.1 accordingly.
+# . #487480 ] roundup-server
+# . #487476 ] INSTALL.txt
+#
+# I also cleaned up the change message / post-edit stuff in the cgi client.
+# There's now a clearly marked "TODO: append the change note" where I believe
+# the change note should be added there. The "changes" list will obviously
+# have to be modified to be a dict of the changes, or somesuch.
+#
+# More testing needed.
+#
+# Revision 1.49  2001/12/01 07:17:50  richard
+# . We now have basic transaction support! Information is only written to
+#   the database when the commit() method is called. Only the anydbm
+#   backend is modified in this way - neither of the bsddb backends have been.
+#   The mail, admin and cgi interfaces all use commit (except the admin tool
+#   doesn't have a commit command, so interactive users can't commit...)
+# . Fixed login/registration forwarding the user to the right page (or not,
+#   on a failure)
+#
+# Revision 1.48  2001/11/27 22:32:03  richard
+# typo
+#
+# Revision 1.47  2001/11/26 22:55:56  richard
+# Feature:
+#  . Added INSTANCE_NAME to configuration - used in web and email to identify
+#    the instance.
+#  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
+#    signature info in e-mails.
+#  . Some more flexibility in the mail gateway and more error handling.
+#  . Login now takes you to the page you back to the were denied access to.
+#
+# Fixed:
+#  . Lots of bugs, thanks Roché and others on the devel mailing list!
+#
+# Revision 1.46  2001/11/21 03:40:54  richard
+# more new property handling
+#
+# Revision 1.45  2001/11/12 22:51:59  jhermann
+# Fixed option & associated error handling
+#
+# Revision 1.44  2001/11/12 22:01:06  richard
+# Fixed issues with nosy reaction and author copies.
+#
+# Revision 1.43  2001/11/09 22:33:28  richard
+# More error handling fixes.
+#
+# Revision 1.42  2001/11/09 10:11:08  richard
+#  . roundup-admin now handles all hyperdb exceptions
+#
+# Revision 1.41  2001/11/09 01:25:40  richard
+# Should parse with python 1.5.2 now.
+#
+# Revision 1.40  2001/11/08 04:42:00  richard
+# Expanded the already-abbreviated "initialise" and "specification" commands,
+# and added a comment to the command help about the abbreviation.
+#
+# Revision 1.39  2001/11/08 04:29:59  richard
+# roundup-admin now accepts abbreviated commands (eg. l = li = lis = list)
+# [thanks Engelbert Gruber for the inspiration]
+#
+# Revision 1.38  2001/11/05 23:45:40  richard
+# Fixed newuser_action so it sets the cookie with the unencrypted password.
+# Also made it present nicer error messages (not tracebacks).
+#
+# Revision 1.37  2001/10/23 01:00:18  richard
+# Re-enabled login and registration access after lopping them off via
+# disabling access for anonymous users.
+# Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
+# a couple of bugs while I was there. Probably introduced a couple, but
+# things seem to work OK at the moment.
+#
+# Revision 1.36  2001/10/21 00:45:15  richard
+# Added author identification to e-mail messages from roundup.
+#
+# Revision 1.35  2001/10/20 11:58:48  richard
+# Catch errors in login - no username or password supplied.
+# Fixed editing of password (Password property type) thanks Roch'e Compaan.
+#
+# Revision 1.34  2001/10/18 02:16:42  richard
+# Oops, committed the admin script with the wierd #! line.
+# Also, made the thing into a class to reduce parameter passing.
+# Nuked the leading whitespace from the help __doc__ displays too.
+#
+# Revision 1.33  2001/10/17 23:13:19  richard
+# Did a fair bit of work on the admin tool. Now has an extra command "table"
+# which displays node information in a tabular format. Also fixed import and
+# export so they work. Removed freshen.
+# Fixed quopri usage in mailgw from bug reports.
+#
+# Revision 1.32  2001/10/17 06:57:29  richard
+# Interactive startup blurb - need to figure how to get the version in there.
+#
+# Revision 1.31  2001/10/17 06:17:26  richard
+# Now with readline support :)
+#
+# Revision 1.30  2001/10/17 06:04:00  richard
+# Beginnings of an interactive mode for roundup-admin
+#
+# Revision 1.29  2001/10/16 03:48:01  richard
+# admin tool now complains if a "find" is attempted with a non-link property.
+#
+# Revision 1.28  2001/10/13 00:07:39  richard
+# More help in admin tool.
+#
+# Revision 1.27  2001/10/11 23:43:04  richard
+# Implemented the comma-separated printing option in the admin tool.
+# Fixed a typo (more of a vim-o actually :) in mailgw.
+#
+# Revision 1.26  2001/10/11 05:03:51  richard
+# Marked the roundup-admin import/export as experimental since they're not fully
+# operational.
+#
+# Revision 1.25  2001/10/10 04:12:32  richard
+# The setup.cfg file is just causing pain. Away it goes.
+#
+# Revision 1.24  2001/10/10 03:54:57  richard
+# Added database importing and exporting through CSV files.
+# Uses the csv module from object-craft for exporting if it's available.
+# Requires the csv module for importing.
+#
+# Revision 1.23  2001/10/09 23:36:25  richard
+# Spit out command help if roundup-admin command doesn't get an argument.
+#
+# Revision 1.22  2001/10/09 07:25:59  richard
+# Added the Password property type. See "pydoc roundup.password" for
+# implementation details. Have updated some of the documentation too.
+#
+# Revision 1.21  2001/10/05 02:23:24  richard
+#  . roundup-admin create now prompts for property info if none is supplied
+#    on the command-line.
+#  . hyperdb Class getprops() method may now return only the mutable
+#    properties.
+#  . Login now uses cookies, which makes it a whole lot more flexible. We can
+#    now support anonymous user access (read-only, unless there's an
+#    "anonymous" user, in which case write access is permitted). Login
+#    handling has been moved into cgi_client.Client.main()
+#  . The "extended" schema is now the default in roundup init.
+#  . The schemas have had their page headings modified to cope with the new
+#    login handling. Existing installations should copy the interfaces.py
+#    file from the roundup lib directory to their instance home.
+#  . Incorrectly had a Bizar Software copyright on the cgitb.py module from
+#    Ping - has been removed.
+#  . Fixed a whole bunch of places in the CGI interface where we should have
+#    been returning Not Found instead of throwing an exception.
+#  . Fixed a deviation from the spec: trying to modify the 'id' property of
+#    an item now throws an exception.
+#
+# Revision 1.20  2001/10/04 02:12:42  richard
+# Added nicer command-line item adding: passing no arguments will enter an
+# interactive more which asks for each property in turn. While I was at it, I
+# fixed an implementation problem WRT the spec - I wasn't raising a
+# ValueError if the key property was missing from a create(). Also added a
+# protected=boolean argument to getprops() so we can list only the mutable
+# properties (defaults to yes, which lists the immutables).
+#
+# Revision 1.19  2001/10/01 06:40:43  richard
+# made do_get have the args in the correct order
+#
+# Revision 1.18  2001/09/18 22:58:37  richard
+#
+# Added some more help to roundu-admin
+#
+# Revision 1.17  2001/08/28 05:58:33  anthonybaxter
+# added missing 'import' statements.
+#
+# Revision 1.16  2001/08/12 06:32:36  richard
+# using isinstance(blah, Foo) now instead of isFooType
+#
+# Revision 1.15  2001/08/07 00:24:42  richard
+# stupid typo
+#
+# Revision 1.14  2001/08/07 00:15:51  richard
+# Added the copyright/license notice to (nearly) all files at request of
+# Bizar Software.
+#
+# Revision 1.13  2001/08/05 07:44:13  richard
+# Instances are now opened by a special function that generates a unique
+# module name for the instances on import time.
+#
+# Revision 1.12  2001/08/03 01:28:33  richard
+# Used the much nicer load_package, pointed out by Steve Majewski.
+#
+# Revision 1.11  2001/08/03 00:59:34  richard
+# Instance import now imports the instance using imp.load_module so that
+# we can have instance homes of "roundup" or other existing python package
+# names.
+#
+# Revision 1.10  2001/07/30 08:12:17  richard
+# Added time logging and file uploading to the templates.
+#
+# Revision 1.9  2001/07/30 03:52:55  richard
+# init help now lists templates and backends
+#
+# Revision 1.8  2001/07/30 02:37:07  richard
+# Freshen is really broken. Commented out.
+#
+# Revision 1.7  2001/07/30 01:28:46  richard
+# Bugfixes
+#
+# Revision 1.6  2001/07/30 00:57:51  richard
+# Now uses getopt, much improved command-line parsing. Much fuller help. Much
+# better internal structure. It's just BETTER. :)
+#
+# Revision 1.5  2001/07/30 00:04:48  richard
+# Made the "init" prompting more friendly.
+#
 # Revision 1.4  2001/07/29 07:01:39  richard
 # Added vim command to all source so that we don't get no steenkin' tabs :)
 #