diff --git a/Lib/idlelib/checkers.py b/Lib/idlelib/checkers.py new file mode 100644 index 00000000000000..4ac5f3ef1ce233 --- /dev/null +++ b/Lib/idlelib/checkers.py @@ -0,0 +1,423 @@ +from functools import partial +import os +import sys +import traceback +from subprocess import Popen +from tempfile import NamedTemporaryFile + +from tkinter import END, RIGHT +from tkinter import messagebox + +from idlelib.config import idleConf + + +CHECKER_PARAMS = ( + # (name, type, default, warn_on_default) + ('enabled', 'bool', True, False), + ('name', 'str', None, True), + ('command', 'str', None, True), + ('additional', 'str', '', False), + ('reload_source', 'bool', False, False), + ('show_result', 'bool', True, False), +) + + +def get_checkers(): + """ + Return list of checkers defined in users config file. + """ + return sorted( + set(idleConf.GetSectionList('default', 'checkers')) | + set(idleConf.GetSectionList('user', 'checkers')) + ) + + +def get_enabled_checkers(): + """ + Return list of currently enabled checkers. + """ + return [ + c for c in get_checkers() + if idleConf.GetOption( + 'checkers', c, 'enabled', default=False, type='bool') + ] + + +def get_checker_config(checker_name): + """ + Return configuration of checker as dict. + """ + if checker_name == '': + raise ValueError('Checker name cannot be empty') + + checker_params = { + name: idleConf.GetOption('checkers', checker_name, + name, type=type_, default=default, + warn_on_default=warn_on_default) + for name, type_, default, warn_on_default + in CHECKER_PARAMS + if name != 'name' + } + checker_params['name'] = checker_name + return checker_params + + +def run_checker(editwin, checker): + """ + Run the 3rd party checker 'checker'. + + If 'show_result' for the checker is configured to True, display + its output in a new CheckerWindow. + + Return True if successful, False otherwise. + """ + filename = editwin.scriptbinding.getfilename() + if not filename: + return False + + config = get_checker_config(checker) + command = config['command'] + if not command: + location = 'Config Checkers' + menu = 'Run' + message = ("'Command' option for '{checker}' checker is empty.\n" + "Please update the 'Command' option for '{checker}' " + "in {location} in {menu} menu, before running " + "'{checker}' again.".format(checker=checker, + location=location, + menu=menu)) + messagebox.showerror(title="Empty Command", message=message, + parent=editwin.top) + return False + additional = config['additional'] + reload_source = config['reload_source'] + show_result = config['show_result'] + + dirname, _file = os.path.split(filename) + args = [ + os.path.expanduser(arg) + for arg in command.split() + additional.split() + [_file] + if arg + ] + + # CheckerWindow closes file if no exception + with NamedTemporaryFile(delete=False) as error_file, \ + NamedTemporaryFile(delete=False) as output_file: + try: + process = Popen(args, stdout=output_file, stderr=error_file, + cwd=dirname) + if show_result: + if CheckerWindow is None: + define_CheckerWindow() + CheckerWindow(editwin, filename, checker, output_file, + error_file, process, reload_source) + elif reload_source: + while process.poll() is None: + continue + editwin.io.loadfile(filename) + return True + except Exception: + *_, value, tb = sys.exc_info() + message = ("File: {}\n" + "Checker: {}\n" + "Command: {}\n" + "Additional Args: {}\n" + "Call details: {}\n\n\n" + "Traceback: {}".format(filename, checker, command, + additional, ' '.join(args), + traceback.format_exc(limit=1) + )) + messagebox.showerror(title=value, + message=message, + parent=editwin.top) + return False + + +class Checkers: + checker_names = tuple(get_enabled_checkers()) + editwin_instance_dict = None + + def __init__(self, editwin): + self.editwin = editwin + self.parent = self.editwin.top + self.set_editwin_instance_dict(self.parent.instance_dict) + self.update_checkers_menu(self.editwin) + + @classmethod + def set_editwin_instance_dict(cls, editwin_instance_dict): + cls.editwin_instance_dict = editwin_instance_dict + + @classmethod + def reload(cls): + """Load class variables from config.""" + cls.checker_names = tuple(get_enabled_checkers()) + for editwin in cls.editwin_instance_dict: + cls.update_checkers_menu(editwin) + + @classmethod + def update_checkers_menu(cls, editwin): + """ + Utility method to update the Run menu to display the + currently enabled checkers. + """ + if not hasattr(editwin, 'checkers_menu'): # a shell window + return + menu = editwin.checkers_menu + menu.delete(0, END) + for checker_name in cls.checker_names: + menu.add_command( + label='Run {}'.format(checker_name), + command=partial(run_checker, editwin, checker_name), + ) + + +# class ConfigCheckerDialog(tk.Toplevel): +# def __init__(self, _checker, checker_name=None): +# tk.Toplevel.__init__(self, _checker.editwin.top) +# self._checker = _checker +# self.editwin = _checker.editwin +# self.checker_name = checker_name +# self.parent = parent = self.editwin.top +# +# self.name = tk.StringVar(parent) +# self.command = tk.StringVar(parent) +# self.additional = tk.StringVar(parent) +# self.reload_source = tk.StringVar(parent) +# self.show_result = tk.StringVar(parent) +# self.enabled = tk.StringVar(parent) +# self.call_string = tk.StringVar(parent) +# self.command.trace('w', self.update_call_string) +# self.additional.trace('w', self.update_call_string) +# +# if checker_name: +# config = get_checker_config(checker_name) +# self.name.set(config['name'] if checker_name else '') +# self.command.set(config['command'] if checker_name else '') +# self.additional.set(config['additional'] if checker_name else '') +# self.reload_source.set(config['reload_source'] if checker_name else 0) +# self.show_result.set(config['show_result'] if checker_name else 1) +# self.enabled.set(config['enabled'] if checker_name else 1) +# +# self.grab_set() +# self.resizable(width=False, height=False) +# self.create_widgets() +# +# def create_widgets(self): +# parent = self.parent +# self.configure(borderwidth=5) +# if self.checker_name: +# title = 'Edit {} checker'.format(self.checker_name) +# else: +# title = 'Config new checker' +# self.wm_title(title) +# self.geometry('+%d+%d' % (parent.winfo_rootx() + 30, +# parent.winfo_rooty() + 30)) +# self.transient(parent) +# self.focus_set() +# # frames creation +# optionsFrame = tk.Frame(self) +# buttonFrame = tk.Frame(self) +# +# # optionsFrame +# nameLabel = tk.Label(optionsFrame, text='Name of Checker') +# commandLabel = tk.Label(optionsFrame, text='Command') +# additionalLabel = tk.Label(optionsFrame, text='Additional Args') +# currentCallStringLabel = tk.Label(optionsFrame, +# text='Call Command string') +# +# self.nameEntry = tk.Entry(optionsFrame, textvariable=self.name, +# width=40) +# self.commandEntry = tk.Entry(optionsFrame, textvariable=self.command, +# width=40) +# self.additionalEntry = tk.Entry(optionsFrame, +# textvariable=self.additional, width=40) +# reload_sourceCheckbutton = tk.Checkbutton(optionsFrame, +# variable=self.reload_source, +# text='Reload file?') +# showResultCheckbutton = tk.Checkbutton(optionsFrame, +# variable=self.show_result, +# text='Show result after run?') +# self.currentCallStringEntry = tk.Entry(optionsFrame, state='readonly', +# textvariable=self.call_string, +# width=40) +# enabledCheckbutton = tk.Checkbutton(optionsFrame, +# variable=self.enabled, +# text='Enable Checker?') +# +# # buttonFrame +# okButton = tk.Button(buttonFrame, text='Ok', command=self.ok) +# cancelButton = tk.Button(buttonFrame, text='Cancel', +# command=self.cancel) +# +# # frames packing +# optionsFrame.pack() +# buttonFrame.pack() +# # optionsFrame packing +# nameLabel.pack() +# self.nameEntry.pack() +# commandLabel.pack() +# self.commandEntry.pack() +# additionalLabel.pack() +# self.additionalEntry.pack() +# reload_sourceCheckbutton.pack() +# showResultCheckbutton.pack() +# currentCallStringLabel.pack() +# self.currentCallStringEntry.pack() +# enabledCheckbutton.pack() +# # buttonFrame packing +# okButton.pack(side=tk.LEFT) +# cancelButton.pack() +# +# def update_call_string(self, *args, **kwargs): +# filename = self.editwin.io.filename or '' +# call_string = ' '.join([self.command.get(), self.additional.get(), +# os.path.split(filename)[1] or '']) +# self.call_string.set(call_string) +# +# def name_ok(self): +# name = self.name.get() +# ok = True +# if name.strip() == '': +# messagebox.showerror(title='Name Error', +# message='No Name Specified', parent=self) +# ok = False +# return ok +# +# def command_ok(self): +# command = self.command.get() +# ok = True +# if command.strip() == '': +# message = ('No command specified. \nCommand is name or full path' +# ' to the program that IDLE has to execute') +# messagebox.showerror(title='Command Error', +# message=message, parent=self) +# ok = False +# return ok +# +# def additional_ok(self): +# ok = True +# return ok +# +# def ok(self): +# _ok = self.name_ok() and self.command_ok() and self.additional_ok() +# if _ok: +# name = self.name.get() +# idleConf.userCfg['checkers'].SetOption(name, 'enabled', +# self.enabled.get()) +# idleConf.userCfg['checkers'].SetOption(name, 'command', +# self.command.get()) +# idleConf.userCfg['checkers'].SetOption(name, 'additional', +# self.additional.get()) +# idleConf.userCfg['checkers'].SetOption(name, 'reload_source', +# self.reload_source.get()), +# idleConf.userCfg['checkers'].SetOption(name, 'show_result', +# self.show_result.get()) +# +# idleConf.userCfg['checkers'].Save() +# self.close() +# +# def cancel(self): +# self.close() +# +# def close(self): +# self._checker.update_listbox() +# self._checker.update_menu() +# self._checker.dialog.grab_set() +# self.destroy() + + +CheckerWindow = None + +def define_CheckerWindow(): + from idlelib.outwin import OutputWindow + + global CheckerWindow + + class CheckerWindow(OutputWindow): + def __init__(self, editwin, filename, checker, output_file, error_file, + process, reload_source): + """ + editwin - EditorWindow instance + filename - string + checker - name of checker + output_file, error_file - Temporary file objects + process - Popen object + reload_source - bool, reload original file after checker finished + """ + self.editwin = editwin + self.filename = filename + self.checker = checker + self.process = process + self.reload_source = reload_source + OutputWindow.__init__(self, self.editwin.flist) + self.error_file = open(error_file.name, 'r') + self.output_file = open(output_file.name, 'r') + + theme = idleConf.CurrentTheme() + stderr_fg = idleConf.GetHighlight(theme, 'stderr', fgBg='fg') + stdout_fg = idleConf.GetHighlight(theme, 'stdout', fgBg='fg') + tagdefs = {'stderr': {'foreground': stderr_fg}, + 'stdout': {'foreground': stdout_fg}, } + for tag, cnf in tagdefs.items(): + self.text.tag_configure(tag, **cnf) + + self.text.mark_set('stderr_index', '1.0') + self.text.mark_set('stdout_index', END) + self.stderr_index = self.text.index('stderr_index') + self.stdout_index = self.text.index('stdout_index') + self.status_bar.set_label('Checker status', 'Processing..!', + side=RIGHT) + self.update() + + def short_title(self): + return '{} - {}'.format(self.checker, self.filename) + + def update(self): + self.update_error() + self.update_output() + if self.process.poll() is None: + self.text.after(10, self.update) + else: + if self.reload_source: + self.editwin.io.loadfile(self.filename) + self.update_error() + self.update_output() + self.status_bar.set_label('Checker status', 'Done', side=RIGHT) + self._clean_tempfiles() + + def update_error(self): + for line in self.error_file.read().splitlines(True): + self.text.insert(self.stderr_index, line, 'stderr') + self.stderr_index = self.text.index('stderr_index') + + def update_output(self): + for line in self.output_file.read().splitlines(True): + self.text.insert(self.stdout_index, line, 'stdout') + self.stdout_index = self.text.index('stdout_index') + + file_line_pats = [r':\s*(\d+)\s*(:|,)'] + + def _file_line_helper(self, line): + for prog in self.file_line_progs: + match = prog.search(line) + if match: + lineno = match.group(1) + else: + return None + try: + return self.filename, int(lineno) + except TypeError: + return None + + def _clean_tempfiles(self): + self.output_file.close() + self.error_file.close() + try: + os.unlink(self.error_file.name) + os.unlink(self.output_file.name) + except OSError: + pass + + def close(self): + self._clean_tempfiles() + OutputWindow.close(self) diff --git a/Lib/idlelib/config-checker.def b/Lib/idlelib/config-checker.def new file mode 100644 index 00000000000000..21fc7a547fe093 --- /dev/null +++ b/Lib/idlelib/config-checker.def @@ -0,0 +1,15 @@ +# This config is for integration of code checkers and other external tools. +# +# There are currently no checkers configured by default because none are +# included in the stdlib or bundled by default with the python.org installers. +# This is intentional and not likely to change. +# +# Format for configuring a "checker", by mock example: +# +# [MockCheckerName] +# enabled = true +# name = MockChecker +# command = mock_checker # should be found in PATH or be an absolute path +# additional = # additional arguments to be passed to the checker command +# reload_source = false # set to true if the checker may alter file contents +# show_result = true # whether to show the checker output in a separate window diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py index 0eb90fc8dc5fd6..d86409461947d8 100644 --- a/Lib/idlelib/config.py +++ b/Lib/idlelib/config.py @@ -161,7 +161,9 @@ class IdleConf: (user home dir)/.idlerc/config-{config-type}.cfg """ def __init__(self, _utest=False): - self.config_types = ('main', 'highlight', 'keys', 'extensions') + self.config_types = ( + 'main', 'highlight', 'keys', 'extensions', 'checkers', + ) self.defaultCfg = {} self.userCfg = {} self.cfg = {} # TODO use to select userCfg vs defaultCfg diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index 229dc898743322..897572bf317640 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -27,6 +27,7 @@ from idlelib.query import SectionName, HelpSource from idlelib.textview import view_text from idlelib.autocomplete import AutoComplete +from idlelib.checkers import Checkers, CHECKER_PARAMS, get_checker_config from idlelib.codecontext import CodeContext from idlelib.parenmatch import ParenMatch from idlelib.paragraph import FormatParagraph @@ -111,11 +112,13 @@ def create_widgets(self): self.fontpage = FontPage(note, self.highpage) self.keyspage = KeysPage(note) self.genpage = GenPage(note) + self.chckpage = CheckersPage(note) self.extpage = self.create_page_extensions() note.add(self.fontpage, text='Fonts/Tabs') note.add(self.highpage, text='Highlights') note.add(self.keyspage, text=' Keys ') note.add(self.genpage, text=' General ') + note.add(self.chckpage, text=' Checkers ') note.add(self.extpage, text='Extensions') note.enable_traversal() note.pack(side=TOP, expand=TRUE, fill=BOTH) @@ -2122,6 +2125,342 @@ def update_help_changes(self): ';'.join(self.user_helplist[num-1][:2])) +class CheckersPage(Frame): + + def __init__(self, master): + super().__init__(master) + + self.current_checker = None + + self.create_page_checkers() + self.load_checkers_cfg() + + def create_page_checkers(self): + """Return frame of widgets for Checkers tab. + + This code is generic - it works for any and all IDLE extensions. + + IDLE extensions save their configuration options using idleConf. + This code reads the current configuration using idleConf, supplies a + GUI interface to change the configuration values, and saves the + changes using idleConf. + + Not all changes take effect immediately - some may require restarting IDLE. + This depends on each extension's implementation. + + All values are treated as text, and it is up to the user to supply + reasonable values. The only exception to this are the 'enable*' options, + which are boolean, and can be toggled with a True/False button. + + Methods: + load_checkers_cfg: + checker_selected: Handle selection from list. + create_checker_frame: Hold widgets for one checker. + set_extension_value: Set in userCfg['extensions']. + save_all_changed_checkers: Call checkers page Save(). + """ + # Create widgets - a listbox shows all available checkers, with + # buttons to add/edit/remove a checker on the right. + frame_checkerlist = Frame(self) + frame_checkerlist_buttons = Frame(frame_checkerlist) + self.checkerlist = Listbox( + frame_checkerlist, height=5, takefocus=True, + exportselection=FALSE) + scroll_checkerlist = Scrollbar(frame_checkerlist) + scroll_checkerlist['command'] = self.checkerlist.yview + self.checkerlist['yscrollcommand'] = scroll_checkerlist.set + self.checkerlist.bind('', self.set_edit_delete_state) + self.button_checkerlist_edit = Button( + frame_checkerlist_buttons, text='Edit', state='disabled', + width=8, command=self.checkerlist_item_edit) + self.button_checkerlist_add = Button( + frame_checkerlist_buttons, text='Add', + width=8, command=self.checkerlist_item_add) + self.button_checkerlist_remove = Button( + frame_checkerlist_buttons, text='Remove', state='disabled', + width=8, command=self.checkerlist_item_remove) + + # Pack widgets + frame_checkerlist_buttons.pack(side=RIGHT, padx=5, pady=5, fill=Y) + frame_checkerlist.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) + scroll_checkerlist.pack(side=RIGHT, anchor=W, fill=Y) + self.checkerlist.pack(side=LEFT, anchor=E, expand=TRUE, fill=BOTH) + self.button_checkerlist_edit.pack(side=TOP, anchor=W, pady=5) + self.button_checkerlist_add.pack(side=TOP, anchor=W) + self.button_checkerlist_remove.pack(side=TOP, anchor=W, pady=5) + + def checkerlist_item_add(self, event=None): + """Handle add button for the help list. + + Query for name and location of new help sources and add + them to the list. + """ + def callback(new_checker_name): + if new_checker_name: + self.checkerlist.insert(END, new_checker_name) + ConfigCheckerDialog(self, callback) + + def checkerlist_item_edit(self, event=None): + """Handle edit button for the help list. + + Query with existing help source information and update + config if the values are changed. + """ + item_index = self.checkerlist.curselection() + checker_name = self.checkerlist.get(item_index) + + def callback(new_checker_name): + if new_checker_name and new_checker_name != checker_name: + self.checkerlist.delete(item_index) + self.checkerlist.insert(item_index, new_checker_name) + self.checkerlist.select_set(item_index) + self.set_edit_delete_state() + ConfigCheckerDialog(self, callback, checker_name) + + def checkerlist_item_remove(self, event=None): + """Handle remove button for the help list. + + Delete the help list item from config. + """ + item_index = self.checkerlist.curselection() + checker_name = self.checkerlist.get(item_index) + deleted = self.delete_checker_config(checker_name) + if deleted: + self.checkerlist.delete(item_index) + self.set_edit_delete_state() # Selected will be un-selected + + def delete_checker_config(self, checker_name): + """Delete a checker's section from the user config. + + Returns: + * True if the section was actually deleted + * False if the section wasn't found in the config + * None if the section is found in the default config, + and therefore can't be deleted + """ + # Since each checker is a section in a config file, + # it is impossible to remove/rename a checker if it + # exists in the (read-only) default config. + if idleConf.defaultCfg['checkers'].has_section(checker_name): + return None + + if idleConf.userCfg['checkers'].has_section(checker_name): + idleConf.userCfg['checkers'].remove_section(checker_name) + idleConf.userCfg['checkers'].Save() + return True + + return False + + def set_edit_delete_state(self, event=None): + """Toggle the state for the add & delete buttons based on list entries.""" + enable = ( + self.checkerlist.size() >= 0 and + self.checkerlist.curselection() + ) + new_state = ('!disabled' if enable else 'disabled',) + self.button_checkerlist_edit.state(new_state) + self.button_checkerlist_remove.state(new_state) + + def load_checkers_cfg(self): + """Fill self.checkers with data from the default and user configs.""" + checker_names = sorted( + set(idleConf.GetSectionList('default', 'checkers')) | + set(idleConf.GetSectionList('user', 'checkers')) + ) + self.checkerlist.delete(0, END) + for checker_name in checker_names: + self.checkerlist.insert(END, checker_name) + + def get_checker_names(self): + return self.checkerlist.get(0, END) + + +class ConfigCheckerDialog(Toplevel): + + def __init__(self, page, callback, checker_name=None): + super().__init__(page) + self.existing_checker_names = page.get_checker_names() + self.callback = callback + + if checker_name is None: + checker_data = { + name: (default or '') if type_ == 'str' else default + for name, type_, default, warn_on_default + in CHECKER_PARAMS + } + else: + checker_data = get_checker_config(checker_name) + self.orig_checker_data = checker_data + + self.vars = {} + for name, type_, *_ in CHECKER_PARAMS: + var_cls = {'bool': BooleanVar, 'str': StringVar}[type_] + self.vars[name] = var = var_cls(self) + var.set(checker_data[name]) + + self.vars['call_string'] = StringVar(self) + self.update_call_string() + self.vars['command'].trace('w', self.update_call_string) + self.vars['additional'].trace('w', self.update_call_string) + + self.grab_set() + self.resizable(width=False, height=False) + self.create_widgets() + + def create_widgets(self): + parent = self.master + self.configure(borderwidth=5) + if self.orig_checker_data['name']: + title = 'Edit {} checker'.format(self.orig_checker_data['name']) + else: + title = 'Config new checker' + self.wm_title(title) + self.geometry('+%d+%d' % (parent.winfo_rootx() + 30, + parent.winfo_rooty() + 30)) + self.transient(parent) + self.focus_set() + # frames creation + optionsFrame = Frame(self) + buttonFrame = Frame(self) + + # optionsFrame + nameLabel = Label(optionsFrame, text='Name of Checker') + commandLabel = Label(optionsFrame, text='Command') + additionalLabel = Label(optionsFrame, text='Additional Args') + currentCallStringLabel = Label(optionsFrame, + text='Call Command string') + + self.nameEntry = Entry(optionsFrame, textvariable=self.vars['name'], + width=40) + name = self.orig_checker_data['name'] + if idleConf.defaultCfg['checkers'].has_section(name): + self.nameEntry.config(state='readonly') + self.commandEntry = Entry(optionsFrame, + textvariable=self.vars['command'], + width=40) + self.additionalEntry = Entry(optionsFrame, + textvariable=self.vars['additional'], + width=40) + reload_sourceCheckbutton = Checkbutton( + optionsFrame, + variable=self.vars['reload_source'], + text='Reload file?') + showResultCheckbutton = Checkbutton(optionsFrame, + variable=self.vars['show_result'], + text='Show result after run?') + self.currentCallStringEntry = Entry( + optionsFrame, + state='readonly', + textvariable=self.vars['call_string'], + width=40) + enabledCheckbutton = Checkbutton(optionsFrame, + variable=self.vars['enabled'], + text='Enable Checker?') + + # buttonFrame + okButton = Button(buttonFrame, text='Ok', command=self.ok) + cancelButton = Button(buttonFrame, text='Cancel', command=self.cancel) + + # frames packing + optionsFrame.pack() + buttonFrame.pack() + # optionsFrame packing + nameLabel.pack() + self.nameEntry.pack() + commandLabel.pack() + self.commandEntry.pack() + additionalLabel.pack() + self.additionalEntry.pack() + reload_sourceCheckbutton.pack() + showResultCheckbutton.pack() + currentCallStringLabel.pack() + self.currentCallStringEntry.pack() + enabledCheckbutton.pack() + # buttonFrame packing + okButton.pack(side=LEFT) + cancelButton.pack() + + def update_call_string(self, *args): + + command = self.vars['command'].get() + additional = self.vars['additional'].get() + if command: + if additional: + command += ' ' + additional + call_string = ' '.join([command, '']) + else: + call_string = '' + self.vars['call_string'].set(call_string) + + def is_name_ok(self): + name = self.vars['name'].get().strip() + if not name: + messagebox.showerror(title='Name Error', + message='No name specified', parent=self) + return False + return True + + def is_command_ok(self): + command = self.vars['command'].get() + if command.strip() == '': + message = ('No command specified.' + '\nCommand is name or full path' + ' to the program that IDLE has to execute') + messagebox.showerror(title='Command Error', + message=message, parent=self) + return False + return True + + def ok(self, event=None): + is_ok = self.is_name_ok() and self.is_command_ok() + if is_ok: + checker_name = self.vars['name'].get().strip() + orig_name = self.orig_checker_data['name'] + if checker_name != orig_name: + if checker_name in self.existing_checker_names: + message = f'A checker named {checker_name} already exists' + messagebox.showerror(title='Name Already Exists', + message=message, parent=self) + return False + + if orig_name: + deleted = self.master.delete_checker_config(orig_name) + if deleted is None: + # Since each checker is a section in a config file, + # it is impossible to remove/rename a checker if it + # exists in the (read-only) default config. + message = \ + 'Cannot rename checkers from default configuration' + messagebox.showerror(title='Cannot rename checker', + message=message, parent=self) + return + + idleConf.userCfg['checkers'].add_section(checker_name) + idleConf.userCfg['checkers'].Save() + + def setopt(opt_name, value): + if not isinstance(value, str): + value = str(value) + return idleConf.SetOption('checkers', checker_name, + opt_name, value) + setopt('enabled', self.vars['enabled'].get()) + setopt('command', self.vars['command'].get()) + setopt('additional', self.vars['additional'].get()) + setopt('reload_source', self.vars['reload_source'].get()) + setopt('show_result', self.vars['show_result'].get()) + + idleConf.userCfg['checkers'].Save() + Checkers.reload() + self.callback(checker_name) + self.close() + + def cancel(self, event=None): + self.close() + + def close(self, event=None): + self.destroy() + + class VarTrace: """Maintain Tk variables trace state.""" diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py index 6689af64c429be..b91f319cd241ca 100644 --- a/Lib/idlelib/editor.py +++ b/Lib/idlelib/editor.py @@ -52,6 +52,7 @@ class EditorWindow(object): from idlelib.autocomplete import AutoComplete from idlelib.autoexpand import AutoExpand from idlelib.calltip import Calltip + from idlelib.checkers import Checkers from idlelib.codecontext import CodeContext from idlelib.paragraph import FormatParagraph from idlelib.parenmatch import ParenMatch @@ -305,9 +306,9 @@ def __init__(self, flist=None, filename=None, key=None, root=None): parenmatch = self.ParenMatch(self) text.bind("<>", parenmatch.flash_paren_event) text.bind("<>", parenmatch.paren_closed_event) - scriptbinding = ScriptBinding(self) - text.bind("<>", scriptbinding.check_module_event) - text.bind("<>", scriptbinding.run_module_event) + self.scriptbinding = ScriptBinding(self) + text.bind("<>", self.scriptbinding.check_module_event) + text.bind("<>", self.scriptbinding.run_module_event) text.bind("<>", self.Rstrip(self).do_rstrip) ctip = self.Calltip(self) text.bind("<>", ctip.try_open_calltip_event) @@ -320,6 +321,7 @@ def __init__(self, flist=None, filename=None, key=None, root=None): squeezer = self.Squeezer(self) text.bind("<>", squeezer.squeeze_current_text_event) + self.Checkers(self) def _filename_to_unicode(self, filename): """Return filename as BMP unicode so diplayable in Tk.""" @@ -433,6 +435,10 @@ def createmenubar(self): self.menudict['file'].insert_cascade(3, label='Recent Files', underline=0, menu=self.recent_files_menu) + if 'run' in self.menudict: + self.checkers_menu = Menu(self.menubar, tearoff=0) + self.menudict['run'].insert_cascade(3, label='Code Checkers', + menu=self.checkers_menu) self.base_helpmenu_length = self.menudict['help'].index(END) self.reset_help_menu_entries() diff --git a/Lib/idlelib/idle_test/test_checker.py b/Lib/idlelib/idle_test/test_checker.py new file mode 100644 index 00000000000000..de9ff72697d7ac --- /dev/null +++ b/Lib/idlelib/idle_test/test_checker.py @@ -0,0 +1,331 @@ +import unittest +from test.support import requires +requires('gui') +from unittest.mock import Mock, call +from idlelib.idle_test.mock_tk import Var, Mbox +from tkinter import Listbox, END, Tk +import idlelib.Checker as checker_module + + +class Dummy_editwin: + io = Mock() + top = None + extensions = {'ScriptBinding': Mock()} + flist = None + + def __init__(self, *args, **kwargs): + self.filename = None + + +class Dummy_tempfile: + def __init__(self, delete=False): + pass + + def __enter__(self, *args, **kwargs): + pass + + def __exit__(self, *args, **kwargs): + pass + +checker_dialog = checker_module.Checker +config_checker_dialog = checker_module.ConfigCheckerDialog +editwin = Dummy_editwin() +run = checker_module.run_checker(editwin, 'three') + +# methods call Mbox.showerror if values are not ok +orig_mbox = checker_module.tkMessageBox +orig_idleConf = checker_module.idleConf +orig_config_checker_dialog = checker_module.ConfigCheckerDialog +orig_get_checker_config = checker_module.get_checker_config +orig_named_temporary_file = checker_module.NamedTemporaryFile +orig_popen = checker_module.Popen +orig_checker_window = checker_module.CheckerWindow +showerror = Mbox.showerror +askyesno = Mbox.askyesno + + +def setUpModule(): + checker_module.idleConf = idleConf = Mock() + idleConf.GetSectionList = Mock(return_value=['one', 'two', 'three']) + attrs = {'userCfg': {'checker': Mock(), 'Save': Mock()}, + 'SetOption': Mock()} + idleConf.configure_mock(**attrs) + checker_module.tkMessageBox = Mbox + + +def tearDownModule(): + checker_module.idleConf = orig_idleConf + checker_module.tkMessageBox = orig_mbox + + +class Dummy_checker: + # Mock for testing methods of Checker + add_checker = checker_dialog.add_checker + edit_checker = checker_dialog.edit_checker + remove_checker = checker_dialog.remove_checker + _selected_checker = checker_dialog._selected_checker + update_listbox = checker_dialog.update_listbox + update_menu = Mock() + editwin = editwin + + +class Dummy_config_checker_dialog: + # Mock for testing methods of ConfigCheckerDialog + name_ok = config_checker_dialog.is_name_ok + command_ok = config_checker_dialog.is_command_ok + additional_ok = config_checker_dialog.additional_ok + update_call_string = config_checker_dialog.update_call_string + ok = config_checker_dialog.ok + cancel = config_checker_dialog.cancel + + name = Var() + command = Var() + additional = Var() + reload_source = Var() + show_result = Var() + enabled = Var() + call_string = Var() + destroyed = False + editwin = editwin + + def destroy(cls): + cls.destroyed = True + + def close(cls): + cls.destroy() + + +class CheckerUtilityTest(unittest.TestCase): + def test_get_checkers(self): + self.assertIsInstance(checker_module.get_checkers(), list) + + def test_get_enabled_checkers(self): + checkers_list = checker_module.get_checkers() + enabled_checkers = checker_module.get_enabled_checkers() + self.assertIsInstance(enabled_checkers, list) + self.assertTrue(set(enabled_checkers) <= set(checkers_list)) + + def test_checker_config(self): + get = checker_module.get_checker_config + with self.assertRaises(ValueError) as ve: + get('') + self.assertIn('empty', str(ve.exception)) + cfg_list = list(get(checker_module.get_enabled_checkers()[0])) + for item in ('enabled', 'name', 'command', 'additional', + 'reload_source', 'show_result'): + self.assertIn(item, cfg_list, '{} config not found'.format(item)) + + +class RunCheckerTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + checker_module.get_checker_config = Mock() + checker_module.NamedTemporaryFile = Dummy_tempfile + checker_module.CheckerWindow = Mock() + cls.get_config = checker_module.get_checker_config + script_binding = editwin.extensions.get('ScriptBinding') + script_binding.getfilename = lambda: editwin.filename + + @classmethod + def tearDownClass(cls): + checker_module.get_checker_config = orig_get_checker_config + checker_module.NamedTemporaryFile = Mock() + checker_module.CheckerWindow = orig_checker_window + checker_module.Popen = orig_popen + + def setUp(self): + self.checker_config = {'name': 'three', 'enabled': True, + 'command': 'python -m three', + 'additional': '-v 2 format=True o', + 'reload_source': 1, 'show_result': 1} + self.get_config.configure_mock(return_value=self.checker_config) + editwin.filename = 'foo/bar/filename.py' + checker_module.Popen = Mock() + + def test_blank_filename(self): + editwin.filename = '' + self.assertFalse(run()) + + def test_blank_command(self): + self.checker_config['command'] = '' + self.get_config.configure_mock(return_value=self.checker_config) + self.assertFalse(run()) + self.assertEqual('Empty Command', showerror.title) + self.assertIn('three', showerror.message) + self.assertIn('Command', showerror.message) + + def test_bad_run(self): + editwin.filename = 'foo/bar/imaginary_file.py' + error_message = 'No such file or directory' + error_filename = 'imaginary_file.py' + checker_module.Popen = Mock(side_effect=OSError(2, + error_message, + error_filename)) + self.assertFalse(run()) + self.assertIn('imaginary_file.py', str(showerror.title)) + self.assertIn('No such file or directory', showerror.message) + self.assertIn('Traceback', showerror.message) + + def test_good_run(self): + self.assertTrue(run()) + call_args = checker_module.Popen.call_args + args = call_args[0][0] + cwd = call_args[1]['cwd'] + self.assertListEqual(args, ['python', '-m', 'three', '-v', '2', + 'format=True', 'o', 'filename.py']) + self.assertEqual(cwd, 'foo/bar') + + +class CheckerTest(unittest.TestCase): + dialog = Dummy_checker() + + @classmethod + def setUpClass(cls): + cls.dialog.dialog = cls.dialog + checker_module.tkMessageBox = Mbox + checker_module.ConfigCheckerDialog = Mock() + cls.dialog.root = Tk() + cls.dialog.listbox = Listbox(cls.dialog.root) + + @classmethod + def tearDownClass(cls): + checker_module.ConfigCheckerDialog = orig_config_checker_dialog + cls.dialog.listbox.destroy() + cls.dialog.root.destroy() + del cls.dialog.listbox, cls.dialog.root + + def tearDown(self): + self.dialog.update_listbox() + + def test_add_checker(self): + self.dialog.add_checker() + checker_module.ConfigCheckerDialog.assert_called_with(self.dialog, '') + + def test_bad_edit(self): + showerror.title = '' + showerror.message = '' + self.dialog.listbox.selection_clear(0, END) + self.dialog.edit_checker() + self.assertEqual(showerror.title, 'No Checker Selected') + self.assertIn('existing', showerror.message) + + def test_good_edit(self): + self.dialog.listbox.selection_set(END) + self.dialog.edit_checker() + checker_module.ConfigCheckerDialog.assert_called_with(self.dialog, + 'three') + + def test_bad_remove(self): + self.dialog.listbox.selection_clear(0, END) + showerror.title = '' + showerror.message = '' + self.dialog.remove_checker() + self.assertEqual(showerror.title, 'No Checker Selected') + self.assertIn('existing', showerror.message) + + def test_dont_confirm_remove(self): + self.dialog.listbox.selection_set(END) + askyesno.result = False + self.assertIsNone(self.dialog.remove_checker()) + self.assertIn('Confirm', askyesno.title) + self.assertIn('three', askyesno.title) + self.assertIn('three', askyesno.message) + self.assertIn('remove', askyesno.message) + + def test_good_remove(self): + self.dialog.listbox.selection_set(END) + askyesno.result = True + self.dialog.remove_checker() + self.assertIn('Confirm', askyesno.title) + self.assertIn('three', askyesno.title) + self.assertIn('three', askyesno.message) + self.assertIn('remove', askyesno.message) + idleConf = checker_module.idleConf + remove = idleConf.userCfg['checker'].remove_option + remove.assert_has_calls([call('three', 'enabled'), + call('three', 'command'), + call('three', 'additional'), + call('three', 'reload_source'), + call('three', 'show_result'), + ]) + save = idleConf.userCfg['checker'].Save + save.assert_called_with() + + def test_update_listbox(self): + self.dialog.listbox.delete(0, END) + self.dialog.update_listbox() + list_items = self.dialog.listbox.get(0, END) + self.assertTupleEqual(list_items, ('one', 'two', 'three')) + + def test_selected_checker(self): + self.dialog.listbox.selection_clear(0, END) + self.assertIsNone(self.dialog._selected_checker()) + self.dialog.listbox.selection_set("end") + self.assertEqual(self.dialog._selected_checker(), 'three') + + +class ConfigCheckerDialogTest(unittest.TestCase): + dialog = Dummy_config_checker_dialog() + + def test_blank_name(self): + for name in ('', ' '): + self.dialog.name.set(name) + self.assertFalse(self.dialog.name_ok()) + self.assertEqual(showerror.title, 'Name Error') + self.assertIn('No', showerror.message) + + self.dialog.name.set(' ') + self.assertFalse(self.dialog.name_ok()) + self.assertEqual(showerror.title, 'Name Error') + self.assertIn('No', showerror.message) + + def test_good_name(self): + self.dialog.name.set('pyflakes') + self.assertTrue(self.dialog.name_ok()) + + def test_blank_command(self): + for command in ('', ' '): + self.dialog.command.set(command) + self.assertFalse(self.dialog.command_ok()) + self.assertEqual(showerror.title, 'Command Error') + self.assertIn('No', showerror.message) + + def test_good_command(self): + self.dialog.command.set('/bin/pyflakes') + self.assertTrue(self.dialog.command_ok()) + + def test_good_ok(self): + self.dialog.name.set('bar') + self.dialog.enabled.set(0) + self.dialog.command.set('foo') + self.dialog.additional.set('') + self.dialog.reload_source.set(0) + self.dialog.show_result.set(1) + self.dialog.ok() + idleConf = checker_module.idleConf + set_option = idleConf.userCfg['checker'].SetOption + set_option.assert_has_calls([call('bar', 'enabled', 0), + call('bar', 'command', 'foo'), + call('bar', 'additional', ''), + call('bar', 'reload_source', 0), + call('bar', 'show_result', 1), + ]) + save = idleConf.userCfg['checker'].Save + save.assert_called_with() + + def test_update_call_string(self): + self.dialog.editwin.io.filename = '/foo/bar/filename.py' + self.dialog.command.set('checker') + self.dialog.additional.set('--help') + self.dialog.update_call_string() + self.assertEqual(self.dialog.call_string.get(), + 'checker --help filename.py') + + self.dialog.editwin.io.filename = None + self.dialog.update_call_string() + self.assertEqual(self.dialog.call_string.get(), + 'checker --help ') + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Misc/NEWS.d/next/IDLE/2018-10-11-10-35-43.bpo-21880.5HQAVx.rst b/Misc/NEWS.d/next/IDLE/2018-10-11-10-35-43.bpo-21880.5HQAVx.rst new file mode 100644 index 00000000000000..9a354724f2899f --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2018-10-11-10-35-43.bpo-21880.5HQAVx.rst @@ -0,0 +1,2 @@ +IDLE can now run external code checkers on files opened in the editor. +Original patch by Saimadhav Heblikar.