Code

Enhance and simplify logging.
[roundup.git] / roundup / admin.py
1 #! /usr/bin/env python
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18 #
19 # $Id: admin.py,v 1.110 2008-02-07 03:28:33 richard Exp $
21 '''Administration commands for maintaining Roundup trackers.
22 '''
23 __docformat__ = 'restructuredtext'
25 import csv, getopt, getpass, os, re, shutil, sys, UserDict
27 from roundup import date, hyperdb, roundupdb, init, password, token
28 from roundup import __version__ as roundup_version
29 import roundup.instance
30 from roundup.configuration import CoreConfig
31 from roundup.i18n import _
33 class CommandDict(UserDict.UserDict):
34     '''Simple dictionary that lets us do lookups using partial keys.
36     Original code submitted by Engelbert Gruber.
37     '''
38     _marker = []
39     def get(self, key, default=_marker):
40         if self.data.has_key(key):
41             return [(key, self.data[key])]
42         keylist = self.data.keys()
43         keylist.sort()
44         l = []
45         for ki in keylist:
46             if ki.startswith(key):
47                 l.append((ki, self.data[ki]))
48         if not l and default is self._marker:
49             raise KeyError, key
50         return l
52 class UsageError(ValueError):
53     pass
55 class AdminTool:
56     ''' A collection of methods used in maintaining Roundup trackers.
58         Typically these methods are accessed through the roundup-admin
59         script. The main() method provided on this class gives the main
60         loop for the roundup-admin script.
62         Actions are defined by do_*() methods, with help for the action
63         given in the method docstring.
65         Additional help may be supplied by help_*() methods.
66     '''
67     def __init__(self):
68         self.commands = CommandDict()
69         for k in AdminTool.__dict__.keys():
70             if k[:3] == 'do_':
71                 self.commands[k[3:]] = getattr(self, k)
72         self.help = {}
73         for k in AdminTool.__dict__.keys():
74             if k[:5] == 'help_':
75                 self.help[k[5:]] = getattr(self, k)
76         self.tracker_home = ''
77         self.db = None
78         self.db_uncommitted = False
80     def get_class(self, classname):
81         '''Get the class - raise an exception if it doesn't exist.
82         '''
83         try:
84             return self.db.getclass(classname)
85         except KeyError:
86             raise UsageError, _('no such class "%(classname)s"')%locals()
88     def props_from_args(self, args):
89         ''' Produce a dictionary of prop: value from the args list.
91             The args list is specified as ``prop=value prop=value ...``.
92         '''
93         props = {}
94         for arg in args:
95             if arg.find('=') == -1:
96                 raise UsageError, _('argument "%(arg)s" not propname=value'
97                     )%locals()
98             l = arg.split('=')
99             if len(l) < 2:
100                 raise UsageError, _('argument "%(arg)s" not propname=value'
101                     )%locals()
102             key, value = l[0], '='.join(l[1:])
103             if value:
104                 props[key] = value
105             else:
106                 props[key] = None
107         return props
109     def usage(self, message=''):
110         ''' Display a simple usage message.
111         '''
112         if message:
113             message = _('Problem: %(message)s\n\n')%locals()
114         print _('''%(message)sUsage: roundup-admin [options] [<command> <arguments>]
116 Options:
117  -i instance home  -- specify the issue tracker "home directory" to administer
118  -u                -- the user[:password] to use for commands
119  -d                -- print full designators not just class id numbers
120  -c                -- when outputting lists of data, comma-separate them.
121                       Same as '-S ","'.
122  -S <string>       -- when outputting lists of data, string-separate them
123  -s                -- when outputting lists of data, space-separate them.
124                       Same as '-S " "'.
125  -V                -- be verbose when importing
126  -v                -- report Roundup and Python versions (and quit)
128  Only one of -s, -c or -S can be specified.
130 Help:
131  roundup-admin -h
132  roundup-admin help                       -- this help
133  roundup-admin help <command>             -- command-specific help
134  roundup-admin help all                   -- all available help
135 ''')%locals()
136         self.help_commands()
138     def help_commands(self):
139         ''' List the commands available with their help summary.
140         '''
141         print _('Commands:'),
142         commands = ['']
143         for command in self.commands.values():
144             h = _(command.__doc__).split('\n')[0]
145             commands.append(' '+h[7:])
146         commands.sort()
147         commands.append(_(
148 """Commands may be abbreviated as long as the abbreviation
149 matches only one command, e.g. l == li == lis == list."""))
150         print '\n'.join(commands)
151         print
153     def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
154         ''' Produce an HTML command list.
155         '''
156         commands = self.commands.values()
157         def sortfun(a, b):
158             return cmp(a.__name__, b.__name__)
159         commands.sort(sortfun)
160         for command in commands:
161             h = _(command.__doc__).split('\n')
162             name = command.__name__[3:]
163             usage = h[0]
164             print '''
165 <tr><td valign=top><strong>%(name)s</strong></td>
166     <td><tt>%(usage)s</tt><p>
167 <pre>''' % locals()
168             indent = indent_re.match(h[3])
169             if indent: indent = len(indent.group(1))
170             for line in h[3:]:
171                 if indent:
172                     print line[indent:]
173                 else:
174                     print line
175             print '</pre></td></tr>\n'
177     def help_all(self):
178         print _('''
179 All commands (except help) require a tracker specifier. This is just
180 the path to the roundup tracker you're working with. A roundup tracker
181 is where roundup keeps the database and configuration file that defines
182 an issue tracker. It may be thought of as the issue tracker's "home
183 directory". It may be specified in the environment variable TRACKER_HOME
184 or on the command line as "-i tracker".
186 A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
188 Property values are represented as strings in command arguments and in the
189 printed results:
190  . Strings are, well, strings.
191  . Date values are printed in the full date format in the local time zone,
192    and accepted in the full format or any of the partial formats explained
193    below.
194  . Link values are printed as node designators. When given as an argument,
195    node designators and key strings are both accepted.
196  . Multilink values are printed as lists of node designators joined
197    by commas.  When given as an argument, node designators and key
198    strings are both accepted; an empty string, a single node, or a list
199    of nodes joined by commas is accepted.
201 When property values must contain spaces, just surround the value with
202 quotes, either ' or ". A single space may also be backslash-quoted. If a
203 value must contain a quote character, it must be backslash-quoted or inside
204 quotes. Examples:
205            hello world      (2 tokens: hello, world)
206            "hello world"    (1 token: hello world)
207            "Roch'e" Compaan (2 tokens: Roch'e Compaan)
208            Roch\\'e Compaan  (2 tokens: Roch'e Compaan)
209            address="1 2 3"  (1 token: address=1 2 3)
210            \\\\               (1 token: \\)
211            \\n\\r\\t           (1 token: a newline, carriage-return and tab)
213 When multiple nodes are specified to the roundup get or roundup set
214 commands, the specified properties are retrieved or set on all the listed
215 nodes.
217 When multiple results are returned by the roundup get or roundup find
218 commands, they are printed one per line (default) or joined by commas (with
219 the -c) option.
221 Where the command changes data, a login name/password is required. The
222 login may be specified as either "name" or "name:password".
223  . ROUNDUP_LOGIN environment variable
224  . the -u command-line option
225 If either the name or password is not supplied, they are obtained from the
226 command-line.
228 Date format examples:
229   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
230   "2000-04-17" means <Date 2000-04-17.00:00:00>
231   "01-25" means <Date yyyy-01-25.00:00:00>
232   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
233   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
234   "14:25" means <Date yyyy-mm-dd.19:25:00>
235   "8:47:11" means <Date yyyy-mm-dd.13:47:11>
236   "." means "right now"
238 Command help:
239 ''')
240         for name, command in self.commands.items():
241             print _('%s:')%name
242             print '   ', _(command.__doc__)
244     def do_help(self, args, nl_re=re.compile('[\r\n]'),
245             indent_re=re.compile(r'^(\s+)\S+')):
246         ""'''Usage: help topic
247         Give help about topic.
249         commands  -- list commands
250         <command> -- help specific to a command
251         initopts  -- init command options
252         all       -- all available help
253         '''
254         if len(args)>0:
255             topic = args[0]
256         else:
257             topic = 'help'
260         # try help_ methods
261         if self.help.has_key(topic):
262             self.help[topic]()
263             return 0
265         # try command docstrings
266         try:
267             l = self.commands.get(topic)
268         except KeyError:
269             print _('Sorry, no help for "%(topic)s"')%locals()
270             return 1
272         # display the help for each match, removing the docsring indent
273         for name, help in l:
274             lines = nl_re.split(_(help.__doc__))
275             print lines[0]
276             indent = indent_re.match(lines[1])
277             if indent: indent = len(indent.group(1))
278             for line in lines[1:]:
279                 if indent:
280                     print line[indent:]
281                 else:
282                     print line
283         return 0
285     def listTemplates(self):
286         ''' List all the available templates.
288         Look in the following places, where the later rules take precedence:
290          1. <roundup.admin.__file__>/../../share/roundup/templates/*
291             this is where they will be if we installed an egg via easy_install
292          2. <prefix>/share/roundup/templates/*
293             this should be the standard place to find them when Roundup is
294             installed
295          3. <roundup.admin.__file__>/../templates/*
296             this will be used if Roundup's run in the distro (aka. source)
297             directory
298          4. <current working dir>/*
299             this is for when someone unpacks a 3rd-party template
300          5. <current working dir>
301             this is for someone who "cd"s to the 3rd-party template dir
302         '''
303         # OK, try <prefix>/share/roundup/templates
304         #     and <egg-directory>/share/roundup/templates
305         # -- this module (roundup.admin) will be installed in something
306         # like:
307         #    /usr/lib/python2.5/site-packages/roundup/admin.py  (5 dirs up)
308         #    c:\python25\lib\site-packages\roundup\admin.py     (4 dirs up)
309         #    /usr/lib/python2.5/site-packages/roundup-1.3.3-py2.5-egg/roundup/admin.py
310         #    (2 dirs up)
311         #
312         # we're interested in where the directory containing "share" is
313         templates = {}
314         for N in 2, 4, 5:
315             path = __file__
316             # move up N elements in the path
317             for i in range(N):
318                 path = os.path.dirname(path)
319             tdir = os.path.join(path, 'share', 'roundup', 'templates')
320             if os.path.isdir(tdir):
321                 templates = init.listTemplates(tdir)
322                 break
324         # OK, now try as if we're in the roundup source distribution
325         # directory, so this module will be in .../roundup-*/roundup/admin.py
326         # and we're interested in the .../roundup-*/ part.
327         path = __file__
328         for i in range(2):
329             path = os.path.dirname(path)
330         tdir = os.path.join(path, 'templates')
331         if os.path.isdir(tdir):
332             templates.update(init.listTemplates(tdir))
334         # Try subdirs of the current dir
335         templates.update(init.listTemplates(os.getcwd()))
337         # Finally, try the current directory as a template
338         template = init.loadTemplateInfo(os.getcwd())
339         if template:
340             templates[template['name']] = template
342         return templates
344     def help_initopts(self):
345         templates = self.listTemplates()
346         print _('Templates:'), ', '.join(templates.keys())
347         import roundup.backends
348         backends = roundup.backends.list_backends()
349         print _('Back ends:'), ', '.join(backends)
351     def do_install(self, tracker_home, args):
352         ""'''Usage: install [template [backend [key=val[,key=val]]]]
353         Install a new Roundup tracker.
355         The command will prompt for the tracker home directory
356         (if not supplied through TRACKER_HOME or the -i option).
357         The template and backend may be specified on the command-line
358         as arguments, in that order.
360         Command line arguments following the backend allows you to
361         pass initial values for config options.  For example, passing
362         "web_http_auth=no,rdbms_user=dinsdale" will override defaults
363         for options http_auth in section [web] and user in section [rdbms].
364         Please be careful to not use spaces in this argument! (Enclose
365         whole argument in quotes if you need spaces in option value).
367         The initialise command must be called after this command in order
368         to initialise the tracker's database. You may edit the tracker's
369         initial database contents before running that command by editing
370         the tracker's dbinit.py module init() function.
372         See also initopts help.
373         '''
374         if len(args) < 1:
375             raise UsageError, _('Not enough arguments supplied')
377         # make sure the tracker home can be created
378         tracker_home = os.path.abspath(tracker_home)
379         parent = os.path.split(tracker_home)[0]
380         if not os.path.exists(parent):
381             raise UsageError, _('Instance home parent directory "%(parent)s"'
382                 ' does not exist')%locals()
384         config_ini_file = os.path.join(tracker_home, CoreConfig.INI_FILE)
385         # check for both old- and new-style configs
386         if filter(os.path.exists, [config_ini_file,
387                 os.path.join(tracker_home, 'config.py')]):
388             ok = raw_input(_(
389 """WARNING: There appears to be a tracker in "%(tracker_home)s"!
390 If you re-install it, you will lose all the data!
391 Erase it? Y/N: """) % locals())
392             if ok.strip().lower() != 'y':
393                 return 0
395             # clear it out so the install isn't confused
396             shutil.rmtree(tracker_home)
398         # select template
399         templates = self.listTemplates()
400         template = len(args) > 1 and args[1] or ''
401         if not templates.has_key(template):
402             print _('Templates:'), ', '.join(templates.keys())
403         while not templates.has_key(template):
404             template = raw_input(_('Select template [classic]: ')).strip()
405             if not template:
406                 template = 'classic'
408         # select hyperdb backend
409         import roundup.backends
410         backends = roundup.backends.list_backends()
411         backend = len(args) > 2 and args[2] or ''
412         if backend not in backends:
413             print _('Back ends:'), ', '.join(backends)
414         while backend not in backends:
415             backend = raw_input(_('Select backend [anydbm]: ')).strip()
416             if not backend:
417                 backend = 'anydbm'
418         # XXX perform a unit test based on the user's selections
420         # Process configuration file definitions
421         if len(args) > 3:
422             try:
423                 defns = dict([item.split("=") for item in args[3].split(",")])
424             except:
425                 print _('Error in configuration settings: "%s"') % args[3]
426                 raise
427         else:
428             defns = {}
430         # install!
431         init.install(tracker_home, templates[template]['path'], settings=defns)
432         init.write_select_db(tracker_home, backend)
434         print _("""
435 ---------------------------------------------------------------------------
436  You should now edit the tracker configuration file:
437    %(config_file)s""") % {"config_file": config_ini_file}
439         # find list of options that need manual adjustments
440         # XXX config._get_unset_options() is marked as private
441         #   (leading underscore).  make it public or don't care?
442         need_set = CoreConfig(tracker_home)._get_unset_options()
443         if need_set:
444             print _(" ... at a minimum, you must set following options:")
445             for section, options in need_set.items():
446                 print "   [%s]: %s" % (section, ", ".join(options))
448         # note about schema modifications
449         print _("""
450  If you wish to modify the database schema,
451  you should also edit the schema file:
452    %(database_config_file)s
453  You may also change the database initialisation file:
454    %(database_init_file)s
455  ... see the documentation on customizing for more information.
457  You MUST run the "roundup-admin initialise" command once you've performed
458  the above steps.
459 ---------------------------------------------------------------------------
460 """) % {
461     'database_config_file': os.path.join(tracker_home, 'schema.py'),
462     'database_init_file': os.path.join(tracker_home, 'initial_data.py'),
464         return 0
466     def do_genconfig(self, args):
467         ""'''Usage: genconfig <filename>
468         Generate a new tracker config file (ini style) with default values
469         in <filename>.
470         '''
471         if len(args) < 1:
472             raise UsageError, _('Not enough arguments supplied')
473         config = CoreConfig()
474         config.save(args[0])
476     def do_initialise(self, tracker_home, args):
477         ""'''Usage: initialise [adminpw]
478         Initialise a new Roundup tracker.
480         The administrator details will be set at this step.
482         Execute the tracker's initialisation function dbinit.init()
483         '''
484         # password
485         if len(args) > 1:
486             adminpw = args[1]
487         else:
488             adminpw = ''
489             confirm = 'x'
490             while adminpw != confirm:
491                 adminpw = getpass.getpass(_('Admin Password: '))
492                 confirm = getpass.getpass(_('       Confirm: '))
494         # make sure the tracker home is installed
495         if not os.path.exists(tracker_home):
496             raise UsageError, _('Instance home does not exist')%locals()
497         try:
498             tracker = roundup.instance.open(tracker_home)
499         except roundup.instance.TrackerError:
500             raise UsageError, _('Instance has not been installed')%locals()
502         # is there already a database?
503         if tracker.exists():
504             ok = raw_input(_(
505 """WARNING: The database is already initialised!
506 If you re-initialise it, you will lose all the data!
507 Erase it? Y/N: """))
508             if ok.strip().lower() != 'y':
509                 return 0
511             backend = tracker.get_backend_name()
513             # nuke it
514             tracker.nuke()
516             # re-write the backend select file
517             init.write_select_db(tracker_home, backend)
519         # GO
520         tracker.init(password.Password(adminpw))
522         return 0
525     def do_get(self, args):
526         ""'''Usage: get property designator[,designator]*
527         Get the given property of one or more designator(s).
529         Retrieves the property value of the nodes specified
530         by the designators.
531         '''
532         if len(args) < 2:
533             raise UsageError, _('Not enough arguments supplied')
534         propname = args[0]
535         designators = args[1].split(',')
536         l = []
537         for designator in designators:
538             # decode the node designator
539             try:
540                 classname, nodeid = hyperdb.splitDesignator(designator)
541             except hyperdb.DesignatorError, message:
542                 raise UsageError, message
544             # get the class
545             cl = self.get_class(classname)
546             try:
547                 id=[]
548                 if self.separator:
549                     if self.print_designator:
550                         # see if property is a link or multilink for
551                         # which getting a desginator make sense.
552                         # Algorithm: Get the properties of the
553                         #     current designator's class. (cl.getprops)
554                         # get the property object for the property the
555                         #     user requested (properties[propname])
556                         # verify its type (isinstance...)
557                         # raise error if not link/multilink
558                         # get class name for link/multilink property
559                         # do the get on the designators
560                         # append the new designators
561                         # print
562                         properties = cl.getprops()
563                         property = properties[propname]
564                         if not (isinstance(property, hyperdb.Multilink) or
565                           isinstance(property, hyperdb.Link)):
566                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
567                         propclassname = self.db.getclass(property.classname).classname
568                         id = cl.get(nodeid, propname)
569                         for i in id:
570                             l.append(propclassname + i)
571                     else:
572                         id = cl.get(nodeid, propname)
573                         for i in id:
574                             l.append(i)
575                 else:
576                     if self.print_designator:
577                         properties = cl.getprops()
578                         property = properties[propname]
579                         if not (isinstance(property, hyperdb.Multilink) or
580                           isinstance(property, hyperdb.Link)):
581                             raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
582                         propclassname = self.db.getclass(property.classname).classname
583                         id = cl.get(nodeid, propname)
584                         for i in id:
585                             print propclassname + i
586                     else:
587                         print cl.get(nodeid, propname)
588             except IndexError:
589                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
590             except KeyError:
591                 raise UsageError, _('no such %(classname)s property '
592                     '"%(propname)s"')%locals()
593         if self.separator:
594             print self.separator.join(l)
596         return 0
599     def do_set(self, args):
600         ""'''Usage: set items property=value property=value ...
601         Set the given properties of one or more items(s).
603         The items are specified as a class or as a comma-separated
604         list of item designators (ie "designator[,designator,...]").
606         This command sets the properties to the values for all designators
607         given. If the value is missing (ie. "property=") then the property
608         is un-set. If the property is a multilink, you specify the linked
609         ids for the multilink as comma-separated numbers (ie "1,2,3").
610         '''
611         if len(args) < 2:
612             raise UsageError, _('Not enough arguments supplied')
613         from roundup import hyperdb
615         designators = args[0].split(',')
616         if len(designators) == 1:
617             designator = designators[0]
618             try:
619                 designator = hyperdb.splitDesignator(designator)
620                 designators = [designator]
621             except hyperdb.DesignatorError:
622                 cl = self.get_class(designator)
623                 designators = [(designator, x) for x in cl.list()]
624         else:
625             try:
626                 designators = [hyperdb.splitDesignator(x) for x in designators]
627             except hyperdb.DesignatorError, message:
628                 raise UsageError, message
630         # get the props from the args
631         props = self.props_from_args(args[1:])
633         # now do the set for all the nodes
634         for classname, itemid in designators:
635             cl = self.get_class(classname)
637             properties = cl.getprops()
638             for key, value in props.items():
639                 try:
640                     props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
641                         key, value)
642                 except hyperdb.HyperdbValueError, message:
643                     raise UsageError, message
645             # try the set
646             try:
647                 apply(cl.set, (itemid, ), props)
648             except (TypeError, IndexError, ValueError), message:
649                 import traceback; traceback.print_exc()
650                 raise UsageError, message
651         self.db_uncommitted = True
652         return 0
654     def do_find(self, args):
655         ""'''Usage: find classname propname=value ...
656         Find the nodes of the given class with a given link property value.
658         Find the nodes of the given class with a given link property value.
659         The value may be either the nodeid of the linked node, or its key
660         value.
661         '''
662         if len(args) < 1:
663             raise UsageError, _('Not enough arguments supplied')
664         classname = args[0]
665         # get the class
666         cl = self.get_class(classname)
668         # handle the propname=value argument
669         props = self.props_from_args(args[1:])
671         # convert the user-input value to a value used for find()
672         for propname, value in props.items():
673             if ',' in value:
674                 values = value.split(',')
675             else:
676                 values = [value]
677             d = props[propname] = {}
678             for value in values:
679                 value = hyperdb.rawToHyperdb(self.db, cl, None, propname, value)
680                 if isinstance(value, list):
681                     for entry in value:
682                         d[entry] = 1
683                 else:
684                     d[value] = 1
686         # now do the find
687         try:
688             id = []
689             designator = []
690             if self.separator:
691                 if self.print_designator:
692                     id=apply(cl.find, (), props)
693                     for i in id:
694                         designator.append(classname + i)
695                     print self.separator.join(designator)
696                 else:
697                     print self.separator.join(apply(cl.find, (), props))
699             else:
700                 if self.print_designator:
701                     id=apply(cl.find, (), props)
702                     for i in id:
703                         designator.append(classname + i)
704                     print designator
705                 else:
706                     print apply(cl.find, (), props)
707         except KeyError:
708             raise UsageError, _('%(classname)s has no property '
709                 '"%(propname)s"')%locals()
710         except (ValueError, TypeError), message:
711             raise UsageError, message
712         return 0
714     def do_specification(self, args):
715         ""'''Usage: specification classname
716         Show the properties for a classname.
718         This lists the properties for a given class.
719         '''
720         if len(args) < 1:
721             raise UsageError, _('Not enough arguments supplied')
722         classname = args[0]
723         # get the class
724         cl = self.get_class(classname)
726         # get the key property
727         keyprop = cl.getkey()
728         for key, value in cl.properties.items():
729             if keyprop == key:
730                 print _('%(key)s: %(value)s (key property)')%locals()
731             else:
732                 print _('%(key)s: %(value)s')%locals()
734     def do_display(self, args):
735         ""'''Usage: display designator[,designator]*
736         Show the property values for the given node(s).
738         This lists the properties and their associated values for the given
739         node.
740         '''
741         if len(args) < 1:
742             raise UsageError, _('Not enough arguments supplied')
744         # decode the node designator
745         for designator in args[0].split(','):
746             try:
747                 classname, nodeid = hyperdb.splitDesignator(designator)
748             except hyperdb.DesignatorError, message:
749                 raise UsageError, message
751             # get the class
752             cl = self.get_class(classname)
754             # display the values
755             keys = cl.properties.keys()
756             keys.sort()
757             for key in keys:
758                 value = cl.get(nodeid, key)
759                 print _('%(key)s: %(value)s')%locals()
761     def do_create(self, args):
762         ""'''Usage: create classname property=value ...
763         Create a new entry of a given class.
765         This creates a new entry of the given class using the property
766         name=value arguments provided on the command line after the "create"
767         command.
768         '''
769         if len(args) < 1:
770             raise UsageError, _('Not enough arguments supplied')
771         from roundup import hyperdb
773         classname = args[0]
775         # get the class
776         cl = self.get_class(classname)
778         # now do a create
779         props = {}
780         properties = cl.getprops(protected = 0)
781         if len(args) == 1:
782             # ask for the properties
783             for key, value in properties.items():
784                 if key == 'id': continue
785                 name = value.__class__.__name__
786                 if isinstance(value , hyperdb.Password):
787                     again = None
788                     while value != again:
789                         value = getpass.getpass(_('%(propname)s (Password): ')%{
790                             'propname': key.capitalize()})
791                         again = getpass.getpass(_('   %(propname)s (Again): ')%{
792                             'propname': key.capitalize()})
793                         if value != again: print _('Sorry, try again...')
794                     if value:
795                         props[key] = value
796                 else:
797                     value = raw_input(_('%(propname)s (%(proptype)s): ')%{
798                         'propname': key.capitalize(), 'proptype': name})
799                     if value:
800                         props[key] = value
801         else:
802             props = self.props_from_args(args[1:])
804         # convert types
805         for propname, value in props.items():
806             try:
807                 props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
808                     propname, value)
809             except hyperdb.HyperdbValueError, message:
810                 raise UsageError, message
812         # check for the key property
813         propname = cl.getkey()
814         if propname and not props.has_key(propname):
815             raise UsageError, _('you must provide the "%(propname)s" '
816                 'property.')%locals()
818         # do the actual create
819         try:
820             print apply(cl.create, (), props)
821         except (TypeError, IndexError, ValueError), message:
822             raise UsageError, message
823         self.db_uncommitted = True
824         return 0
826     def do_list(self, args):
827         ""'''Usage: list classname [property]
828         List the instances of a class.
830         Lists all instances of the given class. If the property is not
831         specified, the  "label" property is used. The label property is
832         tried in order: the key, "name", "title" and then the first
833         property, alphabetically.
835         With -c, -S or -s print a list of item id's if no property
836         specified.  If property specified, print list of that property
837         for every class instance.
838         '''
839         if len(args) > 2:
840             raise UsageError, _('Too many arguments supplied')
841         if len(args) < 1:
842             raise UsageError, _('Not enough arguments supplied')
843         classname = args[0]
845         # get the class
846         cl = self.get_class(classname)
848         # figure the property
849         if len(args) > 1:
850             propname = args[1]
851         else:
852             propname = cl.labelprop()
854         if self.separator:
855             if len(args) == 2:
856                # create a list of propnames since user specified propname
857                 proplist=[]
858                 for nodeid in cl.list():
859                     try:
860                         proplist.append(cl.get(nodeid, propname))
861                     except KeyError:
862                         raise UsageError, _('%(classname)s has no property '
863                             '"%(propname)s"')%locals()
864                 print self.separator.join(proplist)
865             else:
866                 # create a list of index id's since user didn't specify
867                 # otherwise
868                 print self.separator.join(cl.list())
869         else:
870             for nodeid in cl.list():
871                 try:
872                     value = cl.get(nodeid, propname)
873                 except KeyError:
874                     raise UsageError, _('%(classname)s has no property '
875                         '"%(propname)s"')%locals()
876                 print _('%(nodeid)4s: %(value)s')%locals()
877         return 0
879     def do_table(self, args):
880         ""'''Usage: table classname [property[,property]*]
881         List the instances of a class in tabular form.
883         Lists all instances of the given class. If the properties are not
884         specified, all properties are displayed. By default, the column
885         widths are the width of the largest value. The width may be
886         explicitly defined by defining the property as "name:width".
887         For example::
889           roundup> table priority id,name:10
890           Id Name
891           1  fatal-bug
892           2  bug
893           3  usability
894           4  feature
896         Also to make the width of the column the width of the label,
897         leave a trailing : without a width on the property. For example::
899           roundup> table priority id,name:
900           Id Name
901           1  fata
902           2  bug
903           3  usab
904           4  feat
906         will result in a the 4 character wide "Name" column.
907         '''
908         if len(args) < 1:
909             raise UsageError, _('Not enough arguments supplied')
910         classname = args[0]
912         # get the class
913         cl = self.get_class(classname)
915         # figure the property names to display
916         if len(args) > 1:
917             prop_names = args[1].split(',')
918             all_props = cl.getprops()
919             for spec in prop_names:
920                 if ':' in spec:
921                     try:
922                         propname, width = spec.split(':')
923                     except (ValueError, TypeError):
924                         raise UsageError, _('"%(spec)s" not name:width')%locals()
925                 else:
926                     propname = spec
927                 if not all_props.has_key(propname):
928                     raise UsageError, _('%(classname)s has no property '
929                         '"%(propname)s"')%locals()
930         else:
931             prop_names = cl.getprops().keys()
933         # now figure column widths
934         props = []
935         for spec in prop_names:
936             if ':' in spec:
937                 name, width = spec.split(':')
938                 if width == '':
939                     props.append((name, len(spec)))
940                 else:
941                     props.append((name, int(width)))
942             else:
943                # this is going to be slow
944                maxlen = len(spec)
945                for nodeid in cl.list():
946                    curlen = len(str(cl.get(nodeid, spec)))
947                    if curlen > maxlen:
948                        maxlen = curlen
949                props.append((spec, maxlen))
951         # now display the heading
952         print ' '.join([name.capitalize().ljust(width) for name,width in props])
954         # and the table data
955         for nodeid in cl.list():
956             l = []
957             for name, width in props:
958                 if name != 'id':
959                     try:
960                         value = str(cl.get(nodeid, name))
961                     except KeyError:
962                         # we already checked if the property is valid - a
963                         # KeyError here means the node just doesn't have a
964                         # value for it
965                         value = ''
966                 else:
967                     value = str(nodeid)
968                 f = '%%-%ds'%width
969                 l.append(f%value[:width])
970             print ' '.join(l)
971         return 0
973     def do_history(self, args):
974         ""'''Usage: history designator
975         Show the history entries of a designator.
977         Lists the journal entries for the node identified by the designator.
978         '''
979         if len(args) < 1:
980             raise UsageError, _('Not enough arguments supplied')
981         try:
982             classname, nodeid = hyperdb.splitDesignator(args[0])
983         except hyperdb.DesignatorError, message:
984             raise UsageError, message
986         try:
987             print self.db.getclass(classname).history(nodeid)
988         except KeyError:
989             raise UsageError, _('no such class "%(classname)s"')%locals()
990         except IndexError:
991             raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
992         return 0
994     def do_commit(self, args):
995         ""'''Usage: commit
996         Commit changes made to the database during an interactive session.
998         The changes made during an interactive session are not
999         automatically written to the database - they must be committed
1000         using this command.
1002         One-off commands on the command-line are automatically committed if
1003         they are successful.
1004         '''
1005         self.db.commit()
1006         self.db_uncommitted = False
1007         return 0
1009     def do_rollback(self, args):
1010         ""'''Usage: rollback
1011         Undo all changes that are pending commit to the database.
1013         The changes made during an interactive session are not
1014         automatically written to the database - they must be committed
1015         manually. This command undoes all those changes, so a commit
1016         immediately after would make no changes to the database.
1017         '''
1018         self.db.rollback()
1019         self.db_uncommitted = False
1020         return 0
1022     def do_retire(self, args):
1023         ""'''Usage: retire designator[,designator]*
1024         Retire the node specified by designator.
1026         This action indicates that a particular node is not to be retrieved
1027         by the list or find commands, and its key value may be re-used.
1028         '''
1029         if len(args) < 1:
1030             raise UsageError, _('Not enough arguments supplied')
1031         designators = args[0].split(',')
1032         for designator in designators:
1033             try:
1034                 classname, nodeid = hyperdb.splitDesignator(designator)
1035             except hyperdb.DesignatorError, message:
1036                 raise UsageError, message
1037             try:
1038                 self.db.getclass(classname).retire(nodeid)
1039             except KeyError:
1040                 raise UsageError, _('no such class "%(classname)s"')%locals()
1041             except IndexError:
1042                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1043         self.db_uncommitted = True
1044         return 0
1046     def do_restore(self, args):
1047         ""'''Usage: restore designator[,designator]*
1048         Restore the retired node specified by designator.
1050         The given nodes will become available for users again.
1051         '''
1052         if len(args) < 1:
1053             raise UsageError, _('Not enough arguments supplied')
1054         designators = args[0].split(',')
1055         for designator in designators:
1056             try:
1057                 classname, nodeid = hyperdb.splitDesignator(designator)
1058             except hyperdb.DesignatorError, message:
1059                 raise UsageError, message
1060             try:
1061                 self.db.getclass(classname).restore(nodeid)
1062             except KeyError:
1063                 raise UsageError, _('no such class "%(classname)s"')%locals()
1064             except IndexError:
1065                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
1066         self.db_uncommitted = True
1067         return 0
1069     def do_export(self, args, export_files=True):
1070         ""'''Usage: export [[-]class[,class]] export_dir
1071         Export the database to colon-separated-value files.
1072         To exclude the files (e.g. for the msg or file class),
1073         use the exporttables command.
1075         Optionally limit the export to just the named classes
1076         or exclude the named classes, if the 1st argument starts with '-'.
1078         This action exports the current data from the database into
1079         colon-separated-value files that are placed in the nominated
1080         destination directory.
1081         '''
1082         # grab the directory to export to
1083         if len(args) < 1:
1084             raise UsageError, _('Not enough arguments supplied')
1086         dir = args[-1]
1088         # get the list of classes to export
1089         if len(args) == 2:
1090             if args[0].startswith('-'):
1091                 classes = [ c for c in self.db.classes.keys()
1092                             if not c in args[0][1:].split(',') ]
1093             else:
1094                 classes = args[0].split(',')
1095         else:
1096             classes = self.db.classes.keys()
1098         class colon_separated(csv.excel):
1099             delimiter = ':'
1101         # make sure target dir exists
1102         if not os.path.exists(dir):
1103             os.makedirs(dir)
1105         # do all the classes specified
1106         for classname in classes:
1107             cl = self.get_class(classname)
1109             if not export_files and hasattr(cl, 'export_files'):
1110                 sys.stdout.write('Exporting %s WITHOUT the files\r\n'%
1111                     classname)
1113             f = open(os.path.join(dir, classname+'.csv'), 'wb')
1114             writer = csv.writer(f, colon_separated)
1116             properties = cl.getprops()
1117             propnames = cl.export_propnames()
1118             fields = propnames[:]
1119             fields.append('is retired')
1120             writer.writerow(fields)
1122             # all nodes for this class
1123             for nodeid in cl.getnodeids():
1124                 if self.verbose:
1125                     sys.stdout.write('\rExporting %s - %s'%(classname, nodeid))
1126                     sys.stdout.flush()
1127                 writer.writerow(cl.export_list(propnames, nodeid))
1128                 if export_files and hasattr(cl, 'export_files'):
1129                     cl.export_files(dir, nodeid)
1131             # close this file
1132             f.close()
1134             # export the journals
1135             jf = open(os.path.join(dir, classname+'-journals.csv'), 'wb')
1136             if self.verbose:
1137                 sys.stdout.write("\nExporting Journal for %s\n" % classname)
1138                 sys.stdout.flush()
1139             journals = csv.writer(jf, colon_separated)
1140             map(journals.writerow, cl.export_journals())
1141             jf.close()
1142         return 0
1144     def do_exporttables(self, args):
1145         ""'''Usage: exporttables [[-]class[,class]] export_dir
1146         Export the database to colon-separated-value files, excluding the
1147         files below $TRACKER_HOME/db/files/ (which can be archived separately).
1148         To include the files, use the export command.
1150         Optionally limit the export to just the named classes
1151         or exclude the named classes, if the 1st argument starts with '-'.
1153         This action exports the current data from the database into
1154         colon-separated-value files that are placed in the nominated
1155         destination directory.
1156         '''
1157         return self.do_export(args, export_files=False)
1159     def do_import(self, args):
1160         ""'''Usage: import import_dir
1161         Import a database from the directory containing CSV files,
1162         two per class to import.
1164         The files used in the import are:
1166         <class>.csv
1167           This must define the same properties as the class (including
1168           having a "header" line with those property names.)
1169         <class>-journals.csv
1170           This defines the journals for the items being imported.
1172         The imported nodes will have the same nodeid as defined in the
1173         import file, thus replacing any existing content.
1175         The new nodes are added to the existing database - if you want to
1176         create a new database using the imported data, then create a new
1177         database (or, tediously, retire all the old data.)
1178         '''
1179         if len(args) < 1:
1180             raise UsageError, _('Not enough arguments supplied')
1181         from roundup import hyperdb
1183         # directory to import from
1184         dir = args[0]
1186         class colon_separated(csv.excel):
1187             delimiter = ':'
1189         # import all the files
1190         for file in os.listdir(dir):
1191             classname, ext = os.path.splitext(file)
1192             # we only care about CSV files
1193             if ext != '.csv' or classname.endswith('-journals'):
1194                 continue
1196             cl = self.get_class(classname)
1198             # ensure that the properties and the CSV file headings match
1199             f = open(os.path.join(dir, file), 'r')
1200             reader = csv.reader(f, colon_separated)
1201             file_props = None
1202             maxid = 1
1203             # loop through the file and create a node for each entry
1204             for n, r in enumerate(reader):
1205                 if file_props is None:
1206                     file_props = r
1207                     continue
1209                 if self.verbose:
1210                     sys.stdout.write('\rImporting %s - %s'%(classname, n))
1211                     sys.stdout.flush()
1213                 # do the import and figure the current highest nodeid
1214                 nodeid = cl.import_list(file_props, r)
1215                 if hasattr(cl, 'import_files'):
1216                     cl.import_files(dir, nodeid)
1217                 maxid = max(maxid, int(nodeid))
1218             print
1219             f.close()
1221             # import the journals
1222             f = open(os.path.join(args[0], classname + '-journals.csv'), 'r')
1223             reader = csv.reader(f, colon_separated)
1224             cl.import_journals(reader)
1225             f.close()
1227             # set the id counter
1228             print 'setting', classname, maxid+1
1229             self.db.setid(classname, str(maxid+1))
1231         self.db_uncommitted = True
1232         return 0
1234     def do_pack(self, args):
1235         ""'''Usage: pack period | date
1237         Remove journal entries older than a period of time specified or
1238         before a certain date.
1240         A period is specified using the suffixes "y", "m", and "d". The
1241         suffix "w" (for "week") means 7 days.
1243               "3y" means three years
1244               "2y 1m" means two years and one month
1245               "1m 25d" means one month and 25 days
1246               "2w 3d" means two weeks and three days
1248         Date format is "YYYY-MM-DD" eg:
1249             2001-01-01
1251         '''
1252         if len(args) <> 1:
1253             raise UsageError, _('Not enough arguments supplied')
1255         # are we dealing with a period or a date
1256         value = args[0]
1257         date_re = re.compile(r'''
1258               (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
1259               (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
1260               ''', re.VERBOSE)
1261         m = date_re.match(value)
1262         if not m:
1263             raise ValueError, _('Invalid format')
1264         m = m.groupdict()
1265         if m['period']:
1266             pack_before = date.Date(". - %s"%value)
1267         elif m['date']:
1268             pack_before = date.Date(value)
1269         self.db.pack(pack_before)
1270         self.db_uncommitted = True
1271         return 0
1273     def do_reindex(self, args, desre=re.compile('([A-Za-z]+)([0-9]+)')):
1274         ""'''Usage: reindex [classname|designator]*
1275         Re-generate a tracker's search indexes.
1277         This will re-generate the search indexes for a tracker.
1278         This will typically happen automatically.
1279         '''
1280         if args:
1281             for arg in args:
1282                 m = desre.match(arg)
1283                 if m:
1284                     cl = self.get_class(m.group(1))
1285                     try:
1286                         cl.index(m.group(2))
1287                     except IndexError:
1288                         raise UsageError, _('no such item "%(designator)s"')%{
1289                             'designator': arg}
1290                 else:
1291                     cl = self.get_class(arg)
1292                     self.db.reindex(arg)
1293         else:
1294             self.db.reindex(show_progress=True)
1295         return 0
1297     def do_security(self, args):
1298         ""'''Usage: security [Role name]
1299         Display the Permissions available to one or all Roles.
1300         '''
1301         if len(args) == 1:
1302             role = args[0]
1303             try:
1304                 roles = [(args[0], self.db.security.role[args[0]])]
1305             except KeyError:
1306                 print _('No such Role "%(role)s"')%locals()
1307                 return 1
1308         else:
1309             roles = self.db.security.role.items()
1310             role = self.db.config.NEW_WEB_USER_ROLES
1311             if ',' in role:
1312                 print _('New Web users get the Roles "%(role)s"')%locals()
1313             else:
1314                 print _('New Web users get the Role "%(role)s"')%locals()
1315             role = self.db.config.NEW_EMAIL_USER_ROLES
1316             if ',' in role:
1317                 print _('New Email users get the Roles "%(role)s"')%locals()
1318             else:
1319                 print _('New Email users get the Role "%(role)s"')%locals()
1320         roles.sort()
1321         for rolename, role in roles:
1322             print _('Role "%(name)s":')%role.__dict__
1323             for permission in role.permissions:
1324                 d = permission.__dict__
1325                 if permission.klass:
1326                     if permission.properties:
1327                         print _(' %(description)s (%(name)s for "%(klass)s"'
1328                           ': %(properties)s only)')%d
1329                     else:
1330                         print _(' %(description)s (%(name)s for "%(klass)s" '
1331                             'only)')%d
1332                 else:
1333                     print _(' %(description)s (%(name)s)')%d
1334         return 0
1337     def do_migrate(self, args):
1338         '''Usage: migrate
1339         Update a tracker's database to be compatible with the Roundup
1340         codebase.
1342         You should run the "migrate" command for your tracker once you've
1343         installed the latest codebase. 
1345         Do this before you use the web, command-line or mail interface and
1346         before any users access the tracker.
1348         This command will respond with either "Tracker updated" (if you've
1349         not previously run it on an RDBMS backend) or "No migration action
1350         required" (if you have run it, or have used another interface to the
1351         tracker, or possibly because you are using anydbm).
1353         It's safe to run this even if it's not required, so just get into
1354         the habit.
1355         '''
1356         if getattr(self.db, 'db_version_updated'):
1357             print _('Tracker updated')
1358             self.db_uncommitted = True
1359         else:
1360             print _('No migration action required')
1361         return 0
1363     def run_command(self, args):
1364         '''Run a single command
1365         '''
1366         command = args[0]
1368         # handle help now
1369         if command == 'help':
1370             if len(args)>1:
1371                 self.do_help(args[1:])
1372                 return 0
1373             self.do_help(['help'])
1374             return 0
1375         if command == 'morehelp':
1376             self.do_help(['help'])
1377             self.help_commands()
1378             self.help_all()
1379             return 0
1380         if command == 'config':
1381             self.do_config(args[1:])
1382             return 0
1384         # figure what the command is
1385         try:
1386             functions = self.commands.get(command)
1387         except KeyError:
1388             # not a valid command
1389             print _('Unknown command "%(command)s" ("help commands" for a '
1390                 'list)')%locals()
1391             return 1
1393         # check for multiple matches
1394         if len(functions) > 1:
1395             print _('Multiple commands match "%(command)s": %(list)s')%{'command':
1396                 command, 'list': ', '.join([i[0] for i in functions])}
1397             return 1
1398         command, function = functions[0]
1400         # make sure we have a tracker_home
1401         while not self.tracker_home:
1402             self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
1404         # before we open the db, we may be doing an install or init
1405         if command == 'initialise':
1406             try:
1407                 return self.do_initialise(self.tracker_home, args)
1408             except UsageError, message:
1409                 print _('Error: %(message)s')%locals()
1410                 return 1
1411         elif command == 'install':
1412             try:
1413                 return self.do_install(self.tracker_home, args)
1414             except UsageError, message:
1415                 print _('Error: %(message)s')%locals()
1416                 return 1
1418         # get the tracker
1419         try:
1420             tracker = roundup.instance.open(self.tracker_home)
1421         except ValueError, message:
1422             self.tracker_home = ''
1423             print _("Error: Couldn't open tracker: %(message)s")%locals()
1424             return 1
1426         # only open the database once!
1427         if not self.db:
1428             self.db = tracker.open('admin')
1430         # do the command
1431         ret = 0
1432         try:
1433             ret = function(args[1:])
1434         except UsageError, message:
1435             print _('Error: %(message)s')%locals()
1436             print
1437             print function.__doc__
1438             ret = 1
1439         except:
1440             import traceback
1441             traceback.print_exc()
1442             ret = 1
1443         return ret
1445     def interactive(self):
1446         '''Run in an interactive mode
1447         '''
1448         print _('Roundup %s ready for input.\nType "help" for help.'
1449             % roundup_version)
1450         try:
1451             import readline
1452         except ImportError:
1453             print _('Note: command history and editing not available')
1455         while 1:
1456             try:
1457                 command = raw_input(_('roundup> '))
1458             except EOFError:
1459                 print _('exit...')
1460                 break
1461             if not command: continue
1462             args = token.token_split(command)
1463             if not args: continue
1464             if args[0] in ('quit', 'exit'): break
1465             self.run_command(args)
1467         # exit.. check for transactions
1468         if self.db and self.db_uncommitted:
1469             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
1470             if commit and commit[0].lower() == 'y':
1471                 self.db.commit()
1472         return 0
1474     def main(self):
1475         try:
1476             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:vV')
1477         except getopt.GetoptError, e:
1478             self.usage(str(e))
1479             return 1
1481         # handle command-line args
1482         self.tracker_home = os.environ.get('TRACKER_HOME', '')
1483         # TODO: reinstate the user/password stuff (-u arg too)
1484         name = password = ''
1485         if os.environ.has_key('ROUNDUP_LOGIN'):
1486             l = os.environ['ROUNDUP_LOGIN'].split(':')
1487             name = l[0]
1488             if len(l) > 1:
1489                 password = l[1]
1490         self.separator = None
1491         self.print_designator = 0
1492         self.verbose = 0
1493         for opt, arg in opts:
1494             if opt == '-h':
1495                 self.usage()
1496                 return 0
1497             elif opt == '-v':
1498                 print '%s (python %s)'%(roundup_version, sys.version.split()[0])
1499                 return 0
1500             elif opt == '-V':
1501                 self.verbose = 1
1502             elif opt == '-i':
1503                 self.tracker_home = arg
1504             elif opt == '-c':
1505                 if self.separator != None:
1506                     self.usage('Only one of -c, -S and -s may be specified')
1507                     return 1
1508                 self.separator = ','
1509             elif opt == '-S':
1510                 if self.separator != None:
1511                     self.usage('Only one of -c, -S and -s may be specified')
1512                     return 1
1513                 self.separator = arg
1514             elif opt == '-s':
1515                 if self.separator != None:
1516                     self.usage('Only one of -c, -S and -s may be specified')
1517                     return 1
1518                 self.separator = ' '
1519             elif opt == '-d':
1520                 self.print_designator = 1
1522         # if no command - go interactive
1523         # wrap in a try/finally so we always close off the db
1524         ret = 0
1525         try:
1526             if not args:
1527                 self.interactive()
1528             else:
1529                 ret = self.run_command(args)
1530                 if self.db: self.db.commit()
1531             return ret
1532         finally:
1533             if self.db:
1534                 self.db.close()
1536 if __name__ == '__main__':
1537     tool = AdminTool()
1538     sys.exit(tool.main())
1540 # vim: set filetype=python sts=4 sw=4 et si :