changeset 7752:b2dbab2b34bc

fix(refactor): multiple fixups using ruff linter; more testing. Converting to using the ruff linter and its rulesets. Fixed a number of issues. admin.py: sort imports use immutable tuples as default value markers for parameters where a None value is valid. reduced some loops to list comprehensions for performance used ternary to simplify some if statements named some variables to make them less magic (e.g. _default_savepoint_setting = 1000) fixed some tests for argument counts < 2 becomes != 2 so 3 is an error. moved exception handlers outside of loops for performance where exception handler will abort loop anyway. renamed variables called 'id' or 'dir' as they shadow builtin commands. fix translations of form _("string %s" % value) -> _("string %s") % value so translation will be looked up with the key before substitution. end dicts, tuples with a trailing comma to reduce missing comma errors if modified simplified sorted(list(self.setting.keys())) to sorted(self.setting.keys()) as sorted consumes whole list. in if conditions put compared variable on left and threshold condition on right. (no yoda conditions) multiple noqa: suppression removed unneeded noqa as lint rulesets are a bit different do_get - refactor output printing logic: Use fast return if not special formatting is requested; use isinstance with a tuple rather than two isinstance calls; cleaned up flow and removed comments on algorithm as it can be easily read from the code. do_filter, do_find - refactor output printing logic. Reduce duplicate code. do_find - renamed variable 'value' that was set inside a loop. The loop index variable was also named 'value'. do_pragma - added hint to use list subcommand if setting was not found. Replaced condition 'type(x) is bool' with 'isinstance(x, bool)' for various types. test_admin.py added testing for do_list better test coverage for do_get includes: -S and -d for multilinks, error case for -d with non-link. better testing for do_find including all output modes better testing for do_filter including all output modes fixed expected output for do_pragma that now includes hint to use pragma list if setting not found.
author John Rouillard <rouilj@ieee.org>
date Fri, 01 Mar 2024 14:53:18 -0500
parents bd013590d8d6
children ce04a489312e
files roundup/admin.py test/test_admin.py
diffstat 2 files changed, 374 insertions(+), 196 deletions(-) [+]
line wrap: on
line diff
--- a/roundup/admin.py	Fri Mar 01 14:07:28 2024 -0500
+++ b/roundup/admin.py	Fri Mar 01 14:53:18 2024 -0500
@@ -32,16 +32,22 @@
 import shutil
 import sys
 
-from roundup import date, hyperdb, init, password, token_r
+import roundup.instance
 from roundup import __version__ as roundup_version
-import roundup.instance
-from roundup.configuration import (CoreConfig, NoConfigError, Option,
-                                   OptionUnsetError, OptionValueError,
-                                   ParsingOptionError, UserConfig)
-from roundup.i18n import _, get_translation
-from roundup.exceptions import UsageError
+from roundup import date, hyperdb, init, password, token_r
 from roundup.anypy.my_input import my_input
 from roundup.anypy.strings import repr_export
+from roundup.configuration import (
+    CoreConfig,
+    NoConfigError,
+    Option,
+    OptionUnsetError,
+    OptionValueError,
+    ParsingOptionError,
+    UserConfig,
+)
+from roundup.exceptions import UsageError
+from roundup.i18n import _, get_translation
 
 try:
     from UserDict import UserDict
@@ -54,16 +60,16 @@
 
     Original code submitted by Engelbert Gruber.
     """
-    _marker = []
+    _marker = ('CommandDictMarker')
 
     def get(self, key, default=_marker):
         if key in self.data:
             return [(key, self.data[key])]
         keylist = sorted(self.data)
-        matching_keys = []
-        for ki in keylist:
-            if ki.startswith(key):
-                matching_keys.append((ki, self.data[ki]))
+
+        matching_keys = [(ki, self.data[ki]) for ki in keylist
+                          if ki.startswith(key)]
+
         if not matching_keys and default is self._marker:
             raise KeyError(key)
         # FIXME: what happens if default is not self._marker but
@@ -103,13 +109,14 @@
         self.tracker_home = ''
         self.db = None
         self.db_uncommitted = False
+        self._default_savepoint_setting = 10000
         self.force = None
         self.settings = {
             'display_header': False,
             'display_protected': False,
             'indexer_backend': "as set in config.ini",
             '_reopen_tracker': False,
-            'savepoint_limit': 10000,
+            'savepoint_limit': self._default_savepoint_setting,
             'show_retired': "no",
             '_retired_val': False,
             'verbose': False,
@@ -155,12 +162,13 @@
         """ Produce a dictionary of prop: value from the args list.
 
             The args list is specified as ``prop=value prop=value ...``.
