Code

Add new config-option 'password_pbkdf2_default_rounds' in 'main' section
[roundup.git] / roundup / admin.py
index 88cbace2c8e6e3bb253a4d1ec896c38eadd70d07..1a4e241df277116f00424244eefa5658264f3a4c 100644 (file)
 # 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: admin.py,v 1.35 2002-10-03 06:56:28 richard Exp $
+#
+
+"""Administration commands for maintaining Roundup trackers.
+"""
+__docformat__ = 'restructuredtext'
 
-'''Administration commands for maintaining Roundup trackers.
-'''
+import csv, getopt, getpass, os, re, shutil, sys, UserDict, operator
 
-import sys, os, getpass, getopt, re, UserDict, shlex, shutil
-try:
-    import csv
-except ImportError:
-    csv = None
 from roundup import date, hyperdb, roundupdb, init, password, token
 from roundup import __version__ as roundup_version
 import roundup.instance
+from roundup.configuration import CoreConfig
 from roundup.i18n import _
+from roundup.exceptions import UsageError
 
 class CommandDict(UserDict.UserDict):
-    '''Simple dictionary that lets us do lookups using partial keys.
+    """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):
+        if key in self.data:
             return [(key, self.data[key])]
-        keylist = self.data.keys()
-        keylist.sort()
+        keylist = sorted(self.data)
         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
+            raise KeyError(key)
         return l
 
-class UsageError(ValueError):
-    pass
-
 class AdminTool:
-    ''' A collection of methods used in maintaining Roundup trackers.
+    """ A collection of methods used in maintaining Roundup trackers.
 
         Typically these methods are accessed through the roundup-admin
         script. The main() method provided on this class gives the main
@@ -64,42 +59,43 @@ class AdminTool:
         given in the method docstring.
 
         Additional help may be supplied by help_*() methods.
-    '''
+    """
     def __init__(self):
         self.commands = CommandDict()
-        for k in AdminTool.__dict__.keys():
+        for k in AdminTool.__dict__:
             if k[:3] == 'do_':
                 self.commands[k[3:]] = getattr(self, k)
         self.help = {}
-        for k in AdminTool.__dict__.keys():
+        for k in AdminTool.__dict__:
             if k[:5] == 'help_':
                 self.help[k[5:]] = getattr(self, k)
         self.tracker_home = ''
         self.db = None
+        self.db_uncommitted = False
 
     def get_class(self, classname):
-        '''Get the class - raise an exception if it doesn't exist.
-        '''
+        """Get the class - raise an exception if it doesn't exist.
+        """
         try:
             return self.db.getclass(classname)
         except KeyError:
-            raise UsageError, _('no such class "%(classname)s"')%locals()
+            raise UsageError(_('no such class "%(classname)s"')%locals())
 
     def props_from_args(self, args):
-        ''' Produce a dictionary of prop: value from the args list.
+        """ Produce a dictionary of prop: value from the args list.
 
             The args list is specified as ``prop=value prop=value ...``.
-        '''
+        """
         props = {}
         for arg in args:
             if arg.find('=') == -1:
-                raise UsageError_('argument "%(arg)s" not propname=value'
-                    )%locals()
-            try:
-                key, value = arg.split('=')
-            except ValueError:
-                raise UsageError, _('argument "%(arg)s" not propname=value'
-                    )%locals()
+                raise UsageError(_('argument "%(arg)s" not propname=value'
+                    )%locals())
+            l = arg.split('=')
+            if len(l) < 2:
+                raise UsageError(_('argument "%(arg)s" not propname=value'
+                    )%locals())
+            key, value = l[0], '='.join(l[1:])
             if value:
                 props[key] = value
             else:
@@ -107,54 +103,62 @@ class AdminTool:
         return props
 
     def usage(self, message=''):
-        ''' Display a simple usage message.
-        '''
+        """ Display a simple usage message.
+        """
         if message:
-            message = _('Problem: %(message)s)\n\n')%locals()
-        print _('''%(message)sUsage: roundup-admin [options] <command> <arguments>
+            message = _('Problem: %(message)s\n\n')%locals()
+        print _("""%(message)sUsage: roundup-admin [options] [<command> <arguments>]
 
 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
+ -d                -- print full designators not just class id numbers
+ -c                -- when outputting lists of data, comma-separate them.
+                      Same as '-S ","'.
+ -S <string>       -- when outputting lists of data, string-separate them
+ -s                -- when outputting lists of data, space-separate them.
+                      Same as '-S " "'.
+ -V                -- be verbose when importing
+ -v                -- report Roundup and Python versions (and quit)
+
+ Only one of -s, -c or -S can be specified.
 
 Help:
  roundup-admin -h
  roundup-admin help                       -- this help
  roundup-admin help <command>             -- command-specific help
  roundup-admin help all                   -- all available help
-''')%locals()
+""")%locals()
         self.help_commands()
 
     def help_commands(self):
-        ''' List the commands available with their precis help.
-        '''
+        """List the commands available with their help summary.
+        """
         print _('Commands:'),
         commands = ['']
-        for command in self.commands.values():
-            h = command.__doc__.split('\n')[0]
+        for command in self.commands.itervalues():
+            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.'))
+        commands.append(_(
+"""Commands may be abbreviated as long as the abbreviation
+matches only one command, e.g. l == li == lis == list."""))
         print '\n'.join(commands)
         print
 
     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
-        ''' Produce an HTML command list.
-        '''
-        commands = self.commands.values()
-        def sortfun(a, b):
-            return cmp(a.__name__, b.__name__)
-        commands.sort(sortfun)
+        """ Produce an HTML command list.
+        """
+        commands = sorted(self.commands.itervalues(),
+            operator.attrgetter('__name__'))
         for command in commands:
-            h = command.__doc__.split('\n')
+            h = _(command.__doc__).split('\n')
             name = command.__name__[3:]
             usage = h[0]
-            print _('''
+            print """
 <tr><td valign=top><strong>%(name)s</strong></td>
     <td><tt>%(usage)s</tt><p>
-<pre>''')%locals()
+<pre>""" % locals()
             indent = indent_re.match(h[3])
             if indent: indent = len(indent.group(1))
             for line in h[3:]:
@@ -162,57 +166,58 @@ Help:
                     print line[indent:]
                 else:
                     print line
-            print _('</pre></td></tr>\n')
+            print '</pre></td></tr>\n'
 
     def help_all(self):
