Code

Property changes are now completely traceable, whether changes are
[roundup.git] / roundup-admin
index 42ec53237c89da289b53b70df6e4d4d8f1e5047e..984485ee4751bc5915723523b7e40a00f1c444ea 100755 (executable)
@@ -1,4 +1,4 @@
-#! /usr/bin/python
+#! /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
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundup-admin,v 1.25 2001-10-10 04:12:32 richard Exp $
+# $Id: roundup-admin,v 1.48 2001-11-27 22:32:03 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, getopt, re
+import string, os, getpass, getopt, re, UserDict
 try:
     import csv
 except ImportError:
     csv = None
-from roundup import date, roundupdb, init, password
+from roundup import date, hyperdb, roundupdb, init, password
 import roundup.instance
 
-def usage(message=''):
-    if message: message = 'Problem: '+message+'\n'
-    commands = []
-    for command in figureCommands().values():
-        h = command.__doc__.split('\n')[0]
-        commands.append(h[7:])
-    commands.sort()
-    print '''%sUsage: roundup-admin [-i instance home] [-u login] [-c] <command> <arguments>
-
-Commands:
- %s
+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.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 morehelp                   -- even more detailed 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, '\n '.join(commands))
-
-def moreusage(message=''):
-    usage(message)
-    print '''
+ -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_all(self):
+        print '''
 All commands (except help) require an instance specifier. This is just the path
 to the roundup instance you're working with. A roundup instance is where 
 roundup keeps the database and configuration file that defines an issue
@@ -104,467 +144,848 @@ Date format examples:
 
 Command help:
 '''
-    for name, command in figureCommands().items():
-        print '%s:'%name
-        print '   ',command.__doc__
+        for name, command in self.commands.items():
+            print '%s:'%name
+            print '   ',command.__doc__
+
+    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.
+
+        commands  -- list commands
+        <command> -- help specific to a command
+        initopts  -- init command options
+        all       -- all available help
+        '''
+        topic = args[0]
+
+        # try help_ methods
+        if self.help.has_key(topic):
+            self.help[topic]()
+            return 0
 
-def do_init(instance_home, args):
-    '''Usage: init [template [backend [admin password]]]
-    Initialise a new Roundup instance.
+        # try command docstrings
+        try:
+            l = self.commands.get(topic)
+        except KeyError:
+            print 'Sorry, no help for "%s"'%topic
+            return 1
+
+        # 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
 
-    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.
-    '''
-    # select template
-    import roundup.templates
-    templates = roundup.templates.listTemplates()
-    template = len(args) > 1 and args[1] or ''
-    if template not in templates:
+    def help_initopts(self):
+        import roundup.templates
+        templates = roundup.templates.listTemplates()
         print 'Templates:', ', '.join(templates)
-    while template not in templates:
-        template = raw_input('Select template [extended]: ').strip()
-        if not template:
-            template = 'extended'
-
-    import roundup.backends
-    backends = roundup.backends.__all__
-    backend = len(args) > 2 and args[2] or ''
-    if backend not in backends:
+        import roundup.backends
+        backends = roundup.backends.__all__
         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
-
-
-def do_get(db, 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.
-    '''
-    propname = args[0]
-    designators = string.split(args[1], ',')
-    # TODO: handle the -c option
-    for designator in designators:
-        classname, nodeid = roundupdb.splitDesignator(designator)
-        print db.getclass(classname).get(nodeid, propname)
-    return 0
 
 
-def do_set(db, args):
-    '''Usage: set designator[,designator]* propname=value ...
-    Set the given property of one or more designator(s).
+    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.
+        '''
+        # 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:
+                template = 'classic'
+
+        import roundup.backends
+        backends = roundup.backends.__all__
+        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
 