+            A missing value is recorded as None.
         """
         props = {}
         for arg in args:
             key_val = arg.split('=', 1)
             # if = not in string, will return one element
-            if len(key_val) < 2:
+            if len(key_val) != 2:
                 raise UsageError(_('argument "%(arg)s" not propname=value') %
                                  locals())
             key, value = key_val
@@ -212,7 +220,7 @@
         commands.sort()
         commands.append(_(
 """Commands may be abbreviated as long as the abbreviation
-matches only one command, e.g. l == li == lis == list."""))  # noqa: E122
+matches only one command, e.g. l == li == lis == list."""))
         sys.stdout.write('\n'.join(commands) + '\n\n')
 
     indent_re = re.compile(r'^(\s+)\S+')
@@ -427,7 +435,7 @@
                 return default
         return argument
 
-    def do_commit(self, args):
+    def do_commit(self, args):  # noqa: ARG002
         ''"""Usage: commit
         Commit changes made to the database during an interactive session.
 
@@ -491,13 +499,13 @@
             props = self.props_from_args(args[1:])
 
         # convert types
-        for propname in props:
-            try:
+        try:
+            for propname in props:
                 props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
                                                        propname,
                                                        props[propname])
-            except hyperdb.HyperdbValueError as message:
-                raise UsageError(message)
+        except hyperdb.HyperdbValueError as message:
+            raise UsageError(message)
 
         # check for the key property
         propname = cl.getkey()
@@ -542,10 +550,8 @@
 
             # display the values
             normal_props = sorted(cl.properties)
-            if display_protected:
-                keys = sorted(cl.getprops())
-            else:
-                keys = normal_props
+
+            keys = sorted(cl.getprops()) if display_protected else normal_props
 
             if display_header:
                 status = "retired" if cl.is_retired(nodeid) else "active"
@@ -554,7 +560,7 @@
                 value = cl.get(nodeid, key)
                 # prepend * for protected properties else just indent
                 # with space.
-                if display_protected or display_header:
+                if display_protected or display_header:  # noqa: SIM108
                     protected = "*" if key not in normal_props else ' '
                 else:
                     protected = ""
@@ -577,7 +583,7 @@
         if len(args) < 1:
             raise UsageError(_('Not enough arguments supplied'))
 
-        dir = args[-1]
+        export_dir = args[-1]
 
         # get the list of classes to export
         if len(args) == 2:
@@ -593,8 +599,8 @@
             delimiter = ':'
 
         # make sure target dir exists
-        if not os.path.exists(dir):
-            os.makedirs(dir)
+        if not os.path.exists(export_dir):
+            os.makedirs(export_dir)
 
         # maximum csv field length exceeding configured size?
         max_len = self.db.config.CSV_FIELD_SIZE
@@ -607,7 +613,7 @@
                 sys.stdout.write('Exporting %s WITHOUT the files\r\n' %
                                  classname)
 
-            with open(os.path.join(dir, classname+'.csv'), 'w') as f:
+            with open(os.path.join(export_dir, classname+'.csv'), 'w') as f:
                 writer = csv.writer(f, colon_separated)
 
                 propnames = cl.export_propnames()
@@ -626,8 +632,10 @@
 
                 classkey = cl.getkey()
                 if classkey:  # False sorts before True, so negate is_retired
-                    keysort = lambda i: (cl.get(i, classkey),  # noqa: E731
-                                         not cl.is_retired(i))
+                    keysort = lambda i: (      # noqa: E731
+                        cl.get(i, classkey),   # noqa: B023 cl is not loop var
+                        not cl.is_retired(i),  # noqa: B023 cl is not loop var
+                    )
                     all_nodes.sort(key=keysort)
                 # if there is no classkey no need to sort
 
@@ -651,10 +659,10 @@
                             max_len = ll
                     writer.writerow(exp)
                     if export_files and hasattr(cl, 'export_files'):
-                        cl.export_files(dir, nodeid)
+                        cl.export_files(export_dir, nodeid)
 
             # export the journals
-            with open(os.path.join(dir, classname+'-journals.csv'), 'w') as jf:
+            with open(os.path.join(export_dir, classname+'-journals.csv'), 'w') as jf:
                 if self.verbose:
                     sys.stdout.write("\nExporting Journal for %s\n" %
                                      classname)
@@ -703,11 +711,9 @@
 
         # convert the user-input value to a value used for filter
         # multiple , separated values become a list
-        for propname, value in props.items():
-            if ',' in value:
-                values = value.split(',')
-            else:
-                values = [value]
+        for propname, prop_value in props.items():
+            values = prop_value.split(',') if ',' in prop_value \
+                else [prop_value]
 
             props[propname] = []
             # start handling transitive props
@@ -727,8 +733,8 @@
                         except KeyError:
                             raise UsageError(_(
                                 "Class %(curclassname)s has "
-                                "no property %(pn)s in %(propname)s." %
-                                locals()))
+                                "no property %(pn)s in %(propname)s.") %
+                                locals())
                         # get class object
                         curclass = self.get_class(curclassname)
                     except AttributeError:
@@ -742,27 +748,16 @@
                 props[propname].append(val)
 
         # now do the filter
+        props = {"filterspec": props}
         try:
-            id = []
-            designator = []
-            props = {"filterspec": props}
+            output_items = cl.filter(None, **props)
+            if self.print_designator:
+                output_items = [ classname + i for i in output_items ]
 
             if self.separator:
-                if self.print_designator:
-                    id = cl.filter(None, **props)
-                    for i in id:
-                        designator.append(classname + i)
-                    print(self.separator.join(designator))
-                else:
-                    print(self.separator.join(cl.filter(None, **props)))
+                print(self.separator.join(output_items))
             else:
-                if self.print_designator:
-                    id = cl.filter(None, **props)
-                    for i in id:
-                        designator.append(classname + i)
-                    print(designator)
-                else:
-                    print(cl.filter(None, **props))
+                print(output_items)
         except KeyError:
             raise UsageError(_('%(classname)s has no property '
                                '"%(propname)s"') % locals())
@@ -788,42 +783,30 @@
         props = self.props_from_args(args[1:])
 
         # convert the user-input value to a value used for find()
-        for propname, value in props.items():
-            if ',' in value:
-                values = value.split(',')
-            else:
-                values = [value]
+        for propname, prop_value in props.items():
+            values = prop_value.split(',') if ',' in prop_value \
+                else [prop_value]
+
             d = props[propname] = {}
             for value in values:
-                value = hyperdb.rawToHyperdb(self.db, cl, None,
+                val = hyperdb.rawToHyperdb(self.db, cl, None,
                                              propname, value)
-                if isinstance(value, list):
-                    for entry in value:
+                if isinstance(val, list):
+                    for entry in val:
                         d[entry] = 1
                 else:
-                    d[value] = 1
+                    d[val] = 1
 
         # now do the find
         try:
-            id = []
-            designator = []
+            output_items = cl.find(**props)
+            if self.print_designator:
+                output_items = [ classname + i for i in output_items ]
+
             if self.separator:
-                if self.print_designator:
-                    id = cl.find(**props)
-                    for i in id:
-                        designator.append(classname + i)
-                    print(self.separator.join(designator))
-                else:
-                    print(self.separator.join(cl.find(**props)))
-
+                print(self.separator.join(output_items))
             else:
-                if self.print_designator:
-                    id = cl.find(**props)
-                    for i in id:
-                        designator.append(classname + i)
-                    print(designator)
-                else:
-                    print(cl.find(**props))
+                print(output_items)
         except KeyError:
             raise UsageError(_('%(classname)s has no property '
                                '"%(propname)s"') % locals())
@@ -856,11 +839,11 @@
                         "default of %(new_number)s.") % {
                             "old_number":
                             config.PASSWORD_PBKDF2_DEFAULT_ROUNDS,
-                            "new_number": default_ppdr
+                            "new_number": default_ppdr,
                         })
                 config.PASSWORD_PBKDF2_DEFAULT_ROUNDS = default_ppdr
 
-            if config.PASSWORD_PBKDF2_DEFAULT_ROUNDS < default_ppdr:
+            if default_ppdr > config.PASSWORD_PBKDF2_DEFAULT_ROUNDS:
                 print(_("Update "
                         "'password_pbkdf2_default_rounds' "
                         "to a number equal to or larger\nthan %s.") %
@@ -896,62 +879,35 @@
             # get the class
             cl = self.get_class(classname)
             try:
-                id = []
+                if not (self.separator or self.print_designator):
+                    print(cl.get(nodeid, propname))
+                    continue
+
+                if not (isinstance(prop_obj,
+                                   (hyperdb.Link, hyperdb.Multilink))):
+                    raise UsageError(_(
+                        'property %s is not of type'
+                        ' Multilink or Link so -d flag does not '
+                        'apply.') % propname)
+                propclassname = self.db.getclass(
+                    prop_obj.classname).classname
+
+                output_items = cl.get(nodeid, propname)
+                if self.print_designator:
+                    output_items = [propclassname + i for i in output_items]
+
                 if self.separator:
-                    if self.print_designator:
-                        # see if property is a link or multilink for
-                        # which getting a desginator make sense.
-                        # Algorithm: Get the properties of the
-                        #     current designator's class. (cl.getprops)
-                        # get the property object for the property the
-                        #     user requested (properties[propname])
-                        # verify its type (isinstance...)
-                        # raise error if not link/multilink
-                        # get class name for link/multilink property
-                        # do the get on the designators
-                        # append the new designators
-                        # print
-                        properties = cl.getprops()
-                        property = properties[propname]
-                        if not (isinstance(property, hyperdb.Multilink) or
-                                isinstance(property, hyperdb.Link)):
-                            raise UsageError(_(
-                                'property %s is not of type'
-                                ' Multilink or Link so -d flag does not '
-                                'apply.') % propname)
-                        propclassname = self.db.getclass(property.classname).classname
-                        id = cl.get(nodeid, propname)
-                        for i in id:
-                            linked_props.append(propclassname + i)
-                    else:
-                        id = cl.get(nodeid, propname)
-                        for i in id:
-                            linked_props.append(i)
+                    print(self.separator.join(output_items))
                 else:
-                    if self.print_designator:
-                        properties = cl.getprops()
-                        property = properties[propname]
-                        if not (isinstance(property, hyperdb.Multilink) or
-                                isinstance(property, hyperdb.Link)):
-                            raise UsageError(_(
-                                'property %s is not of type'
-                                ' Multilink or Link so -d flag does not '
-                                'apply.') % propname)
-                        propclassname = self.db.getclass(property.classname).classname
-                        id = cl.get(nodeid, propname)
-                        for i in id:
-                            print(propclassname + i)
-                    else:
-                        print(cl.get(nodeid, propname))
+                    # default is to list each on a line
+                    print('\n'.join(output_items))
+
             except IndexError:
                 raise UsageError(_('no such %(classname)s node '
                                    '"%(nodeid)s"') % locals())
             except KeyError:
                 raise UsageError(_('no such %(classname)s property '
                                    '"%(propname)s"') % locals())
-        if self.separator:
-            print(self.separator.join(linked_props))
-
         return 0
 
     def do_help(self, args, nl_re=nl_re, indent_re=indent_re):
@@ -963,10 +919,7 @@
         initopts  -- init command options
         all       -- all available help
         """