-        print _('''
-All commands (except help) require a tracker specifier. This is just the path
-to the roundup tracker you're working with. A roundup tracker 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 TRACKER_HOME or on the command
-line as "-i tracker".
+        print _("""
+All commands (except help) require a tracker specifier. This is just
+the path to the roundup tracker you're working with. A roundup tracker
+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 TRACKER_HOME
+or on the command line as "-i tracker".
 
 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
 
 Property values are represented as strings in command arguments and in the
 printed results:
  . Strings are, well, strings.
- . Date values are printed in the full date format in the local time zone, and
-   accepted in the full format or any of the partial formats explained below.
+ . Date values are printed in the full date format in the local time zone,
+   and accepted in the full format or any of the partial formats explained
+   below.
  . Link values are printed as node designators. When given as an argument,
    node designators and key strings are both accepted.
- . Multilink values are printed as lists of node designators joined by commas.
-   When given as an argument, node designators and key strings are both
-   accepted; an empty string, a single node, or a list of nodes joined by
-   commas is accepted.
+ . Multilink values are printed as lists of node designators joined
+   by commas.  When given as an argument, node designators and key
+   strings are both accepted; an empty string, a single node, or a list
+   of nodes joined by commas is accepted.
 
 When property values must contain spaces, just surround the value with
 quotes, either ' or ". A single space may also be backslash-quoted. If a
-valuu must contain a quote character, it must be backslash-quoted or inside
+value must contain a quote character, it must be backslash-quoted or inside
 quotes. Examples:
            hello world      (2 tokens: hello, world)
            "hello world"    (1 token: hello world)
            "Roch'e" Compaan (2 tokens: Roch'e Compaan)
-           Roch\'e Compaan  (2 tokens: Roch'e Compaan)
+           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)
            address="1 2 3"  (1 token: address=1 2 3)
-           \\               (1 token: \)
-           \n\r\t           (1 token: a newline, carriage-return and tab)
+           \\\\               (1 token: \\)
+           \\n\\r\\t           (1 token: a newline, carriage-return and tab)
 
 When multiple nodes are specified to the roundup get or roundup set
 commands, the specified properties are retrieved or set on all the listed
-nodes. 
+nodes.
 
 When multiple results are returned by the roundup get or roundup find
 commands, they are printed one per line (default) or joined by commas (with
-the -c) option. 
+the -c) option.
 
 Where the command changes data, a login name/password is required. The
 login may be specified as either "name" or "name:password".
  . ROUNDUP_LOGIN environment variable
  . the -u command-line option
 If either the name or password is not supplied, they are obtained from the
-command-line. 
+command-line.
 
 Date format examples:
   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
@@ -225,29 +230,29 @@ Date format examples:
   "." means "right now"
 
 Command help:
-''')
+""")
         for name, command in self.commands.items():
             print _('%s:')%name
-            print _('   '), command.__doc__
+            print '   ', _(command.__doc__)
 
     def do_help(self, args, nl_re=re.compile('[\r\n]'),
             indent_re=re.compile(r'^(\s+)\S+')):
-        '''Usage: help topic
+        ''"""Usage: help topic
         Give help about topic.
 
         commands  -- list commands
         <command> -- help specific to a command
         initopts  -- init command options
         all       -- all available help
-        '''
+        """
         if len(args)>0:
             topic = args[0]
         else:
             topic = 'help'
+
 
         # try help_ methods
-        if self.help.has_key(topic):
+        if topic in self.help:
             self.help[topic]()
             return 0
 
@@ -260,7 +265,7 @@ Command help:
 
         # display the help for each match, removing the docsring indent
         for name, help in l:
-            lines = nl_re.split(help.__doc__)
+            lines = nl_re.split(_(help.__doc__))
             print lines[0]
             indent = indent_re.match(lines[1])
             if indent: indent = len(indent.group(1))
@@ -271,22 +276,87 @@ Command help:
                     print line
         return 0
 
+    def listTemplates(self):
+        """ List all the available templates.
+
+        Look in the following places, where the later rules take precedence:
+
+         1. <roundup.admin.__file__>/../../share/roundup/templates/*
+            this is where they will be if we installed an egg via easy_install
+         2. <prefix>/share/roundup/templates/*
+            this should be the standard place to find them when Roundup is
+            installed
+         3. <roundup.admin.__file__>/../templates/*
+            this will be used if Roundup's run in the distro (aka. source)
+            directory
+         4. <current working dir>/*
+            this is for when someone unpacks a 3rd-party template
+         5. <current working dir>
+            this is for someone who "cd"s to the 3rd-party template dir
+        """
+        # OK, try <prefix>/share/roundup/templates
+        #     and <egg-directory>/share/roundup/templates
+        # -- this module (roundup.admin) will be installed in something
+        # like:
+        #    /usr/lib/python2.5/site-packages/roundup/admin.py  (5 dirs up)
+        #    c:\python25\lib\site-packages\roundup\admin.py     (4 dirs up)
+        #    /usr/lib/python2.5/site-packages/roundup-1.3.3-py2.5-egg/roundup/admin.py
+        #    (2 dirs up)
+        #
+        # we're interested in where the directory containing "share" is
+        templates = {}
+        for N in 2, 4, 5:
+            path = __file__
+            # move up N elements in the path
+            for i in range(N):
+                path = os.path.dirname(path)
+            tdir = os.path.join(path, 'share', 'roundup', 'templates')
+            if os.path.isdir(tdir):
+                templates = init.listTemplates(tdir)
+                break
+
+        # OK, now try as if we're in the roundup source distribution
+        # directory, so this module will be in .../roundup-*/roundup/admin.py
+        # and we're interested in the .../roundup-*/ part.
+        path = __file__
+        for i in range(2):
+            path = os.path.dirname(path)
+        tdir = os.path.join(path, 'templates')
+        if os.path.isdir(tdir):
+            templates.update(init.listTemplates(tdir))
+
+        # Try subdirs of the current dir
+        templates.update(init.listTemplates(os.getcwd()))
+
+        # Finally, try the current directory as a template
+        template = init.loadTemplateInfo(os.getcwd())
+        if template:
+            templates[template['name']] = template
+
+        return templates
+
     def help_initopts(self):
-        import roundup.templates
-        templates = roundup.templates.listTemplates()
+        templates = self.listTemplates()
         print _('Templates:'), ', '.join(templates)
         import roundup.backends
-        backends = roundup.backends.__all__
+        backends = roundup.backends.list_backends()
         print _('Back ends:'), ', '.join(backends)
 
     def do_install(self, tracker_home, args):
-        '''Usage: install [template [backend [admin password]]]
+        ''"""Usage: install [template [backend [key=val[,key=val]]]]
         Install a new Roundup tracker.
 
-        The command will prompt for the tracker home directory (if not supplied
-        through TRACKER_HOME or the -i option). The template, backend and admin
-        password may be specified on the command-line as arguments, in that
-        order.
+        The command will prompt for the tracker home directory
+        (if not supplied through TRACKER_HOME or the -i option).
+        The template and backend may be specified on the command-line
+        as arguments, in that order.
+
+        Command line arguments following the backend allows you to
+        pass initial values for config options.  For example, passing
+        "web_http_auth=no,rdbms_user=dinsdale" will override defaults
+        for options http_auth in section [web] and user in section [rdbms].
+        Please be careful to not use spaces in this argument! (Enclose
+        whole argument in quotes if you need spaces in option value).
 
         The initialise command must be called after this command in order
         to initialise the tracker's database. You may edit the tracker's
@@ -294,19 +364,33 @@ Command help:
         the tracker's dbinit.py module init() function.
 
         See also initopts help.
-        '''
+        """
         if len(args) < 1:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
 
         # make sure the tracker home can be created
