comparison roundup/admin.py @ 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 4dda4a9dfe0b
children 09b216591db5
comparison
equal deleted inserted replaced
7751:bd013590d8d6 7752:b2dbab2b34bc
30 import os 30 import os
31 import re 31 import re
32 import shutil 32 import shutil
33 import sys 33 import sys
34 34
35 import roundup.instance
36 from roundup import __version__ as roundup_version
35 from roundup import date, hyperdb, init, password, token_r 37 from roundup import date, hyperdb, init, password, token_r
36 from roundup import __version__ as roundup_version
37 import roundup.instance
38 from roundup.configuration import (CoreConfig, NoConfigError, Option,
39 OptionUnsetError, OptionValueError,
40 ParsingOptionError, UserConfig)
41 from roundup.i18n import _, get_translation
42 from roundup.exceptions import UsageError
43 from roundup.anypy.my_input import my_input 38 from roundup.anypy.my_input import my_input
44 from roundup.anypy.strings import repr_export 39 from roundup.anypy.strings import repr_export
40 from roundup.configuration import (
41 CoreConfig,
42 NoConfigError,
43 Option,
44 OptionUnsetError,
45 OptionValueError,
46 ParsingOptionError,
47 UserConfig,
48 )
49 from roundup.exceptions import UsageError
50 from roundup.i18n import _, get_translation
45 51
46 try: 52 try:
47 from UserDict import UserDict 53 from UserDict import UserDict
48 except ImportError: 54 except ImportError:
49 from collections import UserDict 55 from collections import UserDict
52 class CommandDict(UserDict): 58 class CommandDict(UserDict):
53 """Simple dictionary that lets us do lookups using partial keys. 59 """Simple dictionary that lets us do lookups using partial keys.
54 60
55 Original code submitted by Engelbert Gruber. 61 Original code submitted by Engelbert Gruber.
56 """ 62 """
57 _marker = [] 63 _marker = ('CommandDictMarker')
58 64
59 def get(self, key, default=_marker): 65 def get(self, key, default=_marker):
60 if key in self.data: 66 if key in self.data:
61 return [(key, self.data[key])] 67 return [(key, self.data[key])]
62 keylist = sorted(self.data) 68 keylist = sorted(self.data)
63 matching_keys = [] 69
64 for ki in keylist: 70 matching_keys = [(ki, self.data[ki]) for ki in keylist
65 if ki.startswith(key): 71 if ki.startswith(key)]
66 matching_keys.append((ki, self.data[ki])) 72
67 if not matching_keys and default is self._marker: 73 if not matching_keys and default is self._marker:
68 raise KeyError(key) 74 raise KeyError(key)
69 # FIXME: what happens if default is not self._marker but 75 # FIXME: what happens if default is not self._marker but
70 # there are no matching keys? Should (default, self.data[default]) 76 # there are no matching keys? Should (default, self.data[default])
71 # be returned??? 77 # be returned???
101 self.help[k[5:]] = getattr(self, k) 107 self.help[k[5:]] = getattr(self, k)
102 self.tracker = None 108 self.tracker = None
103 self.tracker_home = '' 109 self.tracker_home = ''
104 self.db = None 110 self.db = None
105 self.db_uncommitted = False 111 self.db_uncommitted = False
112 self._default_savepoint_setting = 10000
106 self.force = None 113 self.force = None
107 self.settings = { 114 self.settings = {
108 'display_header': False, 115 'display_header': False,
109 'display_protected': False, 116 'display_protected': False,
110 'indexer_backend': "as set in config.ini", 117 'indexer_backend': "as set in config.ini",
111 '_reopen_tracker': False, 118 '_reopen_tracker': False,
112 'savepoint_limit': 10000, 119 'savepoint_limit': self._default_savepoint_setting,
113 'show_retired': "no", 120 'show_retired': "no",
114 '_retired_val': False, 121 '_retired_val': False,
115 'verbose': False, 122 'verbose': False,
116 '_inttest': 3, 123 '_inttest': 3,
117 '_floattest': 3.5, 124 '_floattest': 3.5,
153 160
154 def props_from_args(self, args): 161 def props_from_args(self, args):
155 """ Produce a dictionary of prop: value from the args list. 162 """ Produce a dictionary of prop: value from the args list.
156 163
157 The args list is specified as ``prop=value prop=value ...``. 164 The args list is specified as ``prop=value prop=value ...``.
165 A missing value is recorded as None.
158 """ 166 """
159 props = {} 167 props = {}
160 for arg in args: 168 for arg in args:
161 key_val = arg.split('=', 1) 169 key_val = arg.split('=', 1)
162 # if = not in string, will return one element 170 # if = not in string, will return one element
163 if len(key_val) < 2: 171 if len(key_val) != 2:
164 raise UsageError(_('argument "%(arg)s" not propname=value') % 172 raise UsageError(_('argument "%(arg)s" not propname=value') %
165 locals()) 173 locals())
166 key, value = key_val 174 key, value = key_val
167 if value: 175 if value:
168 props[key] = value 176 props[key] = value
210 h = _(command.__doc__).split('\n')[0] 218 h = _(command.__doc__).split('\n')[0]
211 commands.append(' '+h[7:]) 219 commands.append(' '+h[7:])
212 commands.sort() 220 commands.sort()
213 commands.append(_( 221 commands.append(_(
214 """Commands may be abbreviated as long as the abbreviation 222 """Commands may be abbreviated as long as the abbreviation
215 matches only one command, e.g. l == li == lis == list.""")) # noqa: E122 223 matches only one command, e.g. l == li == lis == list."""))
216 sys.stdout.write('\n'.join(commands) + '\n\n') 224 sys.stdout.write('\n'.join(commands) + '\n\n')
217 225
218 indent_re = re.compile(r'^(\s+)\S+') 226 indent_re = re.compile(r'^(\s+)\S+')
219 227
220 def help_commands_html(self, indent_re=indent_re): 228 def help_commands_html(self, indent_re=indent_re):
425 argument = self.my_input('%s [%s]: ' % (prompt, default)) 433 argument = self.my_input('%s [%s]: ' % (prompt, default))
426 if not argument: 434 if not argument:
427 return default 435 return default
428 return argument 436 return argument
429 437
430 def do_commit(self, args): 438 def do_commit(self, args): # noqa: ARG002
431 ''"""Usage: commit 439 ''"""Usage: commit
432 Commit changes made to the database during an interactive session. 440 Commit changes made to the database during an interactive session.
433 441
434 The changes made during an interactive session are not 442 The changes made during an interactive session are not
435 automatically written to the database - they must be committed 443 automatically written to the database - they must be committed
489 props[key] = value 497 props[key] = value
490 else: 498 else:
491 props = self.props_from_args(args[1:]) 499 props = self.props_from_args(args[1:])
492 500
493 # convert types 501 # convert types
494 for propname in props: 502 try:
495 try: 503 for propname in props:
496 props[propname] = hyperdb.rawToHyperdb(self.db, cl, None, 504 props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
497 propname, 505 propname,
498 props[propname]) 506 props[propname])
499 except hyperdb.HyperdbValueError as message: 507 except hyperdb.HyperdbValueError as message:
500 raise UsageError(message) 508 raise UsageError(message)
501 509
502 # check for the key property 510 # check for the key property
503 propname = cl.getkey() 511 propname = cl.getkey()
504 if propname and propname not in props: 512 if propname and propname not in props:
505 raise UsageError(_('you must provide the "%(propname)s" ' 513 raise UsageError(_('you must provide the "%(propname)s" '
540 # get the class 548 # get the class
541 cl = self.get_class(classname) 549 cl = self.get_class(classname)
542 550
543 # display the values 551 # display the values
544 normal_props = sorted(cl.properties) 552 normal_props = sorted(cl.properties)
545 if display_protected: 553
546 keys = sorted(cl.getprops()) 554 keys = sorted(cl.getprops()) if display_protected else normal_props
547 else:
548 keys = normal_props
549 555
550 if display_header: 556 if display_header:
551 status = "retired" if cl.is_retired(nodeid) else "active" 557 status = "retired" if cl.is_retired(nodeid) else "active"
552 print('\n[%s (%s)]' % (designator, status)) 558 print('\n[%s (%s)]' % (designator, status))
553 for key in keys: 559 for key in keys:
554 value = cl.get(nodeid, key) 560 value = cl.get(nodeid, key)
555 # prepend * for protected properties else just indent 561 # prepend * for protected properties else just indent
556 # with space. 562 # with space.
557 if display_protected or display_header: 563 if display_protected or display_header: # noqa: SIM108
558 protected = "*" if key not in normal_props else ' ' 564 protected = "*" if key not in normal_props else ' '
559 else: 565 else:
560 protected = "" 566 protected = ""
561 print(_('%(protected)s%(key)s: %(value)s') % locals()) 567 print(_('%(protected)s%(key)s: %(value)s') % locals())
562 568
575 """ 581 """
576 # grab the directory to export to 582 # grab the directory to export to
577 if len(args) < 1: 583 if len(args) < 1:
578 raise UsageError(_('Not enough arguments supplied')) 584 raise UsageError(_('Not enough arguments supplied'))
579 585
580 dir = args[-1] 586 export_dir = args[-1]
581 587
582 # get the list of classes to export 588 # get the list of classes to export
583 if len(args) == 2: 589 if len(args) == 2:
584 if args[0].startswith('-'): 590 if args[0].startswith('-'):
585 classes = [c for c in self.db.classes 591 classes = [c for c in self.db.classes
591 597
592 class colon_separated(csv.excel): 598 class colon_separated(csv.excel):
593 delimiter = ':' 599 delimiter = ':'
594 600
595 # make sure target dir exists 601 # make sure target dir exists
596 if not os.path.exists(dir): 602 if not os.path.exists(export_dir):
597 os.makedirs(dir) 603 os.makedirs(export_dir)
598 604
599 # maximum csv field length exceeding configured size? 605 # maximum csv field length exceeding configured size?
600 max_len = self.db.config.CSV_FIELD_SIZE 606 max_len = self.db.config.CSV_FIELD_SIZE
601 607
602 # do all the classes specified 608 # do all the classes specified
605 611
606 if not export_files and hasattr(cl, 'export_files'): 612 if not export_files and hasattr(cl, 'export_files'):
607 sys.stdout.write('Exporting %s WITHOUT the files\r\n' % 613 sys.stdout.write('Exporting %s WITHOUT the files\r\n' %
608 classname) 614 classname)
609 615
610 with open(os.path.join(dir, classname+'.csv'), 'w') as f: 616 with open(os.path.join(export_dir, classname+'.csv'), 'w') as f:
611 writer = csv.writer(f, colon_separated) 617 writer = csv.writer(f, colon_separated)
612 618
613 propnames = cl.export_propnames() 619 propnames = cl.export_propnames()
614 fields = propnames[:] 620 fields = propnames[:]
615 fields.append('is retired') 621 fields.append('is retired')
624 # on imports to rdbms. 630 # on imports to rdbms.
625 all_nodes = cl.getnodeids() 631 all_nodes = cl.getnodeids()
626 632
627 classkey = cl.getkey() 633 classkey = cl.getkey()
628 if classkey: # False sorts before True, so negate is_retired 634 if classkey: # False sorts before True, so negate is_retired
629 keysort = lambda i: (cl.get(i, classkey), # noqa: E731 635 keysort = lambda i: ( # noqa: E731
630 not cl.is_retired(i)) 636 cl.get(i, classkey), # noqa: B023 cl is not loop var
637 not cl.is_retired(i), # noqa: B023 cl is not loop var
638 )
631 all_nodes.sort(key=keysort) 639 all_nodes.sort(key=keysort)
632 # if there is no classkey no need to sort 640 # if there is no classkey no need to sort
633 641
634 for nodeid in all_nodes: 642 for nodeid in all_nodes:
635 if self.verbose: 643 if self.verbose:
649 ll = len(repr_export(node[p])) + d 657 ll = len(repr_export(node[p])) + d
650 if ll > max_len: 658 if ll > max_len:
651 max_len = ll 659 max_len = ll
652 writer.writerow(exp) 660 writer.writerow(exp)
653 if export_files and hasattr(cl, 'export_files'): 661 if export_files and hasattr(cl, 'export_files'):
654 cl.export_files(dir, nodeid) 662 cl.export_files(export_dir, nodeid)
655 663
656 # export the journals 664 # export the journals
657 with open(os.path.join(dir, classname+'-journals.csv'), 'w') as jf: 665 with open(os.path.join(export_dir, classname+'-journals.csv'), 'w') as jf:
658 if self.verbose: 666 if self.verbose:
659 sys.stdout.write("\nExporting Journal for %s\n" % 667 sys.stdout.write("\nExporting Journal for %s\n" %
660 classname) 668 classname)
661 sys.stdout.flush() 669 sys.stdout.flush()
662 journals = csv.writer(jf, colon_separated) 670 journals = csv.writer(jf, colon_separated)
701 # handle the propname=value argument 709 # handle the propname=value argument
702 props = self.props_from_args(args[1:]) 710 props = self.props_from_args(args[1:])
703 711
704 # convert the user-input value to a value used for filter 712 # convert the user-input value to a value used for filter
705 # multiple , separated values become a list 713 # multiple , separated values become a list
706 for propname, value in props.items(): 714 for propname, prop_value in props.items():
707 if ',' in value: 715 values = prop_value.split(',') if ',' in prop_value \
708 values = value.split(',') 716 else [prop_value]
709 else:
710 values = [value]
711 717
712 props[propname] = [] 718 props[propname] = []
713 # start handling transitive props 719 # start handling transitive props
714 # given filter issue assignedto.roles=Admin 720 # given filter issue assignedto.roles=Admin
715 # start at issue 721 # start at issue
725 try: 731 try:
726 curclassname = curclass.getprops()[pn].classname 732 curclassname = curclass.getprops()[pn].classname
727 except KeyError: 733 except KeyError:
728 raise UsageError(_( 734 raise UsageError(_(
729 "Class %(curclassname)s has " 735 "Class %(curclassname)s has "
730 "no property %(pn)s in %(propname)s." % 736 "no property %(pn)s in %(propname)s.") %
731 locals())) 737 locals())
732 # get class object 738 # get class object
733 curclass = self.get_class(curclassname) 739 curclass = self.get_class(curclassname)
734 except AttributeError: 740 except AttributeError:
735 # curclass.getprops()[pn].classname raises this 741 # curclass.getprops()[pn].classname raises this
736 # when we are at a non link/multilink property 742 # when we are at a non link/multilink property
740 val = hyperdb.rawToHyperdb(self.db, curclass, None, 746 val = hyperdb.rawToHyperdb(self.db, curclass, None,
741 lastprop, value) 747 lastprop, value)
742 props[propname].append(val) 748 props[propname].append(val)
743 749
744 # now do the filter 750 # now do the filter
751 props = {"filterspec": props}
745 try: 752 try:
746 id = [] 753 output_items = cl.filter(None, **props)
747 designator = [] 754 if self.print_designator:
748 props = {"filterspec": props} 755 output_items = [ classname + i for i in output_items ]
749 756
750 if self.separator: 757 if self.separator:
751 if self.print_designator: 758 print(self.separator.join(output_items))
752 id = cl.filter(None, **props)
753 for i in id:
754 designator.append(classname + i)
755 print(self.separator.join(designator))
756 else:
757 print(self.separator.join(cl.filter(None, **props)))
758 else: 759 else:
759 if self.print_designator: 760 print(output_items)
760 id = cl.filter(None, **props)
761 for i in id:
762 designator.append(classname + i)
763 print(designator)
764 else:
765 print(cl.filter(None, **props))
766 except KeyError: 761 except KeyError:
767 raise UsageError(_('%(classname)s has no property ' 762 raise UsageError(_('%(classname)s has no property '
768 '"%(propname)s"') % locals()) 763 '"%(propname)s"') % locals())
769 except (ValueError, TypeError) as message: 764 except (ValueError, TypeError) as message:
770 raise UsageError(message) 765 raise UsageError(message)
786 781
787 # handle the propname=value argument 782 # handle the propname=value argument
788 props = self.props_from_args(args[1:]) 783 props = self.props_from_args(args[1:])
789 784
790 # convert the user-input value to a value used for find() 785 # convert the user-input value to a value used for find()
791 for propname, value in props.items(): 786 for propname, prop_value in props.items():
792 if ',' in value: 787 values = prop_value.split(',') if ',' in prop_value \
793 values = value.split(',') 788 else [prop_value]
794 else: 789
795 values = [value]
796 d = props[propname] = {} 790 d = props[propname] = {}
797 for value in values: 791 for value in values:
798 value = hyperdb.rawToHyperdb(self.db, cl, None, 792 val = hyperdb.rawToHyperdb(self.db, cl, None,
799 propname, value) 793 propname, value)
800 if isinstance(value, list): 794 if isinstance(val, list):
801 for entry in value: 795 for entry in val:
802 d[entry] = 1 796 d[entry] = 1
803 else: 797 else:
804 d[value] = 1 798 d[val] = 1
805 799
806 # now do the find 800 # now do the find
807 try: 801 try:
808 id = [] 802 output_items = cl.find(**props)
809 designator = [] 803 if self.print_designator:
804 output_items = [ classname + i for i in output_items ]
805
810 if self.separator: 806 if self.separator:
811 if self.print_designator: 807 print(self.separator.join(output_items))
812 id = cl.find(**props)
813 for i in id:
814 designator.append(classname + i)
815 print(self.separator.join(designator))
816 else:
817 print(self.separator.join(cl.find(**props)))
818
819 else: 808 else:
820 if self.print_designator: 809 print(output_items)
821 id = cl.find(**props)
822 for i in id:
823 designator.append(classname + i)
824 print(designator)
825 else:
826 print(cl.find(**props))
827 except KeyError: 810 except KeyError:
828 raise UsageError(_('%(classname)s has no property ' 811 raise UsageError(_('%(classname)s has no property '
829 '"%(propname)s"') % locals()) 812 '"%(propname)s"') % locals())
830 except (ValueError, TypeError) as message: 813 except (ValueError, TypeError) as message:
831 raise UsageError(message) 814 raise UsageError(message)
854 " 'password_pbkdf2_default_rounds'\n" 837 " 'password_pbkdf2_default_rounds'\n"
855 "from old default of %(old_number)s to new " 838 "from old default of %(old_number)s to new "
856 "default of %(new_number)s.") % { 839 "default of %(new_number)s.") % {
857 "old_number": 840 "old_number":
858 config.PASSWORD_PBKDF2_DEFAULT_ROUNDS, 841 config.PASSWORD_PBKDF2_DEFAULT_ROUNDS,
859 "new_number": default_ppdr 842 "new_number": default_ppdr,
860 }) 843 })
861 config.PASSWORD_PBKDF2_DEFAULT_ROUNDS = default_ppdr 844 config.PASSWORD_PBKDF2_DEFAULT_ROUNDS = default_ppdr
862 845
863 if config.PASSWORD_PBKDF2_DEFAULT_ROUNDS < default_ppdr: 846 if default_ppdr > config.PASSWORD_PBKDF2_DEFAULT_ROUNDS:
864 print(_("Update " 847 print(_("Update "
865 "'password_pbkdf2_default_rounds' " 848 "'password_pbkdf2_default_rounds' "
866 "to a number equal to or larger\nthan %s.") % 849 "to a number equal to or larger\nthan %s.") %
867 default_ppdr) 850 default_ppdr)
868 else: 851 else:
894 raise UsageError(message) 877 raise UsageError(message)
895 878
896 # get the class 879 # get the class
897 cl = self.get_class(classname) 880 cl = self.get_class(classname)
898 try: 881 try:
899 id = [] 882 if not (self.separator or self.print_designator):
883 print(cl.get(nodeid, propname))
884 continue
885
886 if not (isinstance(prop_obj,
887 (hyperdb.Link, hyperdb.Multilink))):
888 raise UsageError(_(
889 'property %s is not of type'
890 ' Multilink or Link so -d flag does not '
891 'apply.') % propname)
892 propclassname = self.db.getclass(
893 prop_obj.classname).classname
894
895 output_items = cl.get(nodeid, propname)
896 if self.print_designator:
897 output_items = [propclassname + i for i in output_items]
898
900 if self.separator: 899 if self.separator:
901 if self.print_designator: 900 print(self.separator.join(output_items))
902 # see if property is a link or multilink for
903 # which getting a desginator make sense.
904 # Algorithm: Get the properties of the
905 # current designator's class. (cl.getprops)
906 # get the property object for the property the
907 # user requested (properties[propname])
908 # verify its type (isinstance...)
909 # raise error if not link/multilink
910 # get class name for link/multilink property
911 # do the get on the designators
912 # append the new designators
913 # print
914 properties = cl.getprops()
915 property = properties[propname]
916 if not (isinstance(property, hyperdb.Multilink) or
917 isinstance(property, hyperdb.Link)):
918 raise UsageError(_(
919 'property %s is not of type'
920 ' Multilink or Link so -d flag does not '
921 'apply.') % propname)
922 propclassname = self.db.getclass(property.classname).classname
923 id = cl.get(nodeid, propname)
924 for i in id:
925 linked_props.append(propclassname + i)
926 else:
927 id = cl.get(nodeid, propname)
928 for i in id:
929 linked_props.append(i)
930 else: 901 else:
931 if self.print_designator: 902 # default is to list each on a line
932 properties = cl.getprops() 903 print('\n'.join(output_items))
933 property = properties[propname] 904
934 if not (isinstance(property, hyperdb.Multilink) or
935 isinstance(property, hyperdb.Link)):
936 raise UsageError(_(
937 'property %s is not of type'
938 ' Multilink or Link so -d flag does not '
939 'apply.') % propname)
940 propclassname = self.db.getclass(property.classname).classname
941 id = cl.get(nodeid, propname)
942 for i in id:
943 print(propclassname + i)
944 else:
945 print(cl.get(nodeid, propname))
946 except IndexError: 905 except IndexError:
947 raise UsageError(_('no such %(classname)s node ' 906 raise UsageError(_('no such %(classname)s node '
948 '"%(nodeid)s"') % locals()) 907 '"%(nodeid)s"') % locals())
949 except KeyError: 908 except KeyError:
950 raise UsageError(_('no such %(classname)s property ' 909 raise UsageError(_('no such %(classname)s property '
951 '"%(propname)s"') % locals()) 910 '"%(propname)s"') % locals())
952 if self.separator:
953 print(self.separator.join(linked_props))
954
955 return 0 911 return 0
956 912
957 def do_help(self, args, nl_re=nl_re, indent_re=indent_re): 913 def do_help(self, args, nl_re=nl_re, indent_re=indent_re):
958 ''"""Usage: help topic 914 ''"""Usage: help topic
959 Give help about topic. 915 Give help about topic.
961 commands -- list commands 917 commands -- list commands
962 <command> -- help specific to a command 918 <command> -- help specific to a command
963 initopts -- init command options 919 initopts -- init command options
964 all -- all available help 920 all -- all available help
965 """ 921 """
966 if len(args) > 0: 922 topic = args[0] if len(args) > 0 else 'help'
967 topic = args[0]
968 else:
969 topic = 'help'
970 923
971 # try help_ methods 924 # try help_ methods
972 if topic in self.help: 925 if topic in self.help:
973 self.help[topic]() 926 self.help[topic]()
974 return 0 927 return 0
979 except KeyError: 932 except KeyError:
980 print(_('Sorry, no help for "%(topic)s"') % locals()) 933 print(_('Sorry, no help for "%(topic)s"') % locals())
981 return 1 934 return 1
982 935
983 # display the help for each match, removing the docstring indent 936 # display the help for each match, removing the docstring indent
984 for _name, help in cmd_docs: 937 for _name, do_function in cmd_docs:
985 lines = nl_re.split(_(help.__doc__)) 938 lines = nl_re.split(_(do_function.__doc__))
986 print(lines[0]) 939 print(lines[0])
987 indent = indent_re.match(lines[1]) 940 indent = indent_re.match(lines[1])
988 if indent: indent = len(indent.group(1)) # noqa: E701 941 if indent: indent = len(indent.group(1)) # noqa: E701
989 for line in lines[1:]: 942 for line in lines[1:]:
990 if indent: 943 if indent:
1055 if hasattr(csv, 'field_size_limit'): 1008 if hasattr(csv, 'field_size_limit'):
1056 csv.field_size_limit(self.db.config.CSV_FIELD_SIZE) 1009 csv.field_size_limit(self.db.config.CSV_FIELD_SIZE)
1057 1010
1058 # default value is 10000, only go through this if default 1011 # default value is 10000, only go through this if default
1059 # is different. 1012 # is different.
1060 if self.settings['savepoint_limit'] != 10000: 1013 if self.settings['savepoint_limit'] != self._default_savepoint_setting:
1061 # create a new option on the fly in the config under the 1014 # create a new option on the fly in the config under the
1062 # rdbms section. It is used by the postgresql backend's 1015 # rdbms section. It is used by the postgresql backend's
1063 # checkpoint_data method. 1016 # checkpoint_data method.
1064 self.db.config.add_option(Option(self.db.config, 1017 self.db.config.add_option(Option(self.db.config,
1065 "rdbms", "savepoint_limit")) 1018 "rdbms", "savepoint_limit"))
1066 self.db.config.options["RDBMS_SAVEPOINT_LIMIT"].set( 1019 self.db.config.options["RDBMS_SAVEPOINT_LIMIT"].set(
1067 self.settings['savepoint_limit']) 1020 self.settings['savepoint_limit'])
1068 1021
1069 # directory to import from 1022 # directory to import from
1070 dir = args[0] 1023 import_dir = args[0]
1071 1024
1072 class colon_separated(csv.excel): 1025 class colon_separated(csv.excel):
1073 delimiter = ':' 1026 delimiter = ':'
1074 1027
1075 # import all the files 1028 # import all the files
1076 for file in os.listdir(dir): 1029 for file in os.listdir(import_dir):
1077 classname, ext = os.path.splitext(file) 1030 classname, ext = os.path.splitext(file)
1078 # we only care about CSV files 1031 # we only care about CSV files
1079 if ext != '.csv' or classname.endswith('-journals'): 1032 if ext != '.csv' or classname.endswith('-journals'):
1080 continue 1033 continue
1081 1034
1082 cl = self.get_class(classname) 1035 cl = self.get_class(classname)
1083 1036
1084 # ensure that the properties and the CSV file headings match 1037 # ensure that the properties and the CSV file headings match
1085 with open(os.path.join(dir, file), 'r') as f: 1038 with open(os.path.join(import_dir, file), 'r') as f:
1086 reader = csv.reader(f, colon_separated) 1039 reader = csv.reader(f, colon_separated)
1087 file_props = None 1040 file_props = None
1088 maxid = 1 1041 maxid = 1
1089 # loop through the file and create a node for each entry 1042 # loop through the file and create a node for each entry
1090 for n, r in enumerate(reader): 1043 for n, r in enumerate(reader):
1097 sys.stdout.flush() 1050 sys.stdout.flush()
1098 1051
1099 # do the import and figure the current highest nodeid 1052 # do the import and figure the current highest nodeid
1100 nodeid = cl.import_list(file_props, r) 1053 nodeid = cl.import_list(file_props, r)
1101 if hasattr(cl, 'import_files') and import_files: 1054 if hasattr(cl, 'import_files') and import_files:
1102 cl.import_files(dir, nodeid) 1055 cl.import_files(import_dir, nodeid)
1103 maxid = max(maxid, int(nodeid)) 1056 maxid = max(maxid, int(nodeid))
1104 1057
1105 # (print to sys.stdout here to allow tests to squash it .. ugh) 1058 # (print to sys.stdout here to allow tests to squash it .. ugh)
1106 print(file=sys.stdout) 1059 print(file=sys.stdout)
1107 1060
1108 # import the journals 1061 # import the journals
1109 with open(os.path.join(args[0], classname + '-journals.csv'), 'r') as f: 1062 with open(os.path.join(import_dir, classname + '-journals.csv'), 'r') as f:
1110 reader = csv.reader(f, colon_separated) 1063 reader = csv.reader(f, colon_separated)
1111 cl.import_journals(reader) 1064 cl.import_journals(reader)
1112 1065
1113 # (print to sys.stdout here to allow tests to squash it .. ugh) 1066 # (print to sys.stdout here to allow tests to squash it .. ugh)
1114 print('setting', classname, maxid+1, file=sys.stdout) 1067 print('setting', classname, maxid+1, file=sys.stdout)
1158 if tracker.exists(): 1111 if tracker.exists():
1159 if not self.force: 1112 if not self.force:
1160 ok = self.my_input(_( 1113 ok = self.my_input(_(
1161 """WARNING: The database is already initialised! 1114 """WARNING: The database is already initialised!
1162 If you re-initialise it, you will lose all the data! 1115 If you re-initialise it, you will lose all the data!
1163 Erase it? Y/N: """)) # noqa: E122 1116 Erase it? Y/N: """))
1164 if ok.strip().lower() != 'y': 1117 if ok.strip().lower() != 'y':
1165 return 0 1118 return 0
1166 1119
1167 # nuke it 1120 # nuke it
1168 tracker.nuke() 1121 tracker.nuke()
1216 os.path.join(tracker_home, 'config.py')])): 1169 os.path.join(tracker_home, 'config.py')])):
1217 if not self.force: 1170 if not self.force:
1218 ok = self.my_input(_( 1171 ok = self.my_input(_(
1219 """WARNING: There appears to be a tracker in "%(tracker_home)s"! 1172 """WARNING: There appears to be a tracker in "%(tracker_home)s"!
1220 If you re-install it, you will lose all the data! 1173 If you re-install it, you will lose all the data!
1221 Erase it? Y/N: """) % locals()) # noqa: E122 1174 Erase it? Y/N: """) % locals())
1222 if ok.strip().lower() != 'y': 1175 if ok.strip().lower() != 'y':
1223 return 0 1176 return 0
1224 1177
1225 # clear it out so the install isn't confused 1178 # clear it out so the install isn't confused
1226 shutil.rmtree(tracker_home) 1179 shutil.rmtree(tracker_home)
1260 # load config_ini.ini from template if it exists. 1213 # load config_ini.ini from template if it exists.
1261 # it sets parameters like template_engine that are 1214 # it sets parameters like template_engine that are
1262 # template specific. 1215 # template specific.
1263 template_config = UserConfig(templates[template]['path'] + 1216 template_config = UserConfig(templates[template]['path'] +
1264 "/config_ini.ini") 1217 "/config_ini.ini")
1265 for k in template_config.keys(): 1218
1219 # .keys() is required. UserConfig has no __iter__ or __next__
1220 for k in template_config.keys(): # noqa: SIM118
1266 if k == 'HOME': # ignore home. It is a default param. 1221 if k == 'HOME': # ignore home. It is a default param.
1267 continue 1222 continue
1268 defns[k] = template_config[k] 1223 defns[k] = template_config[k]
1269 1224
1270 # install! 1225 # install!
1306 1261
1307 You MUST run the "roundup-admin initialise" command once you've performed 1262 You MUST run the "roundup-admin initialise" command once you've performed
1308 the above steps. 1263 the above steps.
1309 --------------------------------------------------------------------------- 1264 ---------------------------------------------------------------------------
1310 """) % {'database_config_file': os.path.join(tracker_home, 'schema.py'), 1265 """) % {'database_config_file': os.path.join(tracker_home, 'schema.py'),
1311 'database_init_file': os.path.join(tracker_home, 'initial_data.py')}) \ 1266 'database_init_file': os.path.join(tracker_home, 'initial_data.py')})
1312 # noqa: E122
1313 return 0 1267 return 0
1314 1268
1315 def do_list(self, args): 1269 def do_list(self, args):
1316 ''"""Usage: list classname [property] 1270 ''"""Usage: list classname [property]
1317 List the instances of a class. 1271 List the instances of a class.
1336 1290
1337 # get the class 1291 # get the class
1338 cl = self.get_class(classname) 1292 cl = self.get_class(classname)
1339 1293
1340 # figure the property 1294 # figure the property
1341 if len(args) > 1: 1295 propname = args[1] if len(args) > 1 else cl.labelprop()
1342 propname = args[1]
1343 else:
1344 propname = cl.labelprop()
1345 1296
1346 if self.separator: 1297 if self.separator:
1347 if len(args) == 2: 1298 if len(args) == 2:
1348 # create a list of propnames since user specified propname 1299 # create a list of propnames since user specified propname
1349 proplist = [] 1300 proplist = []
1350 for nodeid in cl.getnodeids(retired=retired): 1301 try:
1351 try: 1302 proplist = [ cl.get(nodeid, propname) for nodeid in
1352 proplist.append(cl.get(nodeid, propname)) 1303 cl.getnodeids(retired=retired)]
1353 except KeyError: 1304 except KeyError:
1354 raise UsageError(_('%(classname)s has no property ' 1305 raise UsageError(_('%(classname)s has no property '
1355 '"%(propname)s"') % locals()) 1306 '"%(propname)s"') % locals())
1356 print(self.separator.join(proplist)) 1307 print(self.separator.join(proplist))
1357 else: 1308 else:
1358 # create a list of index id's since user didn't specify 1309 # create a list of index id's since user didn't specify
1359 # otherwise 1310 # otherwise
1360 print(self.separator.join(cl.getnodeids(retired=retired))) 1311 print(self.separator.join(cl.getnodeids(retired=retired)))
1361 else: 1312 else:
1362 for nodeid in cl.getnodeids(retired=retired): 1313 try:
1363 try: 1314 for nodeid in cl.getnodeids(retired=retired):
1364 value = cl.get(nodeid, propname) 1315 value = cl.get(nodeid, propname)
1365 except KeyError: 1316 print(_('%(nodeid)4s: %(value)s') % locals())
1366 raise UsageError(_('%(classname)s has no property ' 1317 except KeyError:
1367 '"%(propname)s"') % locals()) 1318 raise UsageError(_('%(classname)s has no property '
1368 print(_('%(nodeid)4s: %(value)s') % locals()) 1319 '"%(propname)s"') % locals())
1369 return 0 1320 return 0
1370 1321
1371 def do_migrate(self, args): 1322 def do_migrate(self, args): # noqa: ARG002 - args unused
1372 ''"""Usage: migrate 1323 ''"""Usage: migrate
1373 1324
1374 Update a tracker's database to be compatible with the Roundup 1325 Update a tracker's database to be compatible with the Roundup
1375 codebase. 1326 codebase.
1376 1327
1488 tic = perf_counter() 1439 tic = perf_counter()
1489 pw_hash = password.encodePassword( 1440 pw_hash = password.encodePassword(
1490 "this is a long password to hash", 1441 "this is a long password to hash",
1491 props['scheme'], 1442 props['scheme'],
1492 None, 1443 None,
1493 config=self.db.config 1444 config=self.db.config,
1494 ) 1445 )
1495 toc = perf_counter() 1446 toc = perf_counter()
1496 except password.PasswordValueError as e: 1447 except password.PasswordValueError as e:
1497 print(e) 1448 print(e)
1498 print_supported_schemes() 1449 print_supported_schemes()
1548 args[0]) 1499 args[0])
1549 else: 1500 else:
1550 print(_("Current settings and values " 1501 print(_("Current settings and values "
1551 "(NYI - not yet implemented):")) 1502 "(NYI - not yet implemented):"))
1552 is_verbose = self.settings['verbose'] 1503 is_verbose = self.settings['verbose']
1553 for key in sorted(list(self.settings.keys())): 1504 for key in sorted(self.settings.keys()):
1554 if key.startswith('_') and not is_verbose: 1505 if key.startswith('_') and not is_verbose:
1555 continue 1506 continue
1556 print(" %s=%s" % (key, self.settings[key])) 1507 print(" %s=%s" % (key, self.settings[key]))
1557 if is_verbose: 1508 if is_verbose:
1558 print(" %s" % self.settings_help[key]) 1509 print(" %s" % self.settings_help[key])
1559 1510
1560 return 1511 return
1561 1512
1562 if setting not in self.settings: 1513 if setting not in self.settings:
1563 raise UsageError(_('Unknown setting %s.') % setting) 1514 raise UsageError(_('Unknown setting %s. Try "pragma list".')
1564 if type(self.settings[setting]) is bool: 1515 % setting)
1516 if isinstance(self.settings[setting], bool):
1565 value = value.lower() 1517 value = value.lower()
1566 if value in ("yes", "true", "on", "1"): 1518 if value in ("yes", "true", "on", "1"):
1567 value = True 1519 value = True
1568 elif value in ("no", "false", "off", "0"): 1520 elif value in ("no", "false", "off", "0"):
1569 value = False 1521 value = False
1570 else: 1522 else:
1571 raise UsageError(_( 1523 raise UsageError(_(
1572 'Incorrect value for boolean setting %(setting)s: ' 1524 'Incorrect value for boolean setting %(setting)s: '
1573 '%(value)s.') % {"setting": setting, "value": value}) 1525 '%(value)s.') % {"setting": setting, "value": value})
1574 elif type(self.settings[setting]) is int: 1526 elif isinstance(self.settings[setting], int):
1575 try: 1527 try:
1576 _val = int(value) 1528 _val = int(value)
1577 except ValueError: 1529 except ValueError:
1578 raise UsageError(_( 1530 raise UsageError(_(
1579 'Incorrect value for integer setting %(setting)s: ' 1531 'Incorrect value for integer setting %(setting)s: '
1580 '%(value)s.') % {"setting": setting, "value": value}) 1532 '%(value)s.') % {"setting": setting, "value": value})
1581 value = _val 1533 value = _val
1582 elif type(self.settings[setting]) is str: 1534 elif isinstance(self.settings[setting], str):
1583 if setting == "show_retired": 1535 if setting == "show_retired":
1584 if value not in ["no", "only", "both"]: 1536 if value not in ["no", "only", "both"]:
1585 raise UsageError(_( 1537 raise UsageError(_(
1586 'Incorrect value for setting %(setting)s: ' 1538 'Incorrect value for setting %(setting)s: '
1587 '%(value)s. Should be no, both, or only.') % { 1539 '%(value)s. Should be no, both, or only.') % {
1704 raise UsageError(_('no such %(classname)s node ' 1656 raise UsageError(_('no such %(classname)s node '
1705 '"%(nodeid)s"') % locals()) 1657 '"%(nodeid)s"') % locals())
1706 self.db_uncommitted = True 1658 self.db_uncommitted = True
1707 return 0 1659 return 0
1708 1660
1709 def do_rollback(self, args): 1661 def do_rollback(self, args): # noqa: ARG002 - args unused
1710 ''"""Usage: rollback 1662 ''"""Usage: rollback
1711 Undo all changes that are pending commit to the database. 1663 Undo all changes that are pending commit to the database.
1712 1664
1713 The changes made during an interactive session are not 1665 The changes made during an interactive session are not
1714 automatically written to the database - they must be committed 1666 automatically written to the database - they must be committed
1829 # now do the set for all the nodes 1781 # now do the set for all the nodes
1830 for classname, itemid in designators: 1782 for classname, itemid in designators:
1831 props = copy.copy(propset) # make a new copy for every designator 1783 props = copy.copy(propset) # make a new copy for every designator
1832 cl = self.get_class(classname) 1784 cl = self.get_class(classname)
1833 1785
1834 for key, value in list(props.items()): 1786 try:
1835 try: 1787 for key, value in list(props.items()):
1836 # You must reinitialize the props every time though. 1788 # You must reinitialize the props every time though.
1837 # if props['nosy'] = '+admin' initally, it gets 1789 # if props['nosy'] = '+admin' initally, it gets
1838 # set to 'demo,admin' (assuming it was set to demo 1790 # set to 'demo,admin' (assuming it was set to demo
1839 # in the db) after rawToHyperdb returns. 1791 # in the db) after rawToHyperdb returns.
1840 # This new value is used for all the rest of the 1792 # This new value is used for all the rest of the
1841 # designators if not reinitalized. 1793 # designators if not reinitalized.
1842 props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid, 1794 props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
1843 key, value) 1795 key, value)
1844 except hyperdb.HyperdbValueError as message: 1796 except hyperdb.HyperdbValueError as message:
1845 raise UsageError(message) 1797 raise UsageError(message)
1846 1798
1847 # try the set 1799 # try the set
1848 try: 1800 try:
1849 cl.set(itemid, **props) 1801 cl.set(itemid, **props)
1850 except (TypeError, IndexError, ValueError) as message: 1802 except (TypeError, IndexError, ValueError) as message:
1864 # get the class 1816 # get the class
1865 cl = self.get_class(classname) 1817 cl = self.get_class(classname)
1866 1818
1867 # get the key property 1819 # get the key property
1868 keyprop = cl.getkey() 1820 keyprop = cl.getkey()
1869 if self.settings['display_protected']: 1821 properties = cl.getprops() if self.settings['display_protected'] \
1870 properties = cl.getprops() 1822 else cl.properties
1871 else: 1823
1872 properties = cl.properties
1873 for key in properties: 1824 for key in properties:
1874 value = properties[key] 1825 value = properties[key]
1875 if keyprop == key: 1826 if keyprop == key:
1876 sys.stdout.write(_('%(key)s: %(value)s (key property)\n') % 1827 sys.stdout.write(_('%(key)s: %(value)s (key property)\n') %
1877 locals()) 1828 locals())
1995 if args and args[0] == "trace_search": 1946 if args and args[0] == "trace_search":
1996 trace_search = True 1947 trace_search = True
1997 1948
1998 templates = self.listTemplates(trace_search=trace_search) 1949 templates = self.listTemplates(trace_search=trace_search)
1999 1950
2000 for name in sorted(list(templates.keys())): 1951 for name in sorted(templates.keys()):
2001 templates[name]['description'] = textwrap.fill( 1952 templates[name]['description'] = textwrap.fill(
2002 "\n".join([line.lstrip() for line in 1953 "\n".join([line.lstrip() for line in
2003 templates[name]['description'].split("\n")]), 1954 templates[name]['description'].split("\n")]),
2004 70, 1955 70,
2005 subsequent_indent=" " 1956 subsequent_indent=" ",
2006 ) 1957 )
2007 print(""" 1958 print("""
2008 Name: %(name)s 1959 Name: %(name)s
2009 Path: %(path)s 1960 Path: %(path)s
2010 Desc: %(description)s 1961 Desc: %(description)s
2031 1982
2032 # handle help now 1983 # handle help now
2033 if command == 'help': 1984 if command == 'help':
2034 if len(args) > 1: 1985 if len(args) > 1:
2035 self.do_help(args[1:]) 1986 self.do_help(args[1:])
2036 return 0 1987 else:
2037 self.do_help(['help']) 1988 self.do_help(['help'])
2038 return 0 1989 return 0
2039 if command == 'morehelp': 1990 if command == 'morehelp':
2040 self.do_help(['help']) 1991 self.do_help(['help'])
2041 self.help_commands() 1992 self.help_commands()
2042 self.help_all() 1993 self.help_all()
2061 2012
2062 if command in ['genconfig', 'templates']: 2013 if command in ['genconfig', 'templates']:
2063 try: 2014 try:
2064 ret = function(args[1:]) 2015 ret = function(args[1:])
2065 return ret 2016 return ret
2066 except UsageError as message: # noqa F841 2017 except UsageError as message:
2067 return self.usageError_feedback(message, function) 2018 return self.usageError_feedback(message, function)
2068 2019
2069 # make sure we have a tracker_home 2020 # make sure we have a tracker_home
2070 while not self.tracker_home: 2021 while not self.tracker_home:
2071 if not self.force: 2022 if not self.force:
2075 2026
2076 # before we open the db, we may be doing an install or init 2027 # before we open the db, we may be doing an install or init
2077 if command == 'initialise': 2028 if command == 'initialise':
2078 try: 2029 try:
2079 return self.do_initialise(self.tracker_home, args) 2030 return self.do_initialise(self.tracker_home, args)
2080 except UsageError as message: # noqa: F841 2031 except UsageError as message:
2081 return self.usageError_feedback(message, function) 2032 return self.usageError_feedback(message, function)
2082 elif command == 'install': 2033 elif command == 'install':
2083 try: 2034 try:
2084 return self.do_install(self.tracker_home, args) 2035 return self.do_install(self.tracker_home, args)
2085 except UsageError as message: # noqa: F841 2036 except UsageError as message:
2086 return self.usageError_feedback(message, function) 2037 return self.usageError_feedback(message, function)
2087 2038
2088 # get the tracker 2039 # get the tracker
2089 try: 2040 try:
2090 if self.tracker and not self.settings['_reopen_tracker']: 2041 if self.tracker and not self.settings['_reopen_tracker']:
2094 print("Reopening tracker") 2045 print("Reopening tracker")
2095 tracker = roundup.instance.open(self.tracker_home) 2046 tracker = roundup.instance.open(self.tracker_home)
2096 self.tracker = tracker 2047 self.tracker = tracker
2097 self.settings['indexer_backend'] = self.tracker.config['INDEXER'] 2048 self.settings['indexer_backend'] = self.tracker.config['INDEXER']
2098 2049
2099 except ValueError as message: # noqa: F841 2050 except ValueError as message: # noqa: F841 -- used from locals
2100 self.tracker_home = '' 2051 self.tracker_home = ''
2101 print(_("Error: Couldn't open tracker: %(message)s") % locals()) 2052 print(_("Error: Couldn't open tracker: %(message)s") % locals())
2102 return 1 2053 return 1
2103 except NoConfigError as message: # noqa: F841 2054 except NoConfigError as message: # noqa: F841 -- used from locals
2104 self.tracker_home = '' 2055 self.tracker_home = ''
2105 print(_("Error: Couldn't open tracker: %(message)s") % locals()) 2056 print(_("Error: Couldn't open tracker: %(message)s") % locals())
2106 return 1 2057 return 1
2107 # message used via locals 2058 # message used via locals
2108 except ParsingOptionError as message: # noqa: F841 2059 except ParsingOptionError as message: # noqa: F841 -- used from locals
2109 print("%(message)s" % locals()) 2060 print("%(message)s" % locals())
2110 return 1 2061 return 1
2111 2062
2112 # only open the database once! 2063 # only open the database once!
2113 if not self.db: 2064 if not self.db:
2122 2073
2123 # do the command 2074 # do the command
2124 ret = 0 2075 ret = 0
2125 try: 2076 try:
2126 ret = function(args[1:]) 2077 ret = function(args[1:])
2127 except UsageError as message: # noqa: F841 2078 except UsageError as message:
2128 ret = self.usageError_feedback(message, function) 2079 ret = self.usageError_feedback(message, function)
2129 except Exception: 2080 except Exception:
2130 import traceback 2081 import traceback
2131 traceback.print_exc() 2082 traceback.print_exc()
2132 ret = 1 2083 ret = 1
2133 return ret 2084 return ret
2134 2085
2135 def interactive(self): 2086 def interactive(self):
2136 """Run in an interactive mode 2087 """Run in an interactive mode
2137 """ 2088 """
2138 print(_('Roundup %s ready for input.\nType "help" for help.' 2089 print(_('Roundup %s ready for input.\nType "help" for help.')
2139 % roundup_version)) 2090 % roundup_version)
2140 try: 2091 try:
2141 import readline # noqa: F401 2092 import readline # noqa: F401
2142 except ImportError: 2093 except ImportError:
2143 print(_('Note: command history and editing not available')) 2094 print(_('Note: command history and editing not available'))
2144 2095
2162 commit = self.my_input(_('There are unsaved changes. Commit them (y/N)? ')) 2113 commit = self.my_input(_('There are unsaved changes. Commit them (y/N)? '))
2163 if commit and commit[0].lower() == 'y': 2114 if commit and commit[0].lower() == 'y':
2164 self.db.commit() 2115 self.db.commit()
2165 return 0 2116 return 0
2166 2117
2167 def main(self): 2118 def main(self): # noqa: PLR0912, PLR0911
2168 try: 2119 try:
2169 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdP:sS:vV') 2120 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdP:sS:vV')
2170 except getopt.GetoptError as e: 2121 except getopt.GetoptError as e:
2171 self.usage(str(e)) 2122 self.usage(str(e))
2172 return 1 2123 return 1

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