-        if len(args) > 0:
-            topic = args[0]
-        else:
-            topic = 'help'
+        topic = args[0] if len(args) > 0 else 'help'
 
         # try help_ methods
         if topic in self.help:
@@ -981,8 +934,8 @@
             return 1
 
         # display the help for each match, removing the docstring indent
-        for _name, help in cmd_docs:
-            lines = nl_re.split(_(help.__doc__))
+        for _name, do_function in cmd_docs:
+            lines = nl_re.split(_(do_function.__doc__))
             print(lines[0])
             indent = indent_re.match(lines[1])
             if indent: indent = len(indent.group(1))  # noqa: E701
@@ -1057,7 +1010,7 @@
 
         # default value is 10000, only go through this if default
         # is different.
-        if self.settings['savepoint_limit'] != 10000:
+        if self.settings['savepoint_limit'] != self._default_savepoint_setting:
             # create a new option on the fly in the config under the
             # rdbms section. It is used by the postgresql backend's
             # checkpoint_data method.
@@ -1067,13 +1020,13 @@
                 self.settings['savepoint_limit'])
 
         # directory to import from
-        dir = args[0]
+        import_dir = args[0]
 
         class colon_separated(csv.excel):
             delimiter = ':'
 
         # import all the files
-        for file in os.listdir(dir):
+        for file in os.listdir(import_dir):
             classname, ext = os.path.splitext(file)
             # we only care about CSV files
             if ext != '.csv' or classname.endswith('-journals'):