-    Sets the property to the value for all designators given.
-    '''
-    from roundup import hyperdb
-
-    designators = string.split(args[0], ',')
-    props = {}
-    for prop in args[1:]:
-        key, value = prop.split('=')
-        props[key] = value
-    for designator in designators:
-        classname, nodeid = roundupdb.splitDesignator(designator)
-        cl = db.getclass(classname)
-        properties = cl.getprops()
-        for key, value in props.items():
-            type =  properties[key]
-            if isinstance(type, hyperdb.String):
-                continue
-            elif isinstance(type, hyperdb.Password):
+
+    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.
+        '''
+        propname = args[0]
+        designators = string.split(args[1], ',')
+        l = []
+        for designator in designators:
+            # 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
+
+
+    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.
+        '''
+        from roundup import hyperdb
+
+        designators = string.split(args[0], ',')
+        props = {}
+        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:
+            # 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():
+                proptype =  properties[key]
+                if isinstance(proptype, hyperdb.String):
+                    continue
+                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 isinstance(proptype, hyperdb.Multilink):
+                    props[key] = value.split(',')
+
+            # 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.
+        '''
+        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.
+        '''
+        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_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.
+        '''
+        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(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 
+
+        # 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(type, hyperdb.Date):
-                props[key] = date.Date(value)
-            elif isinstance(type, hyperdb.Interval):
-                props[key] = date.Interval(value)
-            elif isinstance(type, hyperdb.Link):
-                props[key] = value
-            elif isinstance(type, hyperdb.Multilink):
+            elif isinstance(proptype, hyperdb.Multilink):
                 props[key] = value.split(',')
-        apply(cl.set, (nodeid, ), props)
-    return 0
 
-def do_find(db, args):
-    '''Usage: find classname propname=value ...
-    Find the nodes of the given class with a given property value.
+        # 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()
 
-    Find the nodes of the given class with a given property value. The
-    value may be either the nodeid of the linked node, or its key value.
-    '''
-    classname = args[0]
-    cl = db.getclass(classname)
-
-    # look up the linked-to class and get the nodeid that has the value
-    propname, value = args[1].split('=')
-    num_re = re.compile('^\d+$')
-    if num_re.match(value):
-        nodeid = value
-    else:
-        propcl = cl.properties[propname].classname
-        propcl = db.getclass(propcl)
-        nodeid = propcl.lookup(value)
-
-    # now do the find
-    # TODO: handle the -c option
-    print cl.find(**{propname: nodeid})
-    return 0
-
-def do_spec(db, args):
-    '''Usage: spec classname
-    Show the properties for a classname.
-
-    This lists the properties for a given class.
-    '''
-    classname = args[0]
-    cl = db.getclass(classname)
-    keyprop = cl.getkey()
-    for key, value in cl.properties.items():
-        if keyprop == key:
-            print '%s: %s (key property)'%(key, value)
+        # 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.
+        '''
+        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:
-            print '%s: %s'%(key, value)
+            key = cl.labelprop()
 
-def do_create(db, args):
-    '''Usage: create classname property=value ...
-    Create a new entry of a given class.
+        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
 
-    This creates a new entry of the given class using the property
-    name=value arguments provided on the command line after the "create"
-    command.
-    '''
-    from roundup import hyperdb
-
-    classname = args[0]
-    cl = db.getclass(classname)
-    props = {}
-    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
+    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   
+        '''
+        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(',')
+        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:
-                value = raw_input('%s (%s): '%(key.capitalize(), name))
-                if value:
-                    props[key] = value
-    else:
-        # use the args
-        for prop in args[1:]:
-            key, value = prop.split('=')
-            props[key] = value 
-
-    # convert types
-    for key in props.keys():
-        type =  properties[key]
-        if isinstance(type, hyperdb.Date):
-            props[key] = date.Date(value)
-        elif isinstance(type, hyperdb.Interval):
-            props[key] = date.Interval(value)
-        elif isinstance(type, hyperdb.Password):
-            props[key] = password.Password(value)
-        elif isinstance(type, hyperdb.Multilink):
-            props[key] = value.split(',')
-
-    if cl.getkey() and not props.has_key(cl.getkey()):
-        print "You must provide the '%s' property."%cl.getkey()
-    else:
-        print apply(cl.create, (), props)
-
-    return 0
-
-def do_list(db, args):
-    '''Usage: list classname [property]
-    List the instances of a class.
-
-    Lists all instances of the given class along. 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.
-    '''
-    classname = args[0]
-    cl = db.getclass(classname)
-    if len(args) > 1:
-        key = args[1]
-    else:
-        key = cl.labelprop()
-    # TODO: handle the -c option
-    for nodeid in cl.list():
-        value = cl.get(nodeid, key)
-        print "%4s: %s"%(nodeid, value)
-    return 0
-
-def do_history(db, args):
-    '''Usage: history designator
-    Show the history entries of a designator.
-
-    Lists the journal entries for the node identified by the designator.
-    '''
-    classname, nodeid = roundupdb.splitDesignator(args[0])
-    # TODO: handle the -c option
-    print db.getclass(classname).history(nodeid)
-    return 0
+                props.append((spec, len(spec)))
 
-def do_retire(db, args):
-    '''Usage: retire designator[,designator]*
-    Retire the node specified by designator.
+        # now display the heading
+        print ' '.join([string.capitalize(name) for name, width in props])
 
-    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.
-    '''
-    designators = string.split(args[0], ',')
-    for designator in designators:
-        classname, nodeid = roundupdb.splitDesignator(designator)
-        db.getclass(classname).retire(nodeid)
-    return 0
-
-def do_export(db, args):
-    '''Usage: export class[,class] destination_dir
-    Export the database to CSV files by class in the given directory.
-
-    This action exports the current data from the database into
-    comma-separated files that are placed in the nominated destination
-    directory. The journals are not exported.
-    '''
-    if len(args) < 2:
-        print do_export.__doc__
-        return 1
-    classes = string.split(args[0], ',')
-    dir = args[1]
-
-    # use the csv parser if we can - it's faster
-    if csv is not None:
-        p = csv.parser()
-
-    # do all the classes specified
-    for classname in classes:
-        cl = db.getclass(classname)
-        f = open(os.path.join(dir, classname+'.csv'), 'w')
-        f.write(string.join(cl.properties.keys(), ',') + '\n')
-
-        # all nodes for this class
+        # and the table data
         for nodeid in cl.list():
-            if csv is not None:
-               s = p.join(map(str, cl.getnode(nodeid).values(protected=0)))
-               f.write(s + '\n')
-            else:
-               l = []
-               # escape the individual entries to they're valid CSV
-               for entry in map(str, cl.getnode(nodeid).values(protected=0)):
-                  if '"' in entry:
-                      entry = '""'.join(entry.split('"'))
-                  if ',' in entry:
-                      entry = '"%s"'%entry
-                  l.append(entry)
-               f.write(','.join(l) + '\n')
-    return 0
-
-def do_import(db, args):
-    '''Usage: import class file
-    Import the contents of the CSV file as new nodes for the given class.
-
-    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:
-        print do_export.__doc__
-        return 1
-    if csv is None:
-        print 'Sorry, you need the csv module to use this function.'
-        print 'Get it from: http://www.object-craft.com.au/projects/csv/'
-        return 1
-
-    from roundup import hyperdb
-
-    # ensure that the properties and the CSV file headings match
-    cl = db.getclass(args[0])
-    f = open(args[1])
-    p = csv.parser()
-    file_props = p.parse(f.readline())
-    props = cl.properties.keys()
-    m = file_props[:]
-    m.sort()
-    props.sort()
-    if m != props:
-        print do_export.__doc__
-        print "\n\nFile doesn't define the same properties"
-        return 1
-
-    # 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:
-            value = l[i]
-            key = file_props[i]
-            type = cl.properties[key]
-            if isinstance(type, hyperdb.Date):
-                value = date.Date(value)
-            elif isinstance(type, hyperdb.Interval):
-                value = date.Interval(value)
-            elif isinstance(type, hyperdb.Password):
-                pwd = password.Password()
-                pwd.unpack(value)
-                value = pwd
-            elif isinstance(type, hyperdb.Multilink):
-                value = value.split(',')
-            d[key] = value
-
-        # and create the new node
-        apply(cl.create, (), d)
-    return 0
-
-def do_freshen(db, args):
-    '''Usage: freshen
-    Freshen an existing instance.  **DO NOT USE**
-
-    This currently kills databases!!!!
-
-    This action should generally not be used. It reads in an instance
-    database and writes it again. In the future, is may also update
-    instance code to account for changes in templates. It's probably wise
-    not to use it anyway. Until we're sure it won't break things...
-    '''
-#    for classname, cl in db.classes.items():
-#        properties = cl.properties.items()
-#        for nodeid in cl.list():
-#            node = {}
-#            for name, type in properties:
-# isinstance(               if type, hyperdb.Multilink):
-#                    node[name] = cl.get(nodeid, name, [])
-#                else:
-#                    node[name] = cl.get(nodeid, name, None)
-#                db.setnode(classname, nodeid, node)
-    return 1
-
-def figureCommands():
-    d = {}
-    for k, v in globals().items():
-        if k[:3] == 'do_':
-            d[k[3:]] = v
-    return d
-
-def printInitOptions():
-    import roundup.templates
-    templates = roundup.templates.listTemplates()
-    print 'Templates:', ', '.join(templates)
-    import roundup.backends
-    backends = roundup.backends.__all__
-    print 'Back ends:', ', '.join(backends)
-
-def main():
-    opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
-
-    # handle command-line args
-    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]
-    comma_sep = 0
-    for opt, arg in opts:
-        if opt == '-h':
-            usage()
-            return 0
-        if opt == '-i':
-            instance_home = arg
-        if opt == '-c':
-            comma_sep = 1
-
-    # figure the command
-    if not args:
-        usage('No command specified')
-        return 1
-    command = args[0]
-
-    # handle help now
-    if command == 'help':
-        if len(args)>1:
-            command = figureCommands().get(args[1], None)
-            if not command:
-                usage('no such command "%s"'%args[1])
-                return 1
-            print command.__doc__
-            if args[1] == 'init':
-                printInitOptions()
-            return 0
-        usage()
+            l = []
+            for name, width in props:
+                if name != 'id':
+                    try:
+                        value = str(cl.get(nodeid, name))
+                    except KeyError:
+                        raise UsageError, '%s has no property "%s"'%(classname,
+                            name)
+                else:
+                    value = str(nodeid)
+                f = '%%-%ds'%width
+                l.append(f%value[:width])
+            print ' '.join(l)
         return 0
-    if command == 'morehelp':
-        moreusage()
-        return 0
-
-    # make sure we have an instance_home
-    while not instance_home:
-        instance_home = raw_input('Enter instance home: ').strip()
 
-    # before we open the db, we may be doing an init
-    if command == 'init':
-        return do_init(instance_home, args)
+    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.
+        '''
+        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
 
-    function = figureCommands().get(command, None)
+    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.
+        '''
+        designators = string.split(args[0], ',')
+        for designator in designators:
+            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
 
-    # not a valid command
-    if function is None:
-        usage('Unknown command "%s"'%command)
-        return 1
+    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:
+            print do_export.__doc__
+            return 1
+        classes = string.split(args[0], ',')
+        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(string.join(cl.properties.keys(), ':') + '\n')
+
+            # all nodes for this class
+            properties = cl.properties.items()
+            for nodeid in cl.list():
+                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
 
-    # get the instance
-    instance = roundup.instance.open(instance_home)
-    db = instance.open('admin')
+    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
 
-    if len(args) < 2:
-        print function.__doc__
-        return 1
+    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
 
-    # do the command
-    try:
-        return function(db, args[1:])
-    finally:
-        db.close()
+        # 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:
+            print "Couldn't open instance: %s"%message
+            return 1
+        self.db = instance.open('admin')
+
+        if len(args) < 2:
+            print function.__doc__
+            return 1
+
+        # 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"
 
-    return 1
+        while 1:
+            try:
+                command = raw_input('roundup> ')
+            except EOFError:
+                print '.. exit'
+                return 0
+            args = ws_re.split(command)
+            if not args: continue
+            if args[0] in ('quit', 'exit'): return 0
+            self.run_command(args)
+
+    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.close()
+        return ret
 
 
 if __name__ == '__main__':
-    sys.exit(main())
+    tool = AdminTool()
+    sys.exit(tool.main())
 
 #
 # $Log: not supported by cvs2svn $
+# 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.