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