@@ -1082,7 +1035,7 @@
             cl = self.get_class(classname)
 
             # ensure that the properties and the CSV file headings match
-            with open(os.path.join(dir, file), 'r') as f:
+            with open(os.path.join(import_dir, file), 'r') as f:
                 reader = csv.reader(f, colon_separated)
                 file_props = None
                 maxid = 1
@@ -1099,14 +1052,14 @@
                     # do the import and figure the current highest nodeid
                     nodeid = cl.import_list(file_props, r)
                     if hasattr(cl, 'import_files') and import_files:
-                        cl.import_files(dir, nodeid)
+                        cl.import_files(import_dir, nodeid)
                     maxid = max(maxid, int(nodeid))
 
                 # (print to sys.stdout here to allow tests to squash it .. ugh)
                 print(file=sys.stdout)
 
             # import the journals
-            with open(os.path.join(args[0], classname + '-journals.csv'), 'r') as f:
+            with open(os.path.join(import_dir, classname + '-journals.csv'), 'r') as f:
                 reader = csv.reader(f, colon_separated)
                 cl.import_journals(reader)
 
@@ -1160,7 +1113,7 @@
                 ok = self.my_input(_(
 """WARNING: The database is already initialised!
 If you re-initialise it, you will lose all the data!
-Erase it? Y/N: """))  # noqa: E122
+Erase it? Y/N: """))
                 if ok.strip().lower() != 'y':
                     return 0
 
@@ -1218,7 +1171,7 @@
                 ok = self.my_input(_(
 """WARNING: There appears to be a tracker in "%(tracker_home)s"!
 If you re-install it, you will lose all the data!
-Erase it? Y/N: """) % locals())  # noqa: E122
+Erase it? Y/N: """) % locals())
                 if ok.strip().lower() != 'y':
                     return 0
 
@@ -1262,7 +1215,9 @@
         # template specific.
         template_config = UserConfig(templates[template]['path'] +
                                      "/config_ini.ini")
-        for k in template_config.keys():
+
+        # .keys() is required. UserConfig has no __iter__ or __next__
+        for k in template_config.keys():  # noqa: SIM118
             if k == 'HOME':  # ignore home. It is a default param.
                 continue
             defns[k] = template_config[k]
@@ -1308,8 +1263,7 @@
  the above steps.
 ---------------------------------------------------------------------------
 """) % {'database_config_file': os.path.join(tracker_home, 'schema.py'),
-        'database_init_file': os.path.join(tracker_home, 'initial_data.py')}) \
-        # noqa: E122
+        'database_init_file': os.path.join(tracker_home, 'initial_data.py')})
         return 0
 
     def do_list(self, args):
@@ -1338,20 +1292,17 @@
         cl = self.get_class(classname)
 
         # figure the property
-        if len(args) > 1:
-            propname = args[1]
-        else:
-            propname = cl.labelprop()
+        propname = args[1] if len(args) > 1 else cl.labelprop()
 
         if self.separator:
             if len(args) == 2:
                 # create a list of propnames since user specified propname
                 proplist = []
-                for nodeid in cl.getnodeids(retired=retired):
-                    try:
-                        proplist.append(cl.get(nodeid, propname))
-                    except KeyError:
-                        raise UsageError(_('%(classname)s has no property '
+                try:
+                    proplist = [ cl.get(nodeid, propname) for nodeid in
+                                 cl.getnodeids(retired=retired)]
+                except KeyError:
+                    raise UsageError(_('%(classname)s has no property '
                                            '"%(propname)s"') % locals())
                 print(self.separator.join(proplist))
             else:
@@ -1359,16 +1310,16 @@
                 # otherwise
                 print(self.separator.join(cl.getnodeids(retired=retired)))
         else:
-            for nodeid in cl.getnodeids(retired=retired):
-                try:
+            try:
+                for nodeid in cl.getnodeids(retired=retired):
                     value = cl.get(nodeid, propname)
-                except KeyError:
-                    raise UsageError(_('%(classname)s has no property '
-                                       '"%(propname)s"') % locals())
-                print(_('%(nodeid)4s: %(value)s') % locals())
+                    print(_('%(nodeid)4s: %(value)s') % locals())
+            except KeyError:
+                raise UsageError(_('%(classname)s has no property '
+                                   '"%(propname)s"') % locals())
         return 0
 
-    def do_migrate(self, args):
+    def do_migrate(self, args):  # noqa: ARG002  - args unused
         ''"""Usage: migrate
 
         Update a tracker's database to be compatible with the Roundup
