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