+        tracker_home = os.path.abspath(tracker_home)
         parent = os.path.split(tracker_home)[0]
         if not os.path.exists(parent):
-            raise UsageError, _('Instance home parent directory "%(parent)s"'
-                ' does not exist')%locals()
+            raise UsageError(_('Instance home parent directory "%(parent)s"'
+                ' does not exist')%locals())
+
+        config_ini_file = os.path.join(tracker_home, CoreConfig.INI_FILE)
+        # check for both old- and new-style configs
+        if list(filter(os.path.exists, [config_ini_file,
+                os.path.join(tracker_home, 'config.py')])):
+            ok = raw_input(_(
+"""WARNING: There appears to be a tracker in "%(tracker_home)s"!
+If you re-install it, you will lose all the data!
+Erase it? Y/N: """) % locals())
+            if ok.strip().lower() != 'y':
+                return 0
+
+            # clear it out so the install isn't confused
+            shutil.rmtree(tracker_home)
 
         # select template
-        import roundup.templates
-        templates = roundup.templates.listTemplates()
+        templates = self.listTemplates()
         template = len(args) > 1 and args[1] or ''
         if template not in templates:
             print _('Templates:'), ', '.join(templates)
@@ -317,7 +401,7 @@ Command help:
 
         # select hyperdb backend
         import roundup.backends
-        backends = roundup.backends.__all__
+        backends = roundup.backends.list_backends()
         backend = len(args) > 2 and args[2] or ''
         if backend not in backends:
             print _('Back ends:'), ', '.join(backends)
@@ -327,34 +411,70 @@ Command help:
                 backend = 'anydbm'
         # XXX perform a unit test based on the user's selections
 
+        # Process configuration file definitions
+        if len(args) > 3:
+            try:
+                defns = dict([item.split("=") for item in args[3].split(",")])
+            except:
+                print _('Error in configuration settings: "%s"') % args[3]
+                raise
+        else:
+            defns = {}
+
         # install!
-        init.install(tracker_home, template, backend)
+        init.install(tracker_home, templates[template]['path'], settings=defns)
+        init.write_select_db(tracker_home, backend)
 