@@ -1490,7 +1441,7 @@
                     "this is a long password to hash",
                     props['scheme'],
                     None,
-                    config=self.db.config
+                    config=self.db.config,
                 )
                 toc = perf_counter()
             except password.PasswordValueError as e:
@@ -1550,7 +1501,7 @@
                 print(_("Current settings and values "
                         "(NYI - not yet implemented):"))
                 is_verbose = self.settings['verbose']
-                for key in sorted(list(self.settings.keys())):
+                for key in sorted(self.settings.keys()):
                     if key.startswith('_') and not is_verbose:
                         continue
                     print("   %s=%s" % (key, self.settings[key]))
@@ -1560,8 +1511,9 @@
                 return
 
         if setting not in self.settings:
-            raise UsageError(_('Unknown setting %s.') % setting)
-        if type(self.settings[setting]) is bool:
+            raise UsageError(_('Unknown setting %s. Try "pragma list".')
+                             % setting)
+        if isinstance(self.settings[setting], bool):
             value = value.lower()
             if value in ("yes", "true", "on", "1"):
                 value = True
@@ -1571,7 +1523,7 @@
                 raise UsageError(_(
                     'Incorrect value for boolean setting %(setting)s: '
                     '%(value)s.') % {"setting": setting, "value": value})
-        elif type(self.settings[setting]) is int:
+        elif isinstance(self.settings[setting], int):
             try:
                 _val = int(value)
             except ValueError:
@@ -1579,7 +1531,7 @@
                     'Incorrect value for integer setting %(setting)s: '
                     '%(value)s.') % {"setting": setting, "value": value})
             value = _val
-        elif type(self.settings[setting]) is str:
+        elif isinstance(self.settings[setting], str):
             if setting == "show_retired":
                 if value not in ["no", "only", "both"]:
                     raise UsageError(_(
@@ -1706,7 +1658,7 @@
         self.db_uncommitted = True
         return 0
 
-    def do_rollback(self, args):
+    def do_rollback(self, args):  # noqa: ARG002 - args unused
         ''"""Usage: rollback
         Undo all changes that are pending commit to the database.
 
@@ -1831,8 +1783,8 @@
             props = copy.copy(propset)  # make a new copy for every designator
             cl = self.get_class(classname)
 
-            for key, value in list(props.items()):
-                try:
+            try:
+                for key, value in list(props.items()):
                     # You must reinitialize the props every time though.
                     # if props['nosy'] = '+admin' initally, it gets
                     # set to 'demo,admin' (assuming it was set to demo
@@ -1841,8 +1793,8 @@
                     # designators if not reinitalized.
                     props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
                                                       key, value)
-                except hyperdb.HyperdbValueError as message:
-                    raise UsageError(message)
+            except hyperdb.HyperdbValueError as message:
+                raise UsageError(message)
 
             # try the set
             try:
@@ -1866,10 +1818,9 @@
 
         # get the key property
         keyprop = cl.getkey()
-        if self.settings['display_protected']:
-            properties = cl.getprops()
-        else:
-            properties = cl.properties
+        properties = cl.getprops() if self.settings['display_protected'] \
+            else cl.properties
+
         for key in properties:
             value = properties[key]
             if keyprop == key:
@@ -1997,12 +1948,12 @@
 
         templates = self.listTemplates(trace_search=trace_search)
 
-        for name in sorted(list(templates.keys())):
+        for name in sorted(templates.keys()):
             templates[name]['description'] = textwrap.fill(
                 "\n".join([line.lstrip() for line in
                            templates[name]['description'].split("\n")]),
                 70,
-                subsequent_indent="      "
+                subsequent_indent="      ",
             )
             print("""
 Name: %(name)s
@@ -2033,8 +1984,8 @@
         if command == 'help':
             if len(args) > 1:
                 self.do_help(args[1:])
-                return 0
-            self.do_help(['help'])
+            else:
+                self.do_help(['help'])
             return 0
         if command == 'morehelp':
             self.do_help(['help'])
@@ -2063,7 +2014,7 @@
             try:
                 ret = function(args[1:])
                 return ret
-            except UsageError as message:  # noqa F841
+            except UsageError as message:
                 return self.usageError_feedback(message, function)
 
         # make sure we have a tracker_home
@@ -2077,12 +2028,12 @@
         if command == 'initialise':
             try:
                 return self.do_initialise(self.tracker_home, args)
-            except UsageError as message:  # noqa: F841
+            except UsageError as message:
                 return self.usageError_feedback(message, function)
         elif command == 'install':
             try:
                 return self.do_install(self.tracker_home, args)
-            except UsageError as message:  # noqa: F841
+            except UsageError as message:
                 return self.usageError_feedback(message, function)
 
         # get the tracker
@@ -2096,16 +2047,16 @@
                 self.tracker = tracker
                 self.settings['indexer_backend'] = self.tracker.config['INDEXER']
 
-        except ValueError as message:  # noqa: F841
+        except ValueError as message:  # noqa: F841  -- used from locals
             self.tracker_home = ''
             print(_("Error: Couldn't open tracker: %(message)s") % locals())
             return 1
-        except NoConfigError as message:  # noqa: F841
+        except NoConfigError as message:  # noqa: F841  -- used from locals
             self.tracker_home = ''
             print(_("Error: Couldn't open tracker: %(message)s") % locals())
             return 1
         # message used via locals
-        except ParsingOptionError as message:  # noqa: F841
+        except ParsingOptionError as message:  # noqa: F841 -- used from locals
             print("%(message)s" % locals())
             return 1
 
@@ -2124,7 +2075,7 @@
         ret = 0
         try:
             ret = function(args[1:])
-        except UsageError as message:  # noqa: F841
+        except UsageError as message:
             ret = self.usageError_feedback(message, function)
         except Exception:
             import traceback
@@ -2135,8 +2086,8 @@
     def interactive(self):
         """Run in an interactive mode
         """
-        print(_('Roundup %s ready for input.\nType "help" for help.'
-                % roundup_version))
+        print(_('Roundup %s ready for input.\nType "help" for help.')
+                % roundup_version)
         try:
             import readline  # noqa: F401
         except ImportError:
@@ -2164,7 +2115,7 @@
                 self.db.commit()
         return 0
 
-    def main(self):
+    def main(self):  # noqa: PLR0912, PLR0911
         try:
             opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdP:sS:vV')
         except getopt.GetoptError as e:
--- a/test/test_admin.py	Fri Mar 01 14:07:28 2024 -0500
+++ b/test/test_admin.py	Fri Mar 01 14:53:18 2024 -0500
@@ -194,6 +194,18 @@
 
         self.admin=AdminTool()
         with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, 'create', 'issue',
+                      'title="bar foo bar"', 'assignedto=admin',
+                      'superseder=1,2']
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out, '3')
+
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
             sys.argv=['main', '-i', self.dirname, 'get', 'assignedto',
                       'issue2' ]
             ret = self.admin.main()
@@ -206,6 +218,32 @@
 
         self.admin=AdminTool()
         with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-d',
+                      'get', 'assignedto',
+                      'issue2' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        err = err.getvalue().strip()
+        self.assertEqual(out, 'user2')
+        self.assertEqual(len(err), 0)
+
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-d', '-S', ':',
+                      'get', 'assignedto',
+                      'issue2' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        err = err.getvalue().strip()
+        self.assertEqual(out, 'user2')
+        self.assertEqual(len(err), 0)
+
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
             sys.argv=['main', '-i', self.dirname, 'get', 'superseder',
                       'issue2' ]
             ret = self.admin.main()
@@ -218,6 +256,57 @@
 
         self.admin=AdminTool()
         with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, 'get', 'superseder',
+                      'issue3' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        err = err.getvalue().strip()
+        self.assertEqual(out, "['1', '2']")
+        self.assertEqual(len(err), 0)
+
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-d',
+                      'get', 'superseder',
+                      'issue3' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        err = err.getvalue().strip()
+        self.assertEqual(out, "issue1\nissue2")
+        self.assertEqual(len(err), 0)
+
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-c', '-d',
+                      'get', 'superseder',
+                      'issue3' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        err = err.getvalue().strip()
+        self.assertEqual(out, "issue1,issue2")
+        self.assertEqual(len(err), 0)
+
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-d',
+                      'get', 'title',
+                      'issue3' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 1)
+        out = out.getvalue().strip()
+        err = err.getvalue().strip()
+        self.assertEqual(out.split('\n')[0], "Error: property title is not of type Multilink or Link so -d flag does not apply.")
+        self.assertEqual(len(err), 0)
+
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
             sys.argv=['main', '-i', self.dirname, 'get', 'title', 'issue1']
             ret = self.admin.main()
 
@@ -253,6 +342,20 @@
         self.assertEqual(out.index(expected_err), 0)
         self.assertEqual(len(err), 0)
 
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, 'get', 'title', 'issue500']
+            ret = self.admin.main()
+
+        expected_err = 'Error: no such issue node "500"'
+
+        self.assertEqual(ret, 1)
+        out = out.getvalue().strip()
+        err = err.getvalue().strip()
+        print(out)
+        self.assertEqual(out.index(expected_err), 0)
+        self.assertEqual(len(err), 0)
+
     def testInit(self):
         self.admin=AdminTool()
         sys.argv=['main', '-i', self.dirname, 'install', 'classic', self.backend]
@@ -305,6 +408,88 @@
         config=CoreConfig(self.dirname)
         self.assertEqual(config['MAIL_DEBUG'], self.dirname + "/SendMail.LOG")
 
+    def testList(self):
+        ''' Note the tests will fail if you run this under pdb.
+            the context managers capture the pdb prompts and this screws
+            up the stdout strings with (pdb) prefixed to the line.
+        '''
+        self.install_init()
+        self.admin=AdminTool()
+
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, 'list', 'user',
+                      'username' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out, '1: admin\n   2: anonymous')
+
+        self.admin=AdminTool()
+
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-c',
+                      'list', 'user' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out, '1,2')
+
+        self.admin=AdminTool()
+
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-c',
+                      'list', 'user', 'username' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out, 'admin,anonymous')
+
+        self.admin=AdminTool()
+
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-c',
+                      'list', 'user', 'roles' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 0)
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out, 'Admin,Anonymous')
+
+        self.admin=AdminTool()
+
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, 'list', 'user',
+                      'foo' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 1)
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out.split('\n')[0],
+                         'Error: user has no property "foo"')
+
+
+        self.admin=AdminTool()
+
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname, '-c',
+                      'list', 'user',
+                      'bar' ]
+            ret = self.admin.main()
+
+        self.assertEqual(ret, 1)
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out.split('\n')[0],
+                         'Error: user has no property "bar"')
+
     def testFind(self):
         ''' Note the tests will fail if you run this under pdb.
             the context managers capture the pdb prompts and this screws
@@ -374,6 +559,34 @@
         # so eval to real list so Equal can do a list compare
         self.assertEqual(sorted(eval(out)), ['1', '2'])
 
+        # Reopen the db closed by previous filter call
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            ''' 1,2 should return all entries that have assignedto
+                either admin or anonymous
+            '''
+            sys.argv=['main', '-i', self.dirname, '-c', '-d',
+                      'find', 'issue', 'assignedto=admin,anonymous']
+            ret = self.admin.main()
+
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out, "issue1,issue2")
+
+        # Reopen the db closed by previous filter call
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            ''' 1,2 should return all entries that have assignedto
+                either admin or anonymous
+            '''
+            sys.argv=['main', '-i', self.dirname, '-S', ':',
+                      'find', 'issue', 'assignedto=admin,anonymous']
+            ret = self.admin.main()
+
+        out = out.getvalue().strip()
+        print(out)
+        self.assertEqual(out, "1:2")
+
     def testGenconfigUpdate(self):
         ''' Note the tests will fail if you run this under pdb.
             the context managers capture the pdb prompts and this screws
@@ -822,6 +1035,20 @@
         print(err.getvalue().strip())
         self.assertEqual(out, "issue1:issue2")
 
+        # Reopen the db closed by previous filter call
+        # 
+        # case: transitive property invalid match
+        self.admin=AdminTool()
+        with captured_output() as (out, err):
+            sys.argv=['main', '-i', self.dirname,
+                      '-d', 'filter', 'issue',
+                      'assignedto.username=A']
+            ret = self.admin.main()
+        out = out.getvalue().strip()
+        print("me: " + out)
+        print(err.getvalue().strip())
+        self.assertEqual(out, "['issue1', 'issue2']")
+
     def testPragma_reopen_tracker(self):
         """test that _reopen_tracker works.
         """
@@ -942,7 +1169,7 @@
         self.assertIn(expected, out)
         expected = 'Error: Argument must be setting=value, was given: arg.'
         self.assertIn(expected, out)
-        expected = 'Error: Unknown setting foo.'
+        expected = 'Error: Unknown setting foo. Try "pragma list".'
         self.assertIn(expected, out)
 
         # -----

Roundup Issue Tracker: http://roundup-tracker.org/