-        print _('''
+        print _("""
+---------------------------------------------------------------------------
  You should now edit the tracker configuration file:
-   %(config_file)s
- ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
- ADMIN_EMAIL.
-
- If you wish to modify the default schema, you should also edit the database
- initialisation file:
+   %(config_file)s""") % {"config_file": config_ini_file}
+
+        # find list of options that need manual adjustments
+        # XXX config._get_unset_options() is marked as private
+        #   (leading underscore).  make it public or don't care?
+        need_set = CoreConfig(tracker_home)._get_unset_options()
+        if need_set:
+            print _(" ... at a minimum, you must set following options:")
+            for section in need_set:
+                print "   [%s]: %s" % (section, ", ".join(need_set[section]))
+
+        # note about schema modifications
+        print _("""
+ If you wish to modify the database schema,
+ you should also edit the schema file:
    %(database_config_file)s
+ You may also change the database initialisation file:
+   %(database_init_file)s
  ... see the documentation on customizing for more information.
-''')%{
-    'config_file': os.path.join(tracker_home, 'config.py'),
-    'database_config_file': os.path.join(tracker_home, 'dbinit.py')
+
+ You MUST run the "roundup-admin initialise" command once you've performed
+ the above steps.
+---------------------------------------------------------------------------
+""") % {
+    'database_config_file': os.path.join(tracker_home, 'schema.py'),
+    'database_init_file': os.path.join(tracker_home, 'initial_data.py'),
 }
         return 0
 
+    def do_genconfig(self, args):
+        ''"""Usage: genconfig <filename>
+        Generate a new tracker config file (ini style) with default values
+        in <filename>.
+        """
+        if len(args) < 1:
+            raise UsageError(_('Not enough arguments supplied'))
+        config = CoreConfig()
+        config.save(args[0])
 
     def do_initialise(self, tracker_home, args):
-        '''Usage: initialise [adminpw]
+        ''"""Usage: initialise [adminpw]
         Initialise a new Roundup tracker.
 
         The administrator details will be set at this step.
 
         Execute the tracker's initialisation function dbinit.init()
-        '''
+        """
         # password
         if len(args) > 1:
             adminpw = args[1]
@@ -367,35 +487,47 @@ Command help:
 
         # make sure the tracker home is installed
         if not os.path.exists(tracker_home):
-            raise UsageError, _('Instance home does not exist')%locals()
-        if not os.path.exists(os.path.join(tracker_home, 'html')):
-            raise UsageError, _('Instance has not been installed')%locals()
+            raise UsageError(_('Instance home does not exist')%locals())
+        try:
+            tracker = roundup.instance.open(tracker_home)
+        except roundup.instance.TrackerError:
+            raise UsageError(_('Instance has not been installed')%locals())
 
         # is there already a database?
-        if os.path.exists(os.path.join(tracker_home, 'db')):
-            print _('WARNING: The database is already initialised!')
-            print _('If you re-initialise it, you will lose all the data!')
-            ok = raw_input(_('Erase it? Y/[N]: ')).strip()
-            if ok.lower() != 'y':
+        if tracker.exists():
+            ok = raw_input(_(
+"""WARNING: The database is already initialised!
+If you re-initialise it, you will lose all the data!
+Erase it? Y/N: """))
+            if ok.strip().lower() != 'y':
                 return 0
 
+            backend = tracker.get_backend_name()
+
             # nuke it
-            shutil.rmtree(os.path.join(tracker_home, 'db'))
+            tracker.nuke()
+
+            # re-write the backend select file
+            init.write_select_db(tracker_home, backend, tracker.config.DATABASE)
 
         # GO
-        init.initialise(tracker_home, adminpw)
+        tracker.init(password.Password(adminpw, config=tracker.config))
 
         return 0
 
 
     def do_get(self, args):
-        '''Usage: get property designator[,designator]*
+        ''"""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.
-        '''
+        A designator is a classname and a nodeid concatenated,
+        eg. bug1, user10, ...
+
+        Retrieves the property value of the nodes specified
+        by the designators.
+        """
         if len(args) < 2:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
         propname = args[0]
         designators = args[1].split(',')
         l = []
@@ -404,38 +536,85 @@ Command help:
             try:
                 classname, nodeid = hyperdb.splitDesignator(designator)
             except hyperdb.DesignatorError, message:
-                raise UsageError, message
+                raise UsageError(message)
 
             # get the class
             cl = self.get_class(classname)
             try:
-                if self.comma_sep:
-                    l.append(cl.get(nodeid, propname))
+                id=[]
+                if self.separator:
+                    if self.print_designator:
+                        # see if property is a link or multilink for
+                        # which getting a desginator make sense.
+                        # Algorithm: Get the properties of the
+                        #     current designator's class. (cl.getprops)
+                        # get the property object for the property the
+                        #     user requested (properties[propname])
+                        # verify its type (isinstance...)
+                        # raise error if not link/multilink
+                        # get class name for link/multilink property
+                        # do the get on the designators
+                        # append the new designators
+                        # print
+                        properties = cl.getprops()
+                        property = properties[propname]
+                        if not (isinstance(property, hyperdb.Multilink) or
+                          isinstance(property, hyperdb.Link)):
+                            raise UsageError(_('property %s is not of type'
+                                ' Multilink or Link so -d flag does not '
+                                'apply.')%propname)
+                        propclassname = self.db.getclass(property.classname).classname
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            l.append(propclassname + i)
+                    else:
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            l.append(i)
                 else:
-                    print cl.get(nodeid, propname)
+                    if self.print_designator:
+                        properties = cl.getprops()
+                        property = properties[propname]
+                        if not (isinstance(property, hyperdb.Multilink) or
+                          isinstance(property, hyperdb.Link)):
+                            raise UsageError(_('property %s is not of type'
+                                ' Multilink or Link so -d flag does not '
+                                'apply.')%propname)
+                        propclassname = self.db.getclass(property.classname).classname
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            print propclassname + i
+                    else:
+                        print cl.get(nodeid, propname)
             except IndexError:
-                raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+                raise UsageError(_('no such %(classname)s node '
+                    '"%(nodeid)s"')%locals())
             except KeyError:
-                raise UsageError, _('no such %(classname)s property '
-                    '"%(propname)s"')%locals()
-        if self.comma_sep:
-            print ','.join(l)
+                raise UsageError(_('no such %(classname)s property '
+                    '"%(propname)s"')%locals())
+        if self.separator:
+            print self.separator.join(l)
+
         return 0
 
 
-    def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
-        '''Usage: set [items] property=value property=value ...
+    def do_set(self, args):
+        ''"""Usage: set items property=value property=value ...
         Set the given properties of one or more items(s).
 
-        The items may be specified as a class or as a comma-separeted
+        The items are specified as a class or as a comma-separated
         list of item designators (ie "designator[,designator,...]").
 
+        A designator is a classname and a nodeid concatenated,
+        eg. bug1, user10, ...
+
         This command sets the properties to the values for all designators
-        given. If the value is missing (ie. "property=") then the property is
-        un-set.
-        '''
+        given. If the value is missing (ie. "property=") then the property
+        is un-set. If the property is a multilink, you specify the linked
+        ids for the multilink as comma-separated numbers (ie "1,2,3").
+        """
         if len(args) < 2:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
         from roundup import hyperdb
 
         designators = args[0].split(',')
@@ -451,7 +630,7 @@ Command help:
             try:
                 designators = [hyperdb.splitDesignator(x) for x in designators]
             except hyperdb.DesignatorError, message:
-                raise UsageError, message
+                raise UsageError(message)
 
         # get the props from the args
         props = self.props_from_args(args[1:])
@@ -462,60 +641,31 @@ Command help:
 
             properties = cl.getprops()
             for key, value in props.items():
-                proptype =  properties[key]
-                if isinstance(proptype, hyperdb.Multilink):
-                    if value is None:
-                        props[key] = []
-                    else:
-                        props[key] = value.split(',')
-                elif value is None:
-                    continue
-                elif isinstance(proptype, hyperdb.String):
-                    continue
-                elif isinstance(proptype, hyperdb.Password):
-                    m = pwre.match(value)
-                    if m:
-                        # password is being given to us encrypted
-                        p = password.Password()
-                        p.scheme = m.group(1)
-                        p.password = m.group(2)
-                        props[key] = p
-                    else:
-                        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.Boolean):
-                    props[key] = value.lower() in ('yes', 'true', 'on', '1')
-                elif isinstance(proptype, hyperdb.Number):
-                    props[key] = int(value)
+                try:
+                    props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
+                        key, value)
+                except hyperdb.HyperdbValueError, message:
+                    raise UsageError(message)
 
             # try the set
             try:
-                apply(cl.set, (itemid, ), props)
+                cl.set(itemid, **props)
             except (TypeError, IndexError, ValueError), message:
                 import traceback; traceback.print_exc()
-                raise UsageError, message
+                raise UsageError(message)
+        self.db_uncommitted = True
         return 0
 
     def do_find(self, args):
-        '''Usage: find classname propname=value ...
+        ''"""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.
-        '''
+        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')
+            raise UsageError(_('Not enough arguments supplied'))
         classname = args[0]
         # get the class
         cl = self.get_class(classname)
@@ -523,98 +673,109 @@ Command help:
         # handle the propname=value argument
         props = self.props_from_args(args[1:])
 
-        # if the value isn't a number, look up the linked class to get the
-        # number
-        for propname, value in props.items():
-            num_re = re.compile('^\d+$')
-            if not num_re.match(value):
-                # get the property
-                try:
-                    property = cl.properties[propname]
-                except KeyError:
-                    raise UsageError, _('%(classname)s has no property '
-                        '"%(propname)s"')%locals()
-
-                # 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:
-                    props[propname] = link_class.lookup(value)
-                except TypeError:
-                    raise UsageError, _('%(classname)s has no key property"')%{
-                        'classname': link_class.classname}
+        # convert the user-input value to a value used for find()
+        for propname, value in props.iteritems():
+            if ',' in value:
+                values = value.split(',')
+            else:
+                values = [value]
+            d = props[propname] = {}
+            for value in values:
+                value = hyperdb.rawToHyperdb(self.db, cl, None, propname, value)
+                if isinstance(value, list):
+                    for entry in value:
+                        d[entry] = 1
+                else:
+                    d[value] = 1
 
-        # now do the find 
+        # now do the find
         try:
-            if self.comma_sep:
-                print ','.join(apply(cl.find, (), props))
+            id = []
+            designator = []
+            if self.separator:
+                if self.print_designator:
+                    id = cl.find(**props)
+                    for i in id:
+                        designator.append(classname + i)
+                    print self.separator.join(designator)
+                else:
+                    print self.separator.join(cl.find(**props))
+
             else:
-                print apply(cl.find, (), props)
+                if self.print_designator:
+                    id = cl.find(**props)
+                    for i in id:
+                        designator.append(classname + i)
+                    print designator
+                else:
+                    print cl.find(**props)
         except KeyError:
-            raise UsageError_('%(classname)s has no property '
-                '"%(propname)s"')%locals()
+            raise UsageError(_('%(classname)s has no property '
+                '"%(propname)s"')%locals())
         except (ValueError, TypeError), message:
-            raise UsageError, message
+            raise UsageError(message)
         return 0
 
     def do_specification(self, args):
-        '''Usage: specification classname
+        ''"""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')
+            raise UsageError(_('Not enough arguments supplied'))
         classname = args[0]
         # get the class
         cl = self.get_class(classname)
 
         # get the key property
         keyprop = cl.getkey()
-        for key, value in cl.properties.items():
+        for key in cl.properties:
+            value = cl.properties[key]
             if keyprop == key:
                 print _('%(key)s: %(value)s (key property)')%locals()
             else:
                 print _('%(key)s: %(value)s')%locals()
 
     def do_display(self, args):
-        '''Usage: display designator
-        Show the property values for the given node.
+        ''"""Usage: display designator[,designator]*
+        Show the property values for the given node(s).
+
+        A designator is a classname and a nodeid concatenated,
+        eg. bug1, user10, ...
 
         This lists the properties and their associated values for the given
         node.
-        '''
+        """
         if len(args) < 1:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
 
         # decode the node designator
-        try:
-            classname, nodeid = hyperdb.splitDesignator(args[0])
-        except hyperdb.DesignatorError, message:
-            raise UsageError, message
+        for designator in args[0].split(','):
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError(message)
 
-        # get the class
-        cl = self.get_class(classname)
+            # get the class
+            cl = self.get_class(classname)
 
-        # display the values
-        for key in cl.properties.keys():
-            value = cl.get(nodeid, key)
-            print _('%(key)s: %(value)s')%locals()
+            # display the values
+            keys = sorted(cl.properties)
+            for key in keys:
+                value = cl.get(nodeid, key)
+                print _('%(key)s: %(value)s')%locals()
 
-    def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
-        '''Usage: create classname property=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.
-        '''
+        """
         if len(args) < 1:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
         from roundup import hyperdb
 
         classname = args[0]
@@ -627,8 +788,9 @@ Command help:
         properties = cl.getprops(protected = 0)
         if len(args) == 1:
             # ask for the properties
-            for key, value in properties.items():
+            for key in properties:
                 if key == 'id': continue
+                value = properties[key]
                 name = value.__class__.__name__
                 if isinstance(value , hyperdb.Password):
                     again = None
@@ -649,67 +811,46 @@ Command help:
             props = self.props_from_args(args[1:])
 
         # convert types
-        for propname, value in props.items():
-            # get the property
+        for propname in props:
             try:
-                proptype = properties[propname]
-            except KeyError:
-                raise UsageError, _('%(classname)s has no property '
-                    '"%(propname)s"')%locals()
-
-            if isinstance(proptype, hyperdb.Date):
-                try:
-                    props[propname] = date.Date(value)
-                except ValueError, message:
-                    raise UsageError, _('"%(value)s": %(message)s')%locals()
-            elif isinstance(proptype, hyperdb.Interval):
-                try:
-                    props[propname] = date.Interval(value)
-                except ValueError, message:
-                    raise UsageError, _('"%(value)s": %(message)s')%locals()
-            elif isinstance(proptype, hyperdb.Password):
-                m = pwre.match(value)
-                if m:
-                    # password is being given to us encrypted
-                    p = password.Password()
-                    p.scheme = m.group(1)
-                    p.password = m.group(2)
-                    props[propname] = p
-                else:
-                    props[propname] = password.Password(value)
-            elif isinstance(proptype, hyperdb.Multilink):
-                props[propname] = value.split(',')
-            elif isinstance(proptype, hyperdb.Boolean):
-                props[propname] = value.lower() in ('yes', 'true', 'on', '1')
-            elif isinstance(proptype, hyperdb.Number):
-                props[propname] = int(value)
+                props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
+                    propname, props[propname])
+            except hyperdb.HyperdbValueError, message:
+                raise UsageError(message)
 
         # check for the key property
         propname = cl.getkey()
-        if propname and not props.has_key(propname):
-            raise UsageError_('you must provide the "%(propname)s" '
-                'property.')%locals()
+        if propname and propname not in props:
+            raise UsageError(_('you must provide the "%(propname)s" '
+                'property.')%locals())
 
         # do the actual create
         try:
-            print apply(cl.create, (), props)
+            print cl.create(**props)
         except (TypeError, IndexError, ValueError), message:
-            raise UsageError, message
+            raise UsageError(message)
+        self.db_uncommitted = True
         return 0
 
     def do_list(self, args):
-        '''Usage: list classname [property]
+        ''"""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.
-        '''
+        specified, the  "label" property is used. The label property is
+        tried in order: the key, "name", "title" and then the first
+        property, alphabetically.
+
+        With -c, -S or -s print a list of item id's if no property
+        specified.  If property specified, print list of that property
+        for every class instance.
+        """
+        if len(args) > 2:
+            raise UsageError(_('Too many arguments supplied'))
         if len(args) < 1:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
         classname = args[0]
-
+        
         # get the class
         cl = self.get_class(classname)
 
@@ -719,35 +860,62 @@ Command help:
         else:
             propname = cl.labelprop()
 
-        if self.comma_sep:
-            print ','.join(cl.list())
+        if self.separator:
+            if len(args) == 2:
+                # create a list of propnames since user specified propname
+                proplist=[]
+                for nodeid in cl.list():
+                    try:
+                        proplist.append(cl.get(nodeid, propname))
+                    except KeyError:
+                        raise UsageError(_('%(classname)s has no property '
+                            '"%(propname)s"')%locals())
+                print self.separator.join(proplist)
+            else:
+                # create a list of index id's since user didn't specify
+                # otherwise
+                print self.separator.join(cl.list())
         else:
             for nodeid in cl.list():
                 try:
                     value = cl.get(nodeid, propname)
                 except KeyError:
-                    raise UsageError_('%(classname)s has no property '
-                        '"%(propname)s"')%locals()
+                    raise UsageError(_('%(classname)s has no property '
+                        '"%(propname)s"')%locals())
                 print _('%(nodeid)4s: %(value)s')%locals()
         return 0
 
     def do_table(self, args):
-        '''Usage: table classname [property[,property]*]
+        ''"""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::
+        specified, all properties are displayed. By default, the column
+        widths are the width of the largest value. 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   
-        '''
+          1  fatal-bug
+          2  bug
+          3  usability
+          4  feature
+
+        Also to make the width of the column the width of the label,
+        leave a trailing : without a width on the property. For example::
+
+          roundup> table priority id,name:
+          Id Name
+          1  fata
+          2  bug
+          3  usab
+          4  feat
+
+        will result in a the 4 character wide "Name" column.
+        """
         if len(args) < 1:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
         classname = args[0]
 
         # get the class
@@ -762,23 +930,33 @@ Command help:
                     try:
                         propname, width = spec.split(':')
                     except (ValueError, TypeError):
-                        raise UsageError, _('"%(spec)s" not name:width')%locals()
+                        raise UsageError(_('"%(spec)s" not '
+                            'name:width')%locals())
                 else:
                     propname = spec
-                if not all_props.has_key(propname):
-                    raise UsageError_('%(classname)s has no property '
-                        '"%(propname)s"')%locals()
+                if propname not in all_props:
+                    raise UsageError(_('%(classname)s has no property '
+                        '"%(propname)s"')%locals())
         else:
-            prop_names = cl.getprops().keys()
+            prop_names = cl.getprops()
 
         # now figure column widths
         props = []
         for spec in prop_names:
             if ':' in spec:
                 name, width = spec.split(':')
-                props.append((name, int(width)))
+                if width == '':
+                    props.append((name, len(spec)))
+                else:
+                    props.append((name, int(width)))
             else:
-                props.append((spec, len(spec)))
+               # this is going to be slow
+               maxlen = len(spec)
+               for nodeid in cl.list():
+                   curlen = len(str(cl.get(nodeid, spec)))
+                   if curlen > maxlen:
+                       maxlen = curlen
+               props.append((spec, maxlen))
 
         # now display the heading
         print ' '.join([name.capitalize().ljust(width) for name,width in props])
@@ -803,29 +981,33 @@ Command help:
         return 0
 
     def do_history(self, args):
-        '''Usage: history designator
+        ''"""Usage: history designator
         Show the history entries of a designator.
 
+        A designator is a classname and a nodeid concatenated,
+        eg. bug1, user10, ...
+
         Lists the journal entries for the node identified by the designator.
-        '''
+        """
         if len(args) < 1:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
         try:
             classname, nodeid = hyperdb.splitDesignator(args[0])
         except hyperdb.DesignatorError, message:
-            raise UsageError, message
+            raise UsageError(message)
 
         try:
             print self.db.getclass(classname).history(nodeid)
         except KeyError:
-            raise UsageError, _('no such class "%(classname)s"')%locals()
+            raise UsageError(_('no such class "%(classname)s"')%locals())
         except IndexError:
-            raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+            raise UsageError(_('no such %(classname)s node '
+                '"%(nodeid)s"')%locals())
         return 0
 
     def do_commit(self, args):
-        '''Usage: commit
-        Commit all changes made to the database.
+        ''"""Usage: commit
+        Commit changes made to the database during an interactive session.
 
         The changes made during an interactive session are not
         automatically written to the database - they must be committed
@@ -833,94 +1015,199 @@ Command help:
 
         One-off commands on the command-line are automatically committed if
         they are successful.
-        '''
+        """
         self.db.commit()
+        self.db_uncommitted = False
         return 0
 
     def do_rollback(self, args):
-        '''Usage: rollback
+        ''"""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()
+        self.db_uncommitted = False
         return 0
 
     def do_retire(self, args):
-        '''Usage: retire designator[,designator]*
+        ''"""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.
-        '''
+        A designator is a classname and a nodeid concatenated,
+        eg. bug1, user10, ...
+
+        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')
+            raise UsageError(_('Not enough arguments supplied'))
         designators = args[0].split(',')
         for designator in designators:
             try:
                 classname, nodeid = hyperdb.splitDesignator(designator)
             except hyperdb.DesignatorError, message:
-                raise UsageError, message
+                raise UsageError(message)
             try:
                 self.db.getclass(classname).retire(nodeid)
             except KeyError:
-                raise UsageError, _('no such class "%(classname)s"')%locals()
+                raise UsageError(_('no such class "%(classname)s"')%locals())
             except IndexError:
-                raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+                raise UsageError(_('no such %(classname)s node '
+                    '"%(nodeid)s"')%locals())
+        self.db_uncommitted = True
         return 0
 
-    def do_export(self, args):
-        '''Usage: export [class[,class]] export_dir
+    def do_restore(self, args):
+        ''"""Usage: restore designator[,designator]*
+        Restore the retired node specified by designator.
+
+        A designator is a classname and a nodeid concatenated,
+        eg. bug1, user10, ...
+
+        The given nodes will become available for users again.
+        """
+        if len(args) < 1:
+            raise UsageError(_('Not enough arguments supplied'))
+        designators = args[0].split(',')
+        for designator in designators:
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError(message)
+            try:
+                self.db.getclass(classname).restore(nodeid)
+            except KeyError:
+                raise UsageError(_('no such class "%(classname)s"')%locals())
+            except IndexError:
+                raise UsageError(_('no such %(classname)s node '
+                    '"%(nodeid)s"')%locals())
+        self.db_uncommitted = True
+        return 0
+
+    def do_export(self, args, export_files=True):
+        ''"""Usage: export [[-]class[,class]] export_dir
         Export the database to colon-separated-value files.
+        To exclude the files (e.g. for the msg or file class),
+        use the exporttables command.
+
+        Optionally limit the export to just the named classes
+        or exclude the named classes, if the 1st argument starts with '-'.
 
         This action exports the current data from the database into
         colon-separated-value files that are placed in the nominated
-        destination directory. The journals are not exported.
-        '''
-        # we need the CSV module
-        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/')
-
+        destination directory.
+        """
         # grab the directory to export to
         if len(args) < 1:
-            raise UsageError, _('Not enough arguments supplied')
+            raise UsageError(_('Not enough arguments supplied'))
+
         dir = args[-1]
 
         # get the list of classes to export
         if len(args) == 2:
-            classes = args[0].split(',')
+            if args[0].startswith('-'):
+                classes = [ c for c in self.db.classes
+                            if not c in args[0][1:].split(',') ]
+            else:
+                classes = args[0].split(',')
         else:
-            classes = self.db.classes.keys()
+            classes = self.db.classes
+
+        class colon_separated(csv.excel):
+            delimiter = ':'
+
+        # make sure target dir exists
+        if not os.path.exists(dir):
+            os.makedirs(dir)
 
-        # use the csv parser if we can - it's faster
-        p = csv.parser(field_sep=':')
+        # maximum csv field length exceeding configured size?
+        max_len = self.db.config.CSV_FIELD_SIZE
 
         # do all the classes specified
         for classname in classes:
             cl = self.get_class(classname)
-            f = open(os.path.join(dir, classname+'.csv'), 'w')
+
+            if not export_files and hasattr(cl, 'export_files'):
+                sys.stdout.write('Exporting %s WITHOUT the files\r\n'%
+                    classname)
+
+            f = open(os.path.join(dir, classname+'.csv'), 'wb')
+            writer = csv.writer(f, colon_separated)
+
             properties = cl.getprops()
-            propnames = properties.keys()
-            propnames.sort()
-            print >> f, p.join(propnames)
+            propnames = cl.export_propnames()
+            fields = propnames[:]
+            fields.append('is retired')
+            writer.writerow(fields)
 
             # all nodes for this class
-            for nodeid in cl.list():
-                print >>f, p.join(cl.export_list(propnames, nodeid))
+            for nodeid in cl.getnodeids():
+                if self.verbose:
+                    sys.stdout.write('\rExporting %s - %s'%(classname, nodeid))
+                    sys.stdout.flush()
+                node = cl.getnode(nodeid)
+                exp = cl.export_list(propnames, nodeid)
+                lensum = sum ([len (repr(node[p])) for p in propnames])
+                # for a safe upper bound of field length we add
+                # difference between CSV len and sum of all field lengths
+                d = sum ([len(x) for x in exp]) - lensum
+                assert (d > 0)
+                for p in propnames:
+                    ll = len(repr(node[p])) + d
+                    if ll > max_len:
+                        max_len = ll
+                writer.writerow(exp)
+                if export_files and hasattr(cl, 'export_files'):
+                    cl.export_files(dir, nodeid)
+
+            # close this file
+            f.close()
+
+            # export the journals
+            jf = open(os.path.join(dir, classname+'-journals.csv'), 'wb')
+            if self.verbose:
+                sys.stdout.write("\nExporting Journal for %s\n" % classname)
+                sys.stdout.flush()
+            journals = csv.writer(jf, colon_separated)
+            for row in cl.export_journals():
+                journals.writerow(row)
+            jf.close()
+        if max_len > self.db.config.CSV_FIELD_SIZE:
+            print >> sys.stderr, \
+                "Warning: config csv_field_size should be at least %s"%max_len
         return 0
 
+    def do_exporttables(self, args):
+        ''"""Usage: exporttables [[-]class[,class]] export_dir
+        Export the database to colon-separated-value files, excluding the
+        files below $TRACKER_HOME/db/files/ (which can be archived separately).
+        To include the files, use the export command.
+
+        Optionally limit the export to just the named classes
+        or exclude the named classes, if the 1st argument starts with '-'.
+
+        This action exports the current data from the database into
+        colon-separated-value files that are placed in the nominated
+        destination directory.
+        """
+        return self.do_export(args, export_files=False)
+
     def do_import(self, args):
-        '''Usage: import import_dir
-        Import a database from the directory containing CSV files, one per
-        class to import.
+        ''"""Usage: import import_dir
+        Import a database from the directory containing CSV files,
+        two per class to import.
+
+        The files used in the import are:
 
-        The files must define the same properties as the class (including having
-        a "header" line with those property names.)
+        <class>.csv
+          This must define the same properties as the class (including
+          having a "header" line with those property names.)
+        <class>-journals.csv
+          This defines the journals for the items being imported.
 
         The imported nodes will have the same nodeid as defined in the
         import file, thus replacing any existing content.
@@ -928,109 +1215,137 @@ Command help:
         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) < 1:
-            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/')
-
+            raise UsageError(_('Not enough arguments supplied'))
         from roundup import hyperdb
 
-        for file in os.listdir(args[0]):
-            f = open(os.path.join(args[0], file))
+        if hasattr (csv, 'field_size_limit'):
+            csv.field_size_limit(self.db.config.CSV_FIELD_SIZE)
 
-            # get the classname
-            classname = os.path.splitext(file)[0]
+        # directory to import from
+        dir = args[0]
+
+        class colon_separated(csv.excel):
+            delimiter = ':'
+
+        # import all the files
+        for file in os.listdir(dir):
+            classname, ext = os.path.splitext(file)
+            # we only care about CSV files
+            if ext != '.csv' or classname.endswith('-journals'):
+                continue
 
-            # ensure that the properties and the CSV file headings match
             cl = self.get_class(classname)
-            p = csv.parser(field_sep=':')
-            file_props = p.parse(f.readline())
-            properties = cl.getprops()
-            propnames = properties.keys()
-            propnames.sort()
-            m = file_props[:]
-            m.sort()
-            if m != propnames:
-                raise UsageError, _('Import file doesn\'t define the same '
-                    'properties as "%(arg0)s".')%{'arg0': args[0]}
 
-            # loop through the file and create a node for each entry
+            # ensure that the properties and the CSV file headings match
+            f = open(os.path.join(dir, file), 'r')
+            reader = csv.reader(f, colon_separated)
+            file_props = None
             maxid = 1
-            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
-                    line = f.readline()
-                    if not line:
-                        raise ValueError, "Unexpected EOF during CSV parse"
+            # loop through the file and create a node for each entry
+            for n, r in enumerate(reader):
+                if file_props is None:
+                    file_props = r
+                    continue
+
+                if self.verbose:
+                    sys.stdout.write('\rImporting %s - %s'%(classname, n))
+                    sys.stdout.flush()
 
                 # do the import and figure the current highest nodeid
-                maxid = max(maxid, int(cl.import_list(propnames, l)))
+                nodeid = cl.import_list(file_props, r)
+                if hasattr(cl, 'import_files'):
+                    cl.import_files(dir, nodeid)
+                maxid = max(maxid, int(nodeid))
+
+            # (print to sys.stdout here to allow tests to squash it .. ugh)
+            print >> sys.stdout
+
+            f.close()
 
-            print 'setting', classname, maxid+1
+            # import the journals
+            f = open(os.path.join(args[0], classname + '-journals.csv'), 'r')
+            reader = csv.reader(f, colon_separated)
+            cl.import_journals(reader)
+            f.close()
+
+            # (print to sys.stdout here to allow tests to squash it .. ugh)
+            print >> sys.stdout, 'setting', classname, maxid+1
+
+            # set the id counter
             self.db.setid(classname, str(maxid+1))
+
+        self.db_uncommitted = True
         return 0
 
     def do_pack(self, args):
-        '''Usage: pack period | date
+        ''"""Usage: pack period | date
 
-Remove journal entries older than a period of time specified or
-before a certain date.
+        Remove journal entries older than a period of time specified or
+        before a certain date.
 
-A period is specified using the suffixes "y", "m", and "d". The
-suffix "w" (for "week") means 7 days.
+        A period is specified using the suffixes "y", "m", and "d". The
+        suffix "w" (for "week") means 7 days.
 
-      "3y" means three years
-      "2y 1m" means two years and one month
-      "1m 25d" means one month and 25 days
-      "2w 3d" means two weeks and three days
+              "3y" means three years
+              "2y 1m" means two years and one month
+              "1m 25d" means one month and 25 days
+              "2w 3d" means two weeks and three days
+
+        Date format is "YYYY-MM-DD" eg:
+            2001-01-01
+
+        """
+        if len(args) != 1:
+            raise UsageError(_('Not enough arguments supplied'))
 
-Date format is "YYYY-MM-DD" eg:
-    2001-01-01
-    
-        '''
-        if len(args) <> 1:
-            raise UsageError, _('Not enough arguments supplied')
-        
         # are we dealing with a period or a date
         value = args[0]
-        date_re = re.compile(r'''
+        date_re = re.compile(r"""
               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
-              ''', re.VERBOSE)
+              """, re.VERBOSE)
         m = date_re.match(value)
         if not m:
-            raise ValueError, _('Invalid format')
+            raise ValueError(_('Invalid format'))
         m = m.groupdict()
         if m['period']:
             pack_before = date.Date(". - %s"%value)
         elif m['date']:
             pack_before = date.Date(value)
         self.db.pack(pack_before)
+        self.db_uncommitted = True
         return 0
 
-    def do_reindex(self, args):
-        '''Usage: reindex
+    def do_reindex(self, args, desre=re.compile('([A-Za-z]+)([0-9]+)')):
+        ''"""Usage: reindex [classname|designator]*
         Re-generate a tracker's search indexes.
 
-        This will re-generate the search indexes for a tracker. This will
-        typically happen automatically.
-        '''
-        self.db.indexer.force_reindex()
-        self.db.reindex()
+        This will re-generate the search indexes for a tracker.
+        This will typically happen automatically.
+        """
+        if args:
+            for arg in args:
+                m = desre.match(arg)
+                if m:
+                    cl = self.get_class(m.group(1))
+                    try:
+                        cl.index(m.group(2))
+                    except IndexError:
+                        raise UsageError(_('no such item "%(designator)s"')%{
+                            'designator': arg})
+                else:
+                    cl = self.get_class(arg)
+                    self.db.reindex(arg)
+        else:
+            self.db.reindex(show_progress=True)
         return 0
 
     def do_security(self, args):
-        '''Usage: security [Role name]
+        ''"""Usage: security [Role name]
         Display the Permissions available to one or all Roles.
-        '''
+        """
         if len(args) == 1:
             role = args[0]
             try:
@@ -1039,7 +1354,7 @@ Date format is "YYYY-MM-DD" eg:
                 print _('No such Role "%(role)s"')%locals()
                 return 1
         else:
-            roles = self.db.security.role.items()
+            roles = list(self.db.security.role.items())
             role = self.db.config.NEW_WEB_USER_ROLES
             if ',' in role:
                 print _('New Web users get the Roles "%(role)s"')%locals()
@@ -1054,16 +1369,48 @@ Date format is "YYYY-MM-DD" eg:
         for rolename, role in roles:
             print _('Role "%(name)s":')%role.__dict__
             for permission in role.permissions:
+                d = permission.__dict__
                 if permission.klass:
-                    print _(' %(description)s (%(name)s for "%(klass)s" '
-                        'only)')%permission.__dict__
+                    if permission.properties:
+                        print _(' %(description)s (%(name)s for "%(klass)s"'
+                          ': %(properties)s only)')%d
+                    else:
+                        print _(' %(description)s (%(name)s for "%(klass)s" '
+                            'only)')%d
                 else:
-                    print _(' %(description)s (%(name)s)')%permission.__dict__
+                    print _(' %(description)s (%(name)s)')%d
+        return 0
+
+
+    def do_migrate(self, args):
+        ''"""Usage: migrate
+        Update a tracker's database to be compatible with the Roundup
+        codebase.
+
+        You should run the "migrate" command for your tracker once you've
+        installed the latest codebase. 
+
+        Do this before you use the web, command-line or mail interface and
+        before any users access the tracker.
+
+        This command will respond with either "Tracker updated" (if you've
+        not previously run it on an RDBMS backend) or "No migration action
+        required" (if you have run it, or have used another interface to the
+        tracker, or possibly because you are using anydbm).
+
+        It's safe to run this even if it's not required, so just get into
+        the habit.
+        """
+        if getattr(self.db, 'db_version_updated'):
+            print _('Tracker updated')
+            self.db_uncommitted = True
+        else:
+            print _('No migration action required')
         return 0
 
     def run_command(self, args):
-        '''Run a single command
-        '''
+        """Run a single command
+        """
         command = args[0]
 
         # handle help now
@@ -1078,6 +1425,9 @@ Date format is "YYYY-MM-DD" eg:
             self.help_commands()
             self.help_all()
             return 0
+        if command == 'config':
+            self.do_config(args[1:])
+            return 0
 
         # figure what the command is
         try:
@@ -1141,10 +1491,10 @@ Date format is "YYYY-MM-DD" eg:
         return ret
 
     def interactive(self):
-        '''Run in an interactive mode
-        '''
-        print _('Roundup %s ready for input.'%roundup_version)
-        print _('Type "help" for help.')
+        """Run in an interactive mode
+        """
+        print _('Roundup %s ready for input.\nType "help" for help.'
+            % roundup_version)
         try:
             import readline
         except ImportError:
@@ -1163,7 +1513,7 @@ Date format is "YYYY-MM-DD" eg:
             self.run_command(args)
 
         # exit.. check for transactions
-        if self.db and self.db.transactions:
+        if self.db and self.db_uncommitted:
             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
             if commit and commit[0].lower() == 'y':
                 self.db.commit()
@@ -1171,7 +1521,7 @@ Date format is "YYYY-MM-DD" eg:
 
     def main(self):
         try:
-            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc')
+            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:vV')
         except getopt.GetoptError, e:
             self.usage(str(e))
             return 1
@@ -1180,20 +1530,42 @@ Date format is "YYYY-MM-DD" eg:
         self.tracker_home = os.environ.get('TRACKER_HOME', '')
         # TODO: reinstate the user/password stuff (-u arg too)
         name = password = ''
-        if os.environ.has_key('ROUNDUP_LOGIN'):
+        if 'ROUNDUP_LOGIN' in os.environ:
             l = os.environ['ROUNDUP_LOGIN'].split(':')
             name = l[0]
             if len(l) > 1:
                 password = l[1]
-        self.comma_sep = 0
+        self.separator = None
+        self.print_designator = 0
+        self.verbose = 0
         for opt, arg in opts:
             if opt == '-h':
                 self.usage()
                 return 0
-            if opt == '-i':
+            elif opt == '-v':
+                print '%s (python %s)'%(roundup_version, sys.version.split()[0])
+                return 0
+            elif opt == '-V':
+                self.verbose = 1
+            elif opt == '-i':
                 self.tracker_home = arg
-            if opt == '-c':
-                self.comma_sep = 1
+            elif opt == '-c':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = ','
+            elif opt == '-S':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = arg
+            elif opt == '-s':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = ' '
+            elif opt == '-d':
+                self.print_designator = 1
 
         # if no command - go interactive
         # wrap in a try/finally so we always close off the db
@@ -1213,4 +1585,4 @@ if __name__ == '__main__':
     tool = AdminTool()
     sys.exit(tool.main())
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :