From 12a7b6b72f08af7e05c033fea10f60d63395672a Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Thu, 9 Dec 2021 14:19:30 +0100 Subject: [PATCH 01/83] update decorations decorations --- spyder/plugins/editor/extensions/snippets.py | 1 - spyder/plugins/editor/utils/decoration.py | 59 +++++++++++-------- spyder/plugins/editor/widgets/base.py | 21 +------ spyder/plugins/editor/widgets/codeeditor.py | 17 ++---- .../editor/widgets/tests/test_decorations.py | 7 ++- 5 files changed, 46 insertions(+), 59 deletions(-) diff --git a/spyder/plugins/editor/extensions/snippets.py b/spyder/plugins/editor/extensions/snippets.py index 9d2e3326f1d..0c5c908925e 100644 --- a/spyder/plugins/editor/extensions/snippets.py +++ b/spyder/plugins/editor/extensions/snippets.py @@ -779,7 +779,6 @@ def draw_snippets(self): self.editor.highlight_selection('code_snippets', QTextCursor(cursor), outline_color=color) - self.editor.update_extra_selections() def select_snippet(self, snippet_number): cursor = self.editor.textCursor() diff --git a/spyder/plugins/editor/utils/decoration.py b/spyder/plugins/editor/utils/decoration.py index fcb8a5b11b7..a45b4301e00 100644 --- a/spyder/plugins/editor/utils/decoration.py +++ b/spyder/plugins/editor/utils/decoration.py @@ -29,6 +29,12 @@ UPDATE_TIMEOUT = 15 # milliseconds +def order_function(sel): + end = sel.cursor.selectionEnd() + start = sel.cursor.selectionStart() + return sel.draw_order, -(end - start) + + class TextDecorationsManager(Manager, QObject): """ Manages the collection of TextDecoration that have been set on the editor @@ -37,7 +43,7 @@ class TextDecorationsManager(Manager, QObject): def __init__(self, editor): super(TextDecorationsManager, self).__init__(editor) QObject.__init__(self, None) - self._decorations = [] + self._decorations = {"misc": []} # Timer to not constantly update decorations. self.update_timer = QTimer(self) @@ -58,20 +64,26 @@ def add(self, decorations): Returns: int: Amount of decorations added. """ + current_decorations = self._decorations["misc"] added = 0 if isinstance(decorations, list): - not_repeated = set(decorations) - set(self._decorations) - self._decorations.extend(list(not_repeated)) + not_repeated = set(decorations) - set(current_decorations) + current_decorations.extend(list(not_repeated)) + self._decorations["misc"] = current_decorations added = len(not_repeated) - elif decorations not in self._decorations: - self._decorations.append(decorations) + elif decorations not in current_decorations: + self._decorations["misc"].append(decorations) added = 1 if added > 0: - self._order_decorations() self.update() return added + def add_key(self, key, decorations): + """Add decorations to key.""" + self._decorations[key] = decorations + self.update() + def remove(self, decoration): """ Removes a text decoration from the editor. @@ -83,15 +95,23 @@ def remove(self, decoration): several decorations """ try: - self._decorations.remove(decoration) + self._decorations["misc"].remove(decoration) self.update() return True except ValueError: return False + def remove_key(self, key): + """Remove key""" + try: + del self._decorations[key] + self.update() + except KeyError: + pass + def clear(self): """Removes all text decoration from the editor.""" - self._decorations[:] = [] + self._decorations = {"misc": []} self.update() def update(self): @@ -119,7 +139,7 @@ def _update(self): # Update visible decorations visible_decorations = [] - for decoration in self._decorations: + for decoration in self._sorted_decorations(): need_update_sel = False cursor = decoration.cursor sel_start = cursor.selectionStart() @@ -153,18 +173,9 @@ def __iter__(self): def __len__(self): return len(self._decorations) - def _order_decorations(self): - """Order decorations according draw_order and size of selection. - - Highest draw_order will appear on top of the lowest values. - - If draw_order is equal,smaller selections are draw in top of - bigger selections. - """ - def order_function(sel): - end = sel.cursor.selectionEnd() - start = sel.cursor.selectionStart() - return sel.draw_order, -(end - start) - - self._decorations = sorted(self._decorations, - key=order_function) + def _sorted_decorations(self): + """Get all sorted decorations.""" + return sorted( + [v for key in self._decorations + for v in self._decorations[key]], + key=order_function) \ No newline at end of file diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py index 35647241355..8e0de597858 100644 --- a/spyder/plugins/editor/widgets/base.py +++ b/spyder/plugins/editor/widgets/base.py @@ -191,20 +191,9 @@ def set_extra_selections(self, key, extra_selections): selection.draw_order = draw_order selection.kind = key - self.clear_extra_selections(key) self.extra_selections_dict[key] = extra_selections - - def update_extra_selections(self): - """Add extra selections to DecorationsManager. - - TODO: This method could be remove it and decorations could be - added/removed in set_extra_selections/clear_extra_selections. - """ - extra_selections = [] - - for key, extra in list(self.extra_selections_dict.items()): - extra_selections.extend(extra) - self.decorations.add(extra_selections) + self.decorations.add_key(key, extra_selections) + self.update() def clear_extra_selections(self, key): """Remove decorations added through set_extra_selections. @@ -212,8 +201,7 @@ def clear_extra_selections(self, key): Args: key (str) name of the extra selections group. """ - for decoration in self.extra_selections_dict.get(key, []): - self.decorations.remove(decoration) + self.decorations.remove_key(key) self.extra_selections_dict[key] = [] self.update() @@ -256,7 +244,6 @@ def highlight_current_line(self): selection.format.setBackground(self.currentline_color) selection.cursor.clearSelection() self.set_extra_selections('current_line', [selection]) - self.update_extra_selections() def unhighlight_current_line(self): """Unhighlight current line""" @@ -284,7 +271,6 @@ def highlight_current_cell(self): self.clear_extra_selections('current_cell') else: self.set_extra_selections('current_cell', [selection]) - self.update_extra_selections() def unhighlight_current_cell(self): """Unhighlight current cell""" @@ -419,7 +405,6 @@ def __highlight(self, positions, color=None, cancel=False): QTextCursor.KeepAnchor) extra_selections.append(selection) self.set_extra_selections('brace_matching', extra_selections) - self.update_extra_selections() def cursor_position_changed(self): """Handle brace matching.""" diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index c37a95299d1..9dabdd8b31b 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -1348,9 +1348,6 @@ def _process_code_analysis(self, underline): data.selection_start = start data.selection_end = end - # Don't call highlight_selection with `update=True` so that - # all underline selections are updated in bulk in - # underline_errors. self.highlight_selection('code_analysis_underline', data._selection(), underline_color=block.color) @@ -2465,8 +2462,7 @@ def get_selection(self, cursor, foreground_color=None, def highlight_selection(self, key, cursor, foreground_color=None, background_color=None, underline_color=None, outline_color=None, - underline_style=QTextCharFormat.SingleUnderline, - update=False): + underline_style=QTextCharFormat.SingleUnderline): selection = self.get_selection( cursor, foreground_color, background_color, underline_color, @@ -2476,8 +2472,6 @@ def highlight_selection(self, key, cursor, foreground_color=None, extra_selections = self.get_extra_selections(key) extra_selections.append(selection) self.set_extra_selections(key, extra_selections) - if update: - self.update_extra_selections() def __mark_occurrences(self): """Marking occurrences of the currently selected word""" @@ -2518,7 +2512,6 @@ def __mark_occurrences(self): self.occurrence_color) cursor = self.__find_next(text, cursor) self.set_extra_selections('occurrences', extra_selections) - self.update_extra_selections() if len(self.occurrences) > 1 and self.occurrences[-1] == 0: # XXX: this is never happening with PySide but it's necessary @@ -2554,7 +2547,6 @@ def highlight_found_results(self, pattern, word=False, regexp=False, selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor) extra_selections.append(selection) self.set_extra_selections('find', extra_selections) - self.update_extra_selections() def clear_found_results(self): """Clear found results highlighting""" @@ -3154,8 +3146,7 @@ def highlight_line_warning(self, block_data): self.clear_extra_selections('code_analysis_highlight') self.highlight_selection('code_analysis_highlight', block_data._selection(), - background_color=block_data.color, - update=True) + background_color=block_data.color) self.linenumberarea.update() def get_current_warnings(self): @@ -4904,7 +4895,7 @@ def _handle_goto_definition_event(self, pos): cursor.select(QTextCursor.WordUnderCursor) self.clear_extra_selections('ctrl_click') self.highlight_selection( - 'ctrl_click', cursor, update=True, + 'ctrl_click', cursor, foreground_color=self.ctrl_click_color, underline_color=self.ctrl_click_color, underline_style=QTextCharFormat.SingleUnderline) @@ -4928,7 +4919,7 @@ def _handle_goto_uri_event(self, pos): self.clear_extra_selections('ctrl_click') self.highlight_selection( - 'ctrl_click', cursor, update=True, + 'ctrl_click', cursor, foreground_color=color, underline_color=color, underline_style=QTextCharFormat.SingleUnderline) diff --git a/spyder/plugins/editor/widgets/tests/test_decorations.py b/spyder/plugins/editor/widgets/tests/test_decorations.py index a1a36e13300..9fe9453af99 100644 --- a/spyder/plugins/editor/widgets/tests/test_decorations.py +++ b/spyder/plugins/editor/widgets/tests/test_decorations.py @@ -54,7 +54,8 @@ def test_decorations(codeeditor, qtbot): # Assert number of decorations is the one we expect. qtbot.wait(3000) - decorations = editor.decorations._decorations + decorations = editor.decorations._sorted_decorations() + assert len(decorations) == 2 + text.count('some_variable') # Assert that selection 0 is current cell @@ -78,7 +79,7 @@ def test_decorations(codeeditor, qtbot): # Clear decorations to be sure they are painted again below. editor.decorations.clear() editor.decorations._update() - assert editor.decorations._decorations == [] + assert editor.decorations._sorted_decorations() == [] # Move to a random place in the file and wait until decorations are # updated. @@ -87,7 +88,7 @@ def test_decorations(codeeditor, qtbot): qtbot.wait(editor.UPDATE_DECORATIONS_TIMEOUT + 100) # Assert a new cell is painted - decorations = editor.decorations._decorations + decorations = editor.decorations._sorted_decorations() assert decorations[0].kind == 'current_cell' From c889d1d5e64b39ebc36000a6e34ec188fca1fb45 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Thu, 9 Dec 2021 13:50:07 +0100 Subject: [PATCH 02/83] replace_all --- spyder/widgets/findreplace.py | 115 ++++++++++++++++------------------ spyder/widgets/mixins.py | 8 +-- 2 files changed, 59 insertions(+), 64 deletions(-) diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py index 00cbde457cc..0bf1383f407 100644 --- a/spyder/widgets/findreplace.py +++ b/spyder/widgets/findreplace.py @@ -486,7 +486,7 @@ def find(self, changed=True, forward=True, rehighlight=True, return found @Slot() - def replace_find(self, focus_replace_text=False, replace_all=False): + def replace_find(self, focus_replace_text=False): """Replace and find.""" if self.editor is None: return @@ -507,84 +507,50 @@ def replace_find(self, focus_replace_text=False, replace_all=False): # Do nothing with an invalid regexp return - first = True + # First found + seltxt = to_text_string(self.editor.get_selected_text()) + cmptxt1 = search_text if case else search_text.lower() + cmptxt2 = seltxt if case else seltxt.lower() + do_replace = True + if re_pattern is None: + has_selected = self.editor.has_selected_text() + if not has_selected or cmptxt1 != cmptxt2: + if not self.find(changed=False, forward=True, + rehighlight=False): + do_replace = False + else: + if len(re_pattern.findall(cmptxt2)) <= 0: + if not self.find(changed=False, forward=True, + rehighlight=False): + do_replace = False cursor = None - while True: - if first: - # First found - seltxt = to_text_string(self.editor.get_selected_text()) - cmptxt1 = search_text if case else search_text.lower() - cmptxt2 = seltxt if case else seltxt.lower() - if re_pattern is None: - has_selected = self.editor.has_selected_text() - if has_selected and cmptxt1 == cmptxt2: - # Text was already found, do nothing - pass - else: - if not self.find(changed=False, forward=True, - rehighlight=False): - break - else: - if len(re_pattern.findall(cmptxt2)) > 0: - pass - else: - if not self.find(changed=False, forward=True, - rehighlight=False): - break - first = False - wrapped = False - position = self.editor.get_position('cursor') - position0 = position - cursor = self.editor.textCursor() - cursor.beginEditBlock() - else: - position1 = self.editor.get_position('cursor') - if is_position_inf(position1, - position0 + len(replace_text) - - len(search_text) + 1): - # Identify wrapping even when the replace string - # includes part of the search string - wrapped = True - - if wrapped: - if (position1 == position - or is_position_sup(position1, position)): - # Avoid infinite loop: replace string includes - # part of the search string - break - - if position1 == position0: - # Avoid infinite loop: single found occurrence - break - position0 = position1 - + if do_replace: + cursor = self.editor.textCursor() + cursor.beginEditBlock() + if re_pattern is None: cursor.removeSelectedText() cursor.insertText(replace_text) else: seltxt = to_text_string(cursor.selectedText()) - + # Note: If the selection obtained from an editor spans a line # break, the text will contain a Unicode U+2029 paragraph # separator character instead of a newline \n character. # See: spyder-ide/spyder#2675 eol_char = get_eol_chars(self.editor.toPlainText()) seltxt = seltxt.replace(u'\u2029', eol_char) - + cursor.removeSelectedText() cursor.insertText(re_pattern.sub(replace_text, seltxt)) - + if self.find_next(set_focus=False): found_cursor = self.editor.textCursor() cursor.setPosition(found_cursor.selectionStart(), QTextCursor.MoveAnchor) cursor.setPosition(found_cursor.selectionEnd(), QTextCursor.KeepAnchor) - else: - break - if not replace_all: - break if cursor is not None: cursor.endEditBlock() @@ -598,9 +564,38 @@ def replace_find(self, focus_replace_text=False, replace_all=False): self.editor.document_did_change() @Slot() - def replace_find_all(self, focus_replace_text=False): + def replace_find_all(self): """Replace and find all matching occurrences""" - self.replace_find(focus_replace_text, replace_all=True) + if self.editor is None: + return + replace_text = to_text_string(self.replace_text.currentText()) + search_text = to_text_string(self.search_text.currentText()) + re_pattern = None + case = self.case_button.isChecked() + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + re_enabled = self.re_button.isChecked() + # Check regexp before proceeding + if re_enabled: + try: + re_pattern = re.compile(search_text, flags=re_flags) + # Check if replace_text can be substituted in re_pattern + # Fixes spyder-ide/spyder#7177. + re_pattern.sub(replace_text, '') + except re.error: + # Do nothing with an invalid regexp + return + else: + re_pattern = re.compile(re.escape(search_text), flags=re_flags) + + cursor = self.editor._select_text("sof", "eof") + text = cursor.selectedText() + + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.insertText(re_pattern.sub(replace_text, text)) + cursor.endEditBlock() + + self.editor.setFocus() @Slot() def replace_find_selection(self, focus_replace_text=False): diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py index 030042759f9..501bad2bccd 100644 --- a/spyder/widgets/mixins.py +++ b/spyder/widgets/mixins.py @@ -870,7 +870,7 @@ def extend_selection_to_next(self, what='word', direction='left'): #------Text: get, set, ... - def __select_text(self, position_from, position_to): + def _select_text(self, position_from, position_to): position_from = self.get_position(position_from) position_to = self.get_position(position_to) cursor = self.textCursor() @@ -913,7 +913,7 @@ def get_text(self, position_from, position_to, remove_newlines=True): TODO: Evaluate if this is still a problem and if the workaround can be moved closer to where the problem occurs. """ - cursor = self.__select_text(position_from, position_to) + cursor = self._select_text(position_from, position_to) text = to_text_string(cursor.selectedText()) if remove_newlines: remove_newlines = position_from != 'sof' or position_to != 'eof' @@ -946,7 +946,7 @@ def insert_text(self, text, will_insert_text=True): self.document_did_change() def replace_text(self, position_from, position_to, text): - cursor = self.__select_text(position_from, position_to) + cursor = self._select_text(position_from, position_to) if self.sig_will_remove_selection is not None: start, end = self.get_selection_start_end(cursor) self.sig_will_remove_selection.emit(start, end) @@ -959,7 +959,7 @@ def replace_text(self, position_from, position_to, text): self.document_did_change() def remove_text(self, position_from, position_to): - cursor = self.__select_text(position_from, position_to) + cursor = self._select_text(position_from, position_to) if self.sig_will_remove_selection is not None: start, end = self.get_selection_start_end(cursor) self.sig_will_remove_selection.emit(start, end) From d68c929321d7e113d2cf1e5bfced94746801bec1 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Thu, 9 Dec 2021 14:19:47 +0100 Subject: [PATCH 03/83] less qstringlength --- spyder/plugins/editor/widgets/codeeditor.py | 6 +++++- spyder/utils/syntaxhighlighters.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index 9dabdd8b31b..c7829f68694 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -2538,8 +2538,12 @@ def highlight_found_results(self, pattern, word=False, regexp=False, return extra_selections = [] self.found_results = [] + has_unicode = len(text) != qstring_length(text) for match in regobj.finditer(text): - pos1, pos2 = sh.get_span(match) + if has_unicode: + pos1, pos2 = sh.get_span(match) + else: + pos1, pos2 = match.span() selection = TextDecoration(self.textCursor()) selection.format.setBackground(self.found_results_color) selection.cursor.setPosition(pos1) diff --git a/spyder/utils/syntaxhighlighters.py b/spyder/utils/syntaxhighlighters.py index bef4c2e2a16..d491287409b 100644 --- a/spyder/utils/syntaxhighlighters.py +++ b/spyder/utils/syntaxhighlighters.py @@ -129,7 +129,7 @@ def get_span(match, key=None): else: start, end = match.span() start = qstring_length(match.string[:start]) - end = qstring_length(match.string[:end]) + end = start + qstring_length(match.string[start:end]) return start, end From 8b5f82199e302c51e00a4fa50aa425f39498ba90 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Thu, 9 Dec 2021 14:25:45 +0100 Subject: [PATCH 04/83] pep8 --- spyder/plugins/editor/utils/decoration.py | 2 +- spyder/widgets/findreplace.py | 8 ++++---- spyder/widgets/mixins.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spyder/plugins/editor/utils/decoration.py b/spyder/plugins/editor/utils/decoration.py index a45b4301e00..c99d4550b3e 100644 --- a/spyder/plugins/editor/utils/decoration.py +++ b/spyder/plugins/editor/utils/decoration.py @@ -178,4 +178,4 @@ def _sorted_decorations(self): return sorted( [v for key in self._decorations for v in self._decorations[key]], - key=order_function) \ No newline at end of file + key=order_function) diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py index 0bf1383f407..5f54e31c0d4 100644 --- a/spyder/widgets/findreplace.py +++ b/spyder/widgets/findreplace.py @@ -527,23 +527,23 @@ def replace_find(self, focus_replace_text=False): if do_replace: cursor = self.editor.textCursor() cursor.beginEditBlock() - + if re_pattern is None: cursor.removeSelectedText() cursor.insertText(replace_text) else: seltxt = to_text_string(cursor.selectedText()) - + # Note: If the selection obtained from an editor spans a line # break, the text will contain a Unicode U+2029 paragraph # separator character instead of a newline \n character. # See: spyder-ide/spyder#2675 eol_char = get_eol_chars(self.editor.toPlainText()) seltxt = seltxt.replace(u'\u2029', eol_char) - + cursor.removeSelectedText() cursor.insertText(re_pattern.sub(replace_text, seltxt)) - + if self.find_next(set_focus=False): found_cursor = self.editor.textCursor() cursor.setPosition(found_cursor.selectionStart(), diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py index 501bad2bccd..c54cdaaa1e6 100644 --- a/spyder/widgets/mixins.py +++ b/spyder/widgets/mixins.py @@ -859,7 +859,6 @@ def move_cursor_to_next(self, what='word', direction='left'): """ self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) - #------Selection def extend_selection_to_next(self, what='word', direction='left'): """ @@ -868,9 +867,10 @@ def extend_selection_to_next(self, what='word', direction='left'): """ self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) - #------Text: get, set, ... + def _select_text(self, position_from, position_to): + """Select text and return cursor.""" position_from = self.get_position(position_from) position_to = self.get_position(position_to) cursor = self.textCursor() From c9eedeb42ec2f2de5fde77902d3b47239832646b Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Thu, 9 Dec 2021 15:06:42 +0100 Subject: [PATCH 05/83] fix line return --- spyder/widgets/findreplace.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py index 5f54e31c0d4..5ca74010d6d 100644 --- a/spyder/widgets/findreplace.py +++ b/spyder/widgets/findreplace.py @@ -588,8 +588,7 @@ def replace_find_all(self): re_pattern = re.compile(re.escape(search_text), flags=re_flags) cursor = self.editor._select_text("sof", "eof") - text = cursor.selectedText() - + text = self.editor.toPlainText() cursor.beginEditBlock() cursor.removeSelectedText() cursor.insertText(re_pattern.sub(replace_text, text)) From c1c50eced0f5ac2899a348bdfd7a12b5899c477e Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 15 Dec 2021 15:09:39 +0100 Subject: [PATCH 06/83] fix_highlight --- spyder/utils/syntaxhighlighters.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spyder/utils/syntaxhighlighters.py b/spyder/utils/syntaxhighlighters.py index d491287409b..297b5f05255 100644 --- a/spyder/utils/syntaxhighlighters.py +++ b/spyder/utils/syntaxhighlighters.py @@ -128,9 +128,9 @@ def get_span(match, key=None): start, end = match.span(key) else: start, end = match.span() - start = qstring_length(match.string[:start]) - end = start + qstring_length(match.string[start:end]) - return start, end + start16 = qstring_length(match.string[:start]) + end16 = start16 + qstring_length(match.string[start:end]) + return start16, end16 def get_color_scheme(name): From 18463732d6a8c54f249c587146b6036764171e89 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 15 Dec 2021 15:26:28 +0100 Subject: [PATCH 07/83] Add highlight test --- spyder/utils/tests/test_syntaxhighlighters.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/spyder/utils/tests/test_syntaxhighlighters.py b/spyder/utils/tests/test_syntaxhighlighters.py index add059ddbc9..94c8482a49b 100644 --- a/spyder/utils/tests/test_syntaxhighlighters.py +++ b/spyder/utils/tests/test_syntaxhighlighters.py @@ -48,6 +48,26 @@ def test_HtmlSH_unclosed_commend(): compare_formats(doc.firstBlock().layout().additionalFormats(), res, sh) +def test_PythonSH_UTF16_number(): + """UTF16 string""" + txt = '𨭎𨭎𨭎𨭎 = 100000000' + doc = QTextDocument(txt) + sh = PythonSH(doc, color_scheme='Spyder') + sh.rehighlightBlock(doc.firstBlock()) + res = [(0, 11, 'normal'), (11, 9, 'number')] + compare_formats(doc.firstBlock().layout().additionalFormats(), res, sh) + + +def test_PythonSH_UTF16_string(): + """UTF16 string""" + txt = '𨭎𨭎𨭎𨭎 = "𨭎𨭎𨭎𨭎"' + doc = QTextDocument(txt) + sh = PythonSH(doc, color_scheme='Spyder') + sh.rehighlightBlock(doc.firstBlock()) + res = [(0, 11, 'normal'), (11, 10, 'string')] + compare_formats(doc.firstBlock().layout().additionalFormats(), res, sh) + + def test_python_string_prefix(): if PY3: prefixes = ("r", "u", "R", "U", "f", "F", "fr", "Fr", "fR", "FR", From 4a6262a24425dda90f89b9d9b0381a42d9dc59c5 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 08:35:50 +0200 Subject: [PATCH 08/83] add get_widget_for shellwidget --- spyder/api/shellconnect/main_widget.py | 29 ++++++++++++++++---------- spyder/api/shellconnect/mixins.py | 16 ++++++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/spyder/api/shellconnect/main_widget.py b/spyder/api/shellconnect/main_widget.py index 760c2976670..b6427d73e6c 100644 --- a/spyder/api/shellconnect/main_widget.py +++ b/spyder/api/shellconnect/main_widget.py @@ -100,24 +100,31 @@ def add_shellwidget(self, shellwidget): def remove_shellwidget(self, shellwidget): """Remove widget associated to shellwidget.""" - shellwidget_id = id(shellwidget) - if shellwidget_id in self._shellwidgets: - widget = self._shellwidgets.pop(shellwidget_id) - self._stack.removeWidget(widget) - self.close_widget(widget) - self.update_actions() + widget = self.get_widget_for_shellwidget(shellwidget) + if widget is None: + return + self._stack.removeWidget(widget) + self.close_widget(widget) + self.update_actions() def set_shellwidget(self, shellwidget): """ Set widget associated with shellwidget as the current widget. """ - shellwidget_id = id(shellwidget) old_widget = self.current_widget() + widget = self.get_widget_for_shellwidget(shellwidget) + if widget is None: + return + self._stack.setCurrentWidget(widget) + self.switch_widget(widget, old_widget) + self.update_actions() + + def get_widget_for_shellwidget(self, shellwidget): + """return widget corresponding to shellwidget.""" + shellwidget_id = id(shellwidget) if shellwidget_id in self._shellwidgets: - widget = self._shellwidgets[shellwidget_id] - self._stack.setCurrentWidget(widget) - self.switch_widget(widget, old_widget) - self.update_actions() + return self._shellwidgets.pop(shellwidget_id) + return None def create_new_widget(self, shellwidget): """Create a widget to communicate with shellwidget.""" diff --git a/spyder/api/shellconnect/mixins.py b/spyder/api/shellconnect/mixins.py index 697f65651a3..4efa240f89b 100644 --- a/spyder/api/shellconnect/mixins.py +++ b/spyder/api/shellconnect/mixins.py @@ -95,6 +95,22 @@ def current_widget(self): """ return self.get_widget().current_widget() + def get_widget_for_shellwidget(self, shellwidget): + """ + Return the widget registered with the given shellwidget. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shell widget. + + Returns + ------- + current_widget: QWidget + The widget corresponding to the shellwidget, or None if not found. + """ + return self.get_widget().get_widget_for_shellwidget(shellwidget) + def on_connection_to_external_spyder_kernel(self, shellwidget): """ Actions to take when the IPython console connects to an From 77d719f9f5c7aed14e17a2b9df398dfd065c5cd2 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 08:36:35 +0200 Subject: [PATCH 09/83] remove unused keywords after #381 --- spyder/plugins/ipythonconsole/widgets/debugging.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/spyder/plugins/ipythonconsole/widgets/debugging.py b/spyder/plugins/ipythonconsole/widgets/debugging.py index a15e1f95dec..b235bf05aba 100644 --- a/spyder/plugins/ipythonconsole/widgets/debugging.py +++ b/spyder/plugins/ipythonconsole/widgets/debugging.py @@ -415,12 +415,6 @@ def refresh_from_pdb(self, pdb_state): if (fname, lineno) != last_pdb_loc: self.sig_pdb_step.emit(fname, lineno) - if 'namespace_view' in pdb_state: - self.set_namespace_view(pdb_state['namespace_view']) - - if 'var_properties' in pdb_state: - self.set_var_properties(pdb_state['var_properties']) - def set_pdb_state(self, pdb_state): """Set current pdb state.""" if pdb_state is not None and isinstance(pdb_state, dict): From 4157d38a0b568c9c827cfa7d10ccd581a498937a Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 09:17:35 +0200 Subject: [PATCH 10/83] separate shell from variableexplorer --- .../ipythonconsole/widgets/__init__.py | 1 - .../plugins/ipythonconsole/widgets/client.py | 4 - .../widgets/namespacebrowser.py | 239 ------------------ .../plugins/ipythonconsole/widgets/shell.py | 117 ++++++++- spyder/plugins/variableexplorer/plugin.py | 7 +- .../variableexplorer/widgets/main_widget.py | 11 + .../widgets/namespacebrowser.py | 85 ++++++- spyder/widgets/collectionseditor.py | 7 +- 8 files changed, 212 insertions(+), 259 deletions(-) delete mode 100644 spyder/plugins/ipythonconsole/widgets/namespacebrowser.py diff --git a/spyder/plugins/ipythonconsole/widgets/__init__.py b/spyder/plugins/ipythonconsole/widgets/__init__.py index f6bc62a94fe..c54a9c93de5 100644 --- a/spyder/plugins/ipythonconsole/widgets/__init__.py +++ b/spyder/plugins/ipythonconsole/widgets/__init__.py @@ -14,7 +14,6 @@ from .control import ControlWidget, PageControlWidget from .debugging import DebuggingWidget from .help import HelpWidget -from .namespacebrowser import NamepaceBrowserWidget from .figurebrowser import FigureBrowserWidget from .kernelconnect import KernelConnectionDialog from .restartdialog import ConsoleRestartDialog diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 5c9a7d4cf61..54041809c7c 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -476,10 +476,6 @@ def configure_shellwidget(self, give_focus=True): # To update history after execution self.shellwidget.executed.connect(self.update_history) - # To update the Variable Explorer after execution - self.shellwidget.executed.connect( - self.shellwidget.refresh_namespacebrowser) - # To enable the stop button when executing a process self.shellwidget.executing.connect( self.sig_execution_state_changed) diff --git a/spyder/plugins/ipythonconsole/widgets/namespacebrowser.py b/spyder/plugins/ipythonconsole/widgets/namespacebrowser.py deleted file mode 100644 index 5b60b58fee5..00000000000 --- a/spyder/plugins/ipythonconsole/widgets/namespacebrowser.py +++ /dev/null @@ -1,239 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Widget that handle communications between the IPython Console and -the Variable Explorer -""" - -import logging -import time -try: - time.monotonic # time.monotonic new in 3.3 -except AttributeError: - time.monotonic = time.time - -from pickle import PicklingError, UnpicklingError - -from qtpy.QtWidgets import QMessageBox - -from qtconsole.rich_jupyter_widget import RichJupyterWidget - -from spyder.config.base import _ -from spyder.py3compat import PY2, to_text_string, TimeoutError -from spyder_kernels.comms.commbase import CommError - - -logger = logging.getLogger(__name__) - -# Max time before giving up when making a blocking call to the kernel -CALL_KERNEL_TIMEOUT = 30 - - -class NamepaceBrowserWidget(RichJupyterWidget): - """ - Widget with the necessary attributes and methods to handle communications - between the IPython Console and the Variable Explorer - """ - - # Reference to the nsb widget connected to this client - namespacebrowser = None - - # To save the replies of kernel method executions (except - # getting values of variables) - _kernel_methods = {} - - # To save values and messages returned by the kernel - _kernel_is_starting = True - - # --- Public API -------------------------------------------------- - def set_namespacebrowser(self, namespacebrowser): - """Set namespace browser widget""" - self.namespacebrowser = namespacebrowser - - def refresh_namespacebrowser(self, interrupt=True): - """Refresh namespace browser""" - if self.kernel_client is None: - return - if self.namespacebrowser: - self.call_kernel( - interrupt=interrupt, - callback=self.set_namespace_view - ).get_namespace_view() - self.call_kernel( - interrupt=interrupt, - callback=self.set_var_properties - ).get_var_properties() - - def set_namespace_view(self, view): - """Set the current namespace view.""" - if self.namespacebrowser is not None: - self.namespacebrowser.process_remote_view(view) - - def set_var_properties(self, properties): - """Set var properties.""" - if self.namespacebrowser is not None: - self.namespacebrowser.set_var_properties(properties) - - def set_namespace_view_settings(self): - """Set the namespace view settings""" - if self.kernel_client is None: - return - if self.namespacebrowser: - settings = self.namespacebrowser.get_view_settings() - self.call_kernel( - interrupt=True - ).set_namespace_view_settings(settings) - - def get_value(self, name): - """Ask kernel for a value""" - reason_big = _("The variable is too big to be retrieved") - reason_not_picklable = _("The variable is not picklable") - reason_dead = _("The kernel is dead") - reason_other = _("An error occured, see the console.") - reason_comm = _("The comm channel is not working.") - msg = _("%s.

" - "Note: Please don't report this problem on Github, " - "there's nothing to do about it.") - try: - return self.call_kernel( - blocking=True, - display_error=True, - timeout=CALL_KERNEL_TIMEOUT).get_value(name) - except TimeoutError: - raise ValueError(msg % reason_big) - except (PicklingError, UnpicklingError, TypeError): - raise ValueError(msg % reason_not_picklable) - except RuntimeError: - raise ValueError(msg % reason_dead) - except KeyError: - raise - except CommError: - raise ValueError(msg % reason_comm) - except Exception: - raise ValueError(msg % reason_other) - - def set_value(self, name, value): - """Set value for a variable""" - self.call_kernel( - interrupt=True, - blocking=False, - display_error=True, - ).set_value(name, value) - - def remove_value(self, name): - """Remove a variable""" - self.call_kernel( - interrupt=True, - blocking=False, - display_error=True, - ).remove_value(name) - - def copy_value(self, orig_name, new_name): - """Copy a variable""" - self.call_kernel( - interrupt=True, - blocking=False, - display_error=True, - ).copy_value(orig_name, new_name) - - def load_data(self, filename, ext): - """Load data from a file.""" - overwrite = False - if self.namespacebrowser.editor.var_properties: - message = _('Do you want to overwrite old ' - 'variables (if any) in the namespace ' - 'when loading the data?') - buttons = QMessageBox.Yes | QMessageBox.No - result = QMessageBox.question( - self, _('Data loading'), message, buttons) - overwrite = result == QMessageBox.Yes - try: - return self.call_kernel( - blocking=True, - display_error=True, - timeout=CALL_KERNEL_TIMEOUT).load_data( - filename, ext, overwrite=overwrite) - except ImportError as msg: - module = str(msg).split("'")[1] - msg = _("Spyder is unable to open the file " - "you're trying to load because {module} is " - "not installed. Please install " - "this package in your working environment." - "
").format(module=module) - return msg - except TimeoutError: - msg = _("Data is too big to be loaded") - return msg - except (UnpicklingError, RuntimeError, CommError): - return None - - def save_namespace(self, filename): - try: - return self.call_kernel( - blocking=True, - display_error=True, - timeout=CALL_KERNEL_TIMEOUT).save_namespace(filename) - except TimeoutError: - msg = _("Data is too big to be saved") - return msg - except (UnpicklingError, RuntimeError, CommError): - return None - - # ---- Private API (overrode by us) ---------------------------- - def _handle_execute_reply(self, msg): - """ - Reimplemented to handle communications between Spyder - and the kernel - """ - msg_id = msg['parent_header']['msg_id'] - info = self._request_info['execute'].get(msg_id) - # unset reading flag, because if execute finished, raw_input can't - # still be pending. - self._reading = False - - # Refresh namespacebrowser after the kernel starts running - exec_count = msg['content'].get('execution_count', '') - if exec_count == 0 and self._kernel_is_starting: - if self.namespacebrowser is not None: - self.set_namespace_view_settings() - self.refresh_namespacebrowser(interrupt=False) - self._kernel_is_starting = False - self.ipyclient.t0 = time.monotonic() - - # Handle silent execution of kernel methods - if info and info.kind == 'silent_exec_method': - self.handle_exec_method(msg) - self._request_info['execute'].pop(msg_id) - else: - super(NamepaceBrowserWidget, self)._handle_execute_reply(msg) - - def _handle_status(self, msg): - """ - Reimplemented to refresh the namespacebrowser after kernel - restarts - """ - state = msg['content'].get('execution_state', '') - msg_type = msg['parent_header'].get('msg_type', '') - if state == 'starting': - # This is needed to show the time a kernel - # has been alive in each console. - self.ipyclient.t0 = time.monotonic() - self.ipyclient.timer.timeout.connect(self.ipyclient.show_time) - self.ipyclient.timer.start(1000) - - # This handles restarts when the kernel dies - # unexpectedly - if not self._kernel_is_starting: - self._kernel_is_starting = True - elif state == 'idle' and msg_type == 'shutdown_request': - # This handles restarts asked by the user - if self.namespacebrowser is not None: - self.set_namespace_view_settings() - self.refresh_namespacebrowser(interrupt=False) - self.ipyclient.t0 = time.monotonic() - else: - super(NamepaceBrowserWidget, self)._handle_status(msg) diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 469f827b168..4855a005e0a 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -11,15 +11,18 @@ # Standard library imports import os import os.path as osp +import time import uuid from textwrap import dedent from threading import Lock +from pickle import PicklingError, UnpicklingError # Third party imports from qtpy.QtCore import Signal, QThread from qtpy.QtWidgets import QMessageBox from qtpy import QtCore, QtWidgets, QtGui from traitlets import observe +from spyder_kernels.comms.commbase import CommError # Local imports from spyder.config.base import ( @@ -35,15 +38,17 @@ from spyder.plugins.ipythonconsole.comms.kernelcomm import KernelComm from spyder.plugins.ipythonconsole.widgets import ( ControlWidget, DebuggingWidget, FigureBrowserWidget, HelpWidget, - NamepaceBrowserWidget, PageControlWidget) + PageControlWidget) MODULES_FAQ_URL = ( "https://docs.spyder-ide.org/5/faq.html#using-packages-installer") +# Max time before giving up when making a blocking call to the kernel +CALL_KERNEL_TIMEOUT = 30 -class ShellWidget(NamepaceBrowserWidget, HelpWidget, DebuggingWidget, - FigureBrowserWidget): + +class ShellWidget(HelpWidget, DebuggingWidget, FigureBrowserWidget): """ Shell widget for the IPython Console @@ -82,6 +87,15 @@ class ShellWidget(NamepaceBrowserWidget, HelpWidget, DebuggingWidget, # Class array of shutdown threads shutdown_thread_list = [] + # To save the replies of kernel method executions (except + # getting values of variables) + _kernel_methods = {} + + # To save values and messages returned by the kernel + _kernel_is_starting = True + + sig_kernel_has_started = Signal() + @classmethod def prune_shutdown_thread_list(cls): """Remove shutdown threads.""" @@ -587,7 +601,7 @@ def _perform_reset(self, message): # This doesn't need to interrupt the kernel because # "%reset -f" is being executed before it. # Fixes spyder-ide/spyder#12689 - self.refresh_namespacebrowser(interrupt=False) + self.sig_kernel_has_started.emit() if self.is_spyder_kernel: self.call_kernel().close_all_mpl_figures() @@ -918,7 +932,47 @@ def cut(self): super().cut() self._save_clipboard_indentation() - # ---- Private methods (overrode by us) ----------------------------------- + # ---- Private API (overrode by us) --------------------------------------- + def _handle_execute_reply(self, msg): + """ + Reimplemented to handle communications between Spyder + and the kernel + """ + # Refresh namespacebrowser after the kernel starts running + exec_count = msg['content'].get('execution_count', '') + if exec_count == 0 and self._kernel_is_starting: + self.sig_kernel_has_started.emit() + self.ipyclient.t0 = time.monotonic() + self._kernel_is_starting = False + + + super(ShellWidget, self)._handle_execute_reply(msg) + + def _handle_status(self, msg): + """ + Reimplemented to refresh the namespacebrowser after kernel + restarts + """ + state = msg['content'].get('execution_state', '') + msg_type = msg['parent_header'].get('msg_type', '') + if state == 'starting': + # This is needed to show the time a kernel + # has been alive in each console. + self.ipyclient.t0 = time.monotonic() + self.ipyclient.timer.timeout.connect(self.ipyclient.show_time) + self.ipyclient.timer.start(1000) + + # This handles restarts when the kernel dies + # unexpectedly + if not self._kernel_is_starting: + self._kernel_is_starting = True + elif state == 'idle' and msg_type == 'shutdown_request': + # This handles restarts asked by the user + self.sig_kernel_has_started.emit() + self.ipyclient.t0 = time.monotonic() + else: + super(ShellWidget, self)._handle_status(msg) + def _handle_error(self, msg): """ Reimplemented to reset the prompt if the error comes after the reply @@ -1015,3 +1069,56 @@ def focusOutEvent(self, event): """Reimplement Qt method to send focus change notification""" self.sig_focus_changed.emit() return super(ShellWidget, self).focusOutEvent(event) + + # --- value access -------------------------------------------------------- + def get_value(self, name): + """Ask kernel for a value""" + reason_big = _("The variable is too big to be retrieved") + reason_not_picklable = _("The variable is not picklable") + reason_dead = _("The kernel is dead") + reason_other = _("An error occured, see the console.") + reason_comm = _("The comm channel is not working.") + msg = _("%s.

" + "Note: Please don't report this problem on Github, " + "there's nothing to do about it.") + try: + return self.call_kernel( + blocking=True, + display_error=True, + timeout=CALL_KERNEL_TIMEOUT).get_value(name) + except TimeoutError: + raise ValueError(msg % reason_big) + except (PicklingError, UnpicklingError, TypeError): + raise ValueError(msg % reason_not_picklable) + except RuntimeError: + raise ValueError(msg % reason_dead) + except KeyError: + raise + except CommError: + raise ValueError(msg % reason_comm) + except Exception: + raise ValueError(msg % reason_other) + + def set_value(self, name, value): + """Set value for a variable""" + self.call_kernel( + interrupt=True, + blocking=False, + display_error=True, + ).set_value(name, value) + + def remove_value(self, name): + """Remove a variable""" + self.call_kernel( + interrupt=True, + blocking=False, + display_error=True, + ).remove_value(name) + + def copy_value(self, orig_name, new_name): + """Copy a variable""" + self.call_kernel( + interrupt=True, + blocking=False, + display_error=True, + ).copy_value(orig_name, new_name) diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py index 9cb92684c57..53ad68b73d9 100644 --- a/spyder/plugins/variableexplorer/plugin.py +++ b/spyder/plugins/variableexplorer/plugin.py @@ -68,5 +68,8 @@ def on_preferences_teardown(self): # ------------------------------------------------------------------------ def on_connection_to_external_spyder_kernel(self, shellwidget): """Send namespace view settings to the kernel.""" - shellwidget.set_namespace_view_settings() - shellwidget.refresh_namespacebrowser() + widget = self.get_widget_for_shellwidget(shellwidget) + if widget is None: + return + widget.set_namespace_view_settings() + widget.refresh_namespacebrowser() diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py index 24b905e3050..664844adf88 100644 --- a/spyder/plugins/variableexplorer/widgets/main_widget.py +++ b/spyder/plugins/variableexplorer/widgets/main_widget.py @@ -426,6 +426,13 @@ def create_new_widget(self, shellwidget): nsb.set_shellwidget(shellwidget) nsb.setup() self._set_actions_and_menus(nsb) + + # To update the Variable Explorer after execution + shellwidget.executed.connect( + nsb.refresh_namespacebrowser) + shellwidget.sig_kernel_has_started.connect( + nsb.on_kernel_started) + return nsb def close_widget(self, nsb): @@ -438,6 +445,10 @@ def close_widget(self, nsb): self.start_spinner) nsb.sig_stop_spinner_requested.disconnect( self.stop_spinner) + nsb.shellwidget.executed.disconnect( + nsb.refresh_namespacebrowser) + nsb.shellwidget.sig_kernel_has_started.disconnect( + nsb.on_kernel_started) nsb.close() def import_data(self, filenames=None): diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index f2aad637589..44afa7c91ab 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -13,6 +13,7 @@ # Standard library imports import os import os.path as osp +from pickle import UnpicklingError # Third library imports from qtpy import PYQT5 @@ -21,6 +22,7 @@ from qtpy.QtGui import QCursor from qtpy.QtWidgets import (QApplication, QInputDialog, QMessageBox, QVBoxLayout, QWidget) +from spyder_kernels.comms.commbase import CommError from spyder_kernels.utils.iofuncs import iofunctions from spyder_kernels.utils.misc import fix_reference_name from spyder_kernels.utils.nsview import REMOTE_SETTINGS @@ -40,6 +42,8 @@ # Constants VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w" +# Max time before giving up when making a blocking call to the kernel +CALL_KERNEL_TIMEOUT = 30 class NamespaceBrowser(QWidget, SpyderWidgetMixin): @@ -94,7 +98,7 @@ def setup(self): assert self.shellwidget is not None if self.editor is not None: - self.shellwidget.set_namespace_view_settings() + self.set_namespace_view_settings() self.refresh_table() else: # Widgets @@ -147,16 +151,44 @@ def get_view_settings(self): def set_shellwidget(self, shellwidget): """Bind shellwidget instance to namespace browser""" self.shellwidget = shellwidget - shellwidget.set_namespacebrowser(self) def refresh_table(self): """Refresh variable table.""" - self.shellwidget.refresh_namespacebrowser() + self.refresh_namespacebrowser() try: self.editor.resizeRowToContents() except TypeError: pass + def refresh_namespacebrowser(self, interrupt=True): + """Refresh namespace browser""" + if self.shellwidget.kernel_client is None: + return + + self.shellwidget.call_kernel( + interrupt=interrupt, + callback=self.process_remote_view + ).get_namespace_view() + + self.shellwidget.call_kernel( + interrupt=interrupt, + callback=self.set_var_properties + ).get_var_properties() + + def set_namespace_view_settings(self): + """Set the namespace view settings""" + if self.shellwidget.kernel_client is None: + return + + settings = self.get_view_settings() + self.shellwidget.call_kernel( + interrupt=True + ).set_namespace_view_settings(settings) + + def on_kernel_started(self): + self.set_namespace_view_settings() + self.refresh_namespacebrowser(interrupt=False) + def process_remote_view(self, remote_view): """Process remote view""" if remote_view is not None: @@ -232,7 +264,7 @@ def import_data(self, filenames=None): else: QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) QApplication.processEvents() - error_message = self.shellwidget.load_data(self.filename, ext) + error_message = self.load_data(self.filename, ext) QApplication.restoreOverrideCursor() QApplication.processEvents() @@ -244,6 +276,37 @@ def import_data(self, filenames=None): ) % (self.filename, error_message)) self.refresh_table() + def load_data(self, filename, ext): + """Load data from a file.""" + overwrite = False + if self.editor.var_properties: + message = _('Do you want to overwrite old ' + 'variables (if any) in the namespace ' + 'when loading the data?') + buttons = QMessageBox.Yes | QMessageBox.No + result = QMessageBox.question( + self, _('Data loading'), message, buttons) + overwrite = result == QMessageBox.Yes + try: + return self.shellwidget.call_kernel( + blocking=True, + display_error=True, + timeout=CALL_KERNEL_TIMEOUT).load_data( + filename, ext, overwrite=overwrite) + except ImportError as msg: + module = str(msg).split("'")[1] + msg = _("Spyder is unable to open the file " + "you're trying to load because {module} is " + "not installed. Please install " + "this package in your working environment." + "
").format(module=module) + return msg + except TimeoutError: + msg = _("Data is too big to be loaded") + return msg + except (UnpicklingError, RuntimeError, CommError): + return None + def reset_namespace(self): warning = self.get_conf( section='ipython_console', @@ -269,7 +332,7 @@ def save_data(self, filename=None): QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) QApplication.processEvents() - error_message = self.shellwidget.save_namespace(self.filename) + error_message = self.save_namespace(self.filename) QApplication.restoreOverrideCursor() QApplication.processEvents() @@ -286,3 +349,15 @@ def save_data(self, filename=None): "The error message was:
") + error_message QMessageBox.critical(self, _("Save data"), save_data_message) + + def save_namespace(self, filename): + try: + return self.shellwidget.call_kernel( + blocking=True, + display_error=True, + timeout=CALL_KERNEL_TIMEOUT).save_namespace(filename) + except TimeoutError: + msg = _("Data is too big to be saved") + return msg + except (UnpicklingError, RuntimeError, CommError): + return None diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index 96c10a9648a..68ad22c3734 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -1564,6 +1564,7 @@ def __init__(self, parent, data, shellwidget=None, remote_editing=False, create_menu=False): BaseTableView.__init__(self, parent) + self.namespacebrowser = parent self.shellwidget = shellwidget self.var_properties = {} self.dictfilter = None @@ -1616,18 +1617,18 @@ def new_value(self, name, value): except TypeError as e: QMessageBox.critical(self, _("Error"), "TypeError: %s" % to_text_string(e)) - self.shellwidget.refresh_namespacebrowser() + self.namespacebrowser.refresh_namespacebrowser() def remove_values(self, names): """Remove values from data""" for name in names: self.shellwidget.remove_value(name) - self.shellwidget.refresh_namespacebrowser() + self.namespacebrowser.refresh_namespacebrowser() def copy_value(self, orig_name, new_name): """Copy value""" self.shellwidget.copy_value(orig_name, new_name) - self.shellwidget.refresh_namespacebrowser() + self.namespacebrowser.refresh_namespacebrowser() def is_list(self, name): """Return True if variable is a list, a tuple or a set""" From c09c24b0dcba526f1487392c93c3e00bce1278b7 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 09:25:40 +0200 Subject: [PATCH 11/83] remove figurebrowser link --- .../ipythonconsole/widgets/figurebrowser.py | 14 +++----------- spyder/plugins/plots/widgets/figurebrowser.py | 5 ++++- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/spyder/plugins/ipythonconsole/widgets/figurebrowser.py b/spyder/plugins/ipythonconsole/widgets/figurebrowser.py index 6b71b00f13c..ba1c5e5c935 100644 --- a/spyder/plugins/ipythonconsole/widgets/figurebrowser.py +++ b/spyder/plugins/ipythonconsole/widgets/figurebrowser.py @@ -24,15 +24,8 @@ class FigureBrowserWidget(RichJupyterWidget): This widget can also block the plotting of inline figures in the IPython Console so that figures are only plotted in the plots plugin. """ - - # Reference to the figurebrowser widget connected to this client - figurebrowser = None - - # ---- Public API - def set_figurebrowser(self, figurebrowser): - """Set the namespace for the figurebrowser widget.""" - self.figurebrowser = figurebrowser - self.sended_render_message = False + mute_inline_plotting = None + sended_render_message = False # ---- Private API (overrode by us) def _handle_display_data(self, msg): @@ -56,8 +49,7 @@ def _handle_display_data(self, msg): if img is not None: self.sig_new_inline_figure.emit(img, fmt) - if (self.figurebrowser is not None and - self.figurebrowser.mute_inline_plotting): + if self.mute_inline_plotting: if not self.sended_render_message: self._append_html("
", before_prompt=True) self.append_html_message( diff --git a/spyder/plugins/plots/widgets/figurebrowser.py b/spyder/plugins/plots/widgets/figurebrowser.py index a1fd5537e26..32740f1eb8f 100644 --- a/spyder/plugins/plots/widgets/figurebrowser.py +++ b/spyder/plugins/plots/widgets/figurebrowser.py @@ -202,6 +202,9 @@ def setup(self, options): self.change_auto_fit_plotting(value) elif option == 'mute_inline_plotting': self.mute_inline_plotting = value + if self.shellwidget: + self.shellwidget.mute_inline_plotting = value + elif option == 'show_plot_outline': self.show_fig_outline_in_viewer(value) elif option == 'save_dir': @@ -238,7 +241,7 @@ def change_auto_fit_plotting(self, state): def set_shellwidget(self, shellwidget): """Bind the shellwidget instance to the figure browser""" self.shellwidget = shellwidget - shellwidget.set_figurebrowser(self) + self.shellwidget.mute_inline_plotting = self.mute_inline_plotting shellwidget.sig_new_inline_figure.connect(self._handle_new_figure) def _handle_new_figure(self, fig, fmt): From c5ef313aac5968183f7d568b7fe1f65ee11c9665 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 10:37:08 +0200 Subject: [PATCH 12/83] pep8 --- spyder/api/shellconnect/mixins.py | 2 +- spyder/plugins/ipythonconsole/widgets/shell.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/spyder/api/shellconnect/mixins.py b/spyder/api/shellconnect/mixins.py index 4efa240f89b..d7e221663ed 100644 --- a/spyder/api/shellconnect/mixins.py +++ b/spyder/api/shellconnect/mixins.py @@ -103,7 +103,7 @@ def get_widget_for_shellwidget(self, shellwidget): ---------- shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget The shell widget. - + Returns ------- current_widget: QWidget diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 4855a005e0a..938f65f91d9 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -945,7 +945,6 @@ def _handle_execute_reply(self, msg): self.ipyclient.t0 = time.monotonic() self._kernel_is_starting = False - super(ShellWidget, self)._handle_execute_reply(msg) def _handle_status(self, msg): From 90fb878e711dde69e4096c10ab59d90ab5fdcac1 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 11:05:48 +0200 Subject: [PATCH 13/83] fix get_widget_for_shellwidget --- spyder/api/shellconnect/main_widget.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spyder/api/shellconnect/main_widget.py b/spyder/api/shellconnect/main_widget.py index b6427d73e6c..dbe845a8965 100644 --- a/spyder/api/shellconnect/main_widget.py +++ b/spyder/api/shellconnect/main_widget.py @@ -100,12 +100,12 @@ def add_shellwidget(self, shellwidget): def remove_shellwidget(self, shellwidget): """Remove widget associated to shellwidget.""" - widget = self.get_widget_for_shellwidget(shellwidget) - if widget is None: - return - self._stack.removeWidget(widget) - self.close_widget(widget) - self.update_actions() + shellwidget_id = id(shellwidget) + if shellwidget_id in self._shellwidgets: + widget = self._shellwidgets.pop(shellwidget_id) + self._stack.removeWidget(widget) + self.close_widget(widget) + self.update_actions() def set_shellwidget(self, shellwidget): """ @@ -123,7 +123,7 @@ def get_widget_for_shellwidget(self, shellwidget): """return widget corresponding to shellwidget.""" shellwidget_id = id(shellwidget) if shellwidget_id in self._shellwidgets: - return self._shellwidgets.pop(shellwidget_id) + return self._shellwidgets[shellwidget_id] return None def create_new_widget(self, shellwidget): From 069a63bec7bbd14557be36b60ddf9599d299b4a6 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 11:32:08 +0200 Subject: [PATCH 14/83] fix silent_exec_method --- spyder/plugins/ipythonconsole/widgets/shell.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 938f65f91d9..829bf1deb21 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -938,14 +938,24 @@ def _handle_execute_reply(self, msg): Reimplemented to handle communications between Spyder and the kernel """ - # Refresh namespacebrowser after the kernel starts running + # Notify that kernel has started exec_count = msg['content'].get('execution_count', '') if exec_count == 0 and self._kernel_is_starting: self.sig_kernel_has_started.emit() self.ipyclient.t0 = time.monotonic() self._kernel_is_starting = False - super(ShellWidget, self)._handle_execute_reply(msg) + # Handle silent execution of kernel methods + msg_id = msg['parent_header']['msg_id'] + info = self._request_info['execute'].get(msg_id) + # unset reading flag, because if execute finished, raw_input can't + # still be pending. + self._reading = False + if info and info.kind == 'silent_exec_method': + self.handle_exec_method(msg) + self._request_info['execute'].pop(msg_id) + else: + super()._handle_execute_reply(msg) def _handle_status(self, msg): """ From 1544035079b045e6ff58c435ed79424a82591615 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 12:28:55 +0200 Subject: [PATCH 15/83] remove silent_exec_method --- .../plugins/ipythonconsole/widgets/shell.py | 99 ++++--------------- 1 file changed, 17 insertions(+), 82 deletions(-) diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 829bf1deb21..d5e4d29fa79 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -87,10 +87,6 @@ class ShellWidget(HelpWidget, DebuggingWidget, FigureBrowserWidget): # Class array of shutdown threads shutdown_thread_list = [] - # To save the replies of kernel method executions (except - # getting values of variables) - _kernel_methods = {} - # To save values and messages returned by the kernel _kernel_is_starting = True @@ -308,7 +304,22 @@ def check_spyder_kernel(self): if self._reading: return else: - self.silent_exec_method(code) + self._silent_exec_callback(code, self.check_spyder_kernel_callback) + + def check_spyder_kernel_callback(self, reply): + """ + Handle data returned by silent executions of kernel methods + + This is based on the _handle_exec_callback of RichJupyterWidget. + Therefore this is licensed BSD. + """ + # Process kernel reply + data = reply.get('data') + if data is not None and 'text/plain' in data: + is_spyder_kernel = data['text/plain'] + if 'SpyderKernel' in is_spyder_kernel: + self.is_spyder_kernel = True + self.sig_is_spykernel.emit(self) def set_cwd(self, dirname): """Set shell current working directory.""" @@ -683,72 +694,6 @@ def silent_execute(self, code): except AttributeError: pass - def silent_exec_method(self, code): - """Silently execute a kernel method and save its reply - - The methods passed here **don't** involve getting the value - of a variable but instead replies that can be handled by - ast.literal_eval. - - To get a value see `get_value` - - Parameters - ---------- - code : string - Code that contains the kernel method as part of its - string - - See Also - -------- - handle_exec_method : Method that deals with the reply - - Note - ---- - This is based on the _silent_exec_callback method of - RichJupyterWidget. Therefore this is licensed BSD - """ - # Generate uuid, which would be used as an indication of whether or - # not the unique request originated from here - local_uuid = to_text_string(uuid.uuid1()) - code = to_text_string(code) - if self.kernel_client is None: - return - - msg_id = self.kernel_client.execute( - '', silent=True, - user_expressions={local_uuid: code}) - self._kernel_methods[local_uuid] = code - self._request_info['execute'][msg_id] = self._ExecutionRequest( - msg_id, - 'silent_exec_method', - False) - - def handle_exec_method(self, msg): - """ - Handle data returned by silent executions of kernel methods - - This is based on the _handle_exec_callback of RichJupyterWidget. - Therefore this is licensed BSD. - """ - user_exp = msg['content'].get('user_expressions') - if not user_exp: - return - for expression in user_exp: - if expression in self._kernel_methods: - # Process kernel reply - method = self._kernel_methods[expression] - reply = user_exp[expression] - data = reply.get('data') - if 'getattr' in method: - if data is not None and 'text/plain' in data: - is_spyder_kernel = data['text/plain'] - if 'SpyderKernel' in is_spyder_kernel: - self.is_spyder_kernel = True - self.sig_is_spykernel.emit(self) - - # Remove method after being processed - self._kernel_methods.pop(expression) - def set_backend_for_mayavi(self, command): """ Mayavi plots require the Qt backend, so we try to detect if one is @@ -945,17 +890,7 @@ def _handle_execute_reply(self, msg): self.ipyclient.t0 = time.monotonic() self._kernel_is_starting = False - # Handle silent execution of kernel methods - msg_id = msg['parent_header']['msg_id'] - info = self._request_info['execute'].get(msg_id) - # unset reading flag, because if execute finished, raw_input can't - # still be pending. - self._reading = False - if info and info.kind == 'silent_exec_method': - self.handle_exec_method(msg) - self._request_info['execute'].pop(msg_id) - else: - super()._handle_execute_reply(msg) + super()._handle_execute_reply(msg) def _handle_status(self, msg): """ From a000c1f906930df5ccb3fab8613cc73944378714 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 13:24:38 +0200 Subject: [PATCH 16/83] new super --- spyder/plugins/ipythonconsole/widgets/shell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index d5e4d29fa79..2fc7754d2d2 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -915,7 +915,7 @@ def _handle_status(self, msg): self.sig_kernel_has_started.emit() self.ipyclient.t0 = time.monotonic() else: - super(ShellWidget, self)._handle_status(msg) + super()._handle_status(msg) def _handle_error(self, msg): """ From b8a1e6aadad67cd3e00cd598e5549dbb00a9531b Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 17:36:16 +0200 Subject: [PATCH 17/83] fix framesexplorer --- spyder/plugins/framesexplorer/plugin.py | 26 ++++++++++++++++++- .../framesexplorer/widgets/main_widget.py | 7 +++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/spyder/plugins/framesexplorer/plugin.py b/spyder/plugins/framesexplorer/plugin.py index ef88ffb0f74..02dd743a351 100644 --- a/spyder/plugins/framesexplorer/plugin.py +++ b/spyder/plugins/framesexplorer/plugin.py @@ -22,7 +22,7 @@ class FramesExplorer(SpyderDockablePlugin, ShellConnectMixin): NAME = 'frames_explorer' REQUIRES = [Plugins.IPythonConsole, Plugins.Preferences] - OPTIONAL = [Plugins.Editor] + OPTIONAL = [Plugins.Editor, Plugins.VariableExplorer] TABIFY = [Plugins.VariableExplorer, Plugins.Help] WIDGET_CLASS = FramesExplorerWidget CONF_SECTION = NAME @@ -64,3 +64,27 @@ def on_editor_available(self): def on_editor_teardown(self): editor = self.get_plugin(Plugins.Editor) self.get_widget().edit_goto.disconnect(editor.load) + + @on_plugin_available(plugin=Plugins.VariableExplorer) + def on_variable_explorer_available(self): + self.get_widget().sig_show_namespace.connect( + self.show_namespace_in_variable_explorer) + + @on_plugin_teardown(plugin=Plugins.VariableExplorer) + def on_variable_explorer_teardown(self): + self.get_widget().sig_show_namespace.disconnect( + self.show_namespace_in_variable_explorer) + + def show_namespace_in_variable_explorer(self, namespace, shellwidget): + """ + Find the right variable explorer widget and show the namespace. + + This should only be called when there is a Variable explorer + """ + variable_explorer = self.get_plugin(Plugins.VariableExplorer) + if variable_explorer is None: + return + nsb = variable_explorer.get_widget_for_shellwidget(shellwidget) + nsb.process_remote_view(namespace) + + diff --git a/spyder/plugins/framesexplorer/widgets/main_widget.py b/spyder/plugins/framesexplorer/widgets/main_widget.py index 39ed4a6798f..b2bc6af4ab7 100644 --- a/spyder/plugins/framesexplorer/widgets/main_widget.py +++ b/spyder/plugins/framesexplorer/widgets/main_widget.py @@ -70,6 +70,7 @@ class FramesExplorerWidget(ShellConnectMainWidget): # Signals edit_goto = Signal((str, int, str), (str, int, str, bool)) + sig_show_namespace = Signal(dict, object) def __init__(self, name=None, plugin=None, parent=None): super().__init__(name, plugin, parent) @@ -219,7 +220,9 @@ def create_new_widget(self, shellwidget): widget.sig_hide_finder_requested.connect(self.hide_finder) widget.sig_update_actions_requested.connect(self.update_actions) - widget.sig_show_namespace.connect(shellwidget.set_namespace_view) + widget.sig_show_namespace.connect( + lambda namespace: self.sig_show_namespace.emit( + namespace, shellwidget)) shellwidget.executed.connect(widget.clear_if_needed) shellwidget.spyder_kernel_comm.register_call_handler( @@ -248,7 +251,7 @@ def close_widget(self, widget): shellwidget = widget.shellwidget - widget.sig_show_namespace.disconnect(shellwidget.set_namespace_view) + widget.sig_show_namespace.disconnect() shellwidget.executed.disconnect(widget.clear_if_needed) shellwidget.spyder_kernel_comm.register_call_handler( From 271808b6258d9c711791bfd3f92be0a860815c21 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 28 Jun 2022 18:05:54 +0200 Subject: [PATCH 18/83] pep8 --- spyder/plugins/framesexplorer/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/spyder/plugins/framesexplorer/plugin.py b/spyder/plugins/framesexplorer/plugin.py index 02dd743a351..b75346dd213 100644 --- a/spyder/plugins/framesexplorer/plugin.py +++ b/spyder/plugins/framesexplorer/plugin.py @@ -86,5 +86,3 @@ def show_namespace_in_variable_explorer(self, namespace, shellwidget): return nsb = variable_explorer.get_widget_for_shellwidget(shellwidget) nsb.process_remote_view(namespace) - - From 919e80df5c0b2d71cca70836249654aaa812c740 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 29 Jun 2022 09:00:38 +0200 Subject: [PATCH 19/83] remove kernel_client check --- spyder/plugins/variableexplorer/widgets/namespacebrowser.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index 44afa7c91ab..a4afccf8277 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -162,9 +162,6 @@ def refresh_table(self): def refresh_namespacebrowser(self, interrupt=True): """Refresh namespace browser""" - if self.shellwidget.kernel_client is None: - return - self.shellwidget.call_kernel( interrupt=interrupt, callback=self.process_remote_view @@ -177,9 +174,6 @@ def refresh_namespacebrowser(self, interrupt=True): def set_namespace_view_settings(self): """Set the namespace view settings""" - if self.shellwidget.kernel_client is None: - return - settings = self.get_view_settings() self.shellwidget.call_kernel( interrupt=True From fff0b8aa0b646271d704590f50add32f4201b3cf Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 29 Jun 2022 12:20:34 +0200 Subject: [PATCH 20/83] move function --- spyder/api/shellconnect/main_widget.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spyder/api/shellconnect/main_widget.py b/spyder/api/shellconnect/main_widget.py index dbe845a8965..8e96d47f622 100644 --- a/spyder/api/shellconnect/main_widget.py +++ b/spyder/api/shellconnect/main_widget.py @@ -76,6 +76,13 @@ def current_widget(self): def get_focus_widget(self): return self.current_widget() + def get_widget_for_shellwidget(self, shellwidget): + """return widget corresponding to shellwidget.""" + shellwidget_id = id(shellwidget) + if shellwidget_id in self._shellwidgets: + return self._shellwidgets[shellwidget_id] + return None + # ---- Public API # ------------------------------------------------------------------------ def add_shellwidget(self, shellwidget): @@ -119,13 +126,6 @@ def set_shellwidget(self, shellwidget): self.switch_widget(widget, old_widget) self.update_actions() - def get_widget_for_shellwidget(self, shellwidget): - """return widget corresponding to shellwidget.""" - shellwidget_id = id(shellwidget) - if shellwidget_id in self._shellwidgets: - return self._shellwidgets[shellwidget_id] - return None - def create_new_widget(self, shellwidget): """Create a widget to communicate with shellwidget.""" raise NotImplementedError From 859de91ffeebbef1384c0de8df081aaa1965c823 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 29 Jun 2022 18:35:04 +0200 Subject: [PATCH 21/83] add new signals --- spyder/plugins/ipythonconsole/widgets/client.py | 2 +- spyder/plugins/ipythonconsole/widgets/shell.py | 17 ++++++++--------- .../variableexplorer/widgets/main_widget.py | 8 ++++++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 54041809c7c..62978f5fded 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -487,7 +487,7 @@ def configure_shellwidget(self, give_focus=True): # To show kernel restarted/died messages self.shellwidget.sig_kernel_restarted_message.connect( self.kernel_restarted_message) - self.shellwidget.sig_kernel_restarted.connect( + self.shellwidget.sig_kernel_died_restarted.connect( self._finalise_restart) # To correctly change Matplotlib backend interactively diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 2fc7754d2d2..2bb96e36e5e 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -74,7 +74,8 @@ class ShellWidget(HelpWidget, DebuggingWidget, FigureBrowserWidget): new_client = Signal() sig_is_spykernel = Signal(object) sig_kernel_restarted_message = Signal(str) - sig_kernel_restarted = Signal() + # Kernel died and restarted (not user requested) + sig_kernel_died_restarted = Signal() sig_prompt_ready = Signal() sig_remote_execute = Signal() @@ -90,7 +91,9 @@ class ShellWidget(HelpWidget, DebuggingWidget, FigureBrowserWidget): # To save values and messages returned by the kernel _kernel_is_starting = True - sig_kernel_has_started = Signal() + # Kernel started or restarted + sig_kernel_started = Signal() + sig_kernel_reset = Signal() @classmethod def prune_shutdown_thread_list(cls): @@ -609,10 +612,7 @@ def _perform_reset(self, message): if kernel_env.get('SPY_RUN_CYTHON') == 'True': self.silent_execute("%reload_ext Cython") - # This doesn't need to interrupt the kernel because - # "%reset -f" is being executed before it. - # Fixes spyder-ide/spyder#12689 - self.sig_kernel_has_started.emit() + self.sig_kernel_reset.emit() if self.is_spyder_kernel: self.call_kernel().close_all_mpl_figures() @@ -886,7 +886,7 @@ def _handle_execute_reply(self, msg): # Notify that kernel has started exec_count = msg['content'].get('execution_count', '') if exec_count == 0 and self._kernel_is_starting: - self.sig_kernel_has_started.emit() + self.sig_kernel_started.emit() self.ipyclient.t0 = time.monotonic() self._kernel_is_starting = False @@ -912,7 +912,6 @@ def _handle_status(self, msg): self._kernel_is_starting = True elif state == 'idle' and msg_type == 'shutdown_request': # This handles restarts asked by the user - self.sig_kernel_has_started.emit() self.ipyclient.t0 = time.monotonic() else: super()._handle_status(msg) @@ -948,7 +947,7 @@ def _kernel_restarted_message(self, died=True): def _handle_kernel_restarted(self, *args, **kwargs): super(ShellWidget, self)._handle_kernel_restarted(*args, **kwargs) - self.sig_kernel_restarted.emit() + self.sig_kernel_died_restarted.emit() @observe('syntax_style') def _syntax_style_changed(self, changed=None): diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py index 664844adf88..c70326c59dd 100644 --- a/spyder/plugins/variableexplorer/widgets/main_widget.py +++ b/spyder/plugins/variableexplorer/widgets/main_widget.py @@ -430,7 +430,9 @@ def create_new_widget(self, shellwidget): # To update the Variable Explorer after execution shellwidget.executed.connect( nsb.refresh_namespacebrowser) - shellwidget.sig_kernel_has_started.connect( + shellwidget.sig_kernel_started.connect( + nsb.on_kernel_started) + shellwidget.sig_kernel_reset.connect( nsb.on_kernel_started) return nsb @@ -447,7 +449,9 @@ def close_widget(self, nsb): self.stop_spinner) nsb.shellwidget.executed.disconnect( nsb.refresh_namespacebrowser) - nsb.shellwidget.sig_kernel_has_started.disconnect( + nsb.shellwidget.sig_kernel_started.disconnect( + nsb.on_kernel_started) + nsb.shellwidget.sig_kernel_reset.disconnect( nsb.on_kernel_started) nsb.close() From f545c1ed45283ac3fa335233182e754526a2e142 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 29 Jun 2022 21:23:45 +0200 Subject: [PATCH 22/83] put emit back --- spyder/plugins/ipythonconsole/widgets/shell.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index d607a37125a..8ff8fd92772 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -916,6 +916,7 @@ def _handle_status(self, msg): elif state == 'idle' and msg_type == 'shutdown_request': # This handles restarts asked by the user self.ipyclient.t0 = time.monotonic() + self.sig_kernel_started.emit() else: super()._handle_status(msg) From ff24fa784d0a6c89092f6ae805d2966db33ed715 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 5 Jul 2022 23:06:48 +0200 Subject: [PATCH 23/83] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- spyder/plugins/editor/utils/decoration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/editor/utils/decoration.py b/spyder/plugins/editor/utils/decoration.py index c99d4550b3e..d25eeee59ef 100644 --- a/spyder/plugins/editor/utils/decoration.py +++ b/spyder/plugins/editor/utils/decoration.py @@ -178,4 +178,5 @@ def _sorted_decorations(self): return sorted( [v for key in self._decorations for v in self._decorations[key]], - key=order_function) + key=order_function + ) From 4669adc92d123ccae31f7c868e036a083eb0fc8e Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 6 Jul 2022 06:39:33 +0200 Subject: [PATCH 24/83] remove extra_selection_dict --- spyder/plugins/editor/utils/decoration.py | 4 ++++ spyder/plugins/editor/widgets/base.py | 5 +---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/editor/utils/decoration.py b/spyder/plugins/editor/utils/decoration.py index d25eeee59ef..4417a89f139 100644 --- a/spyder/plugins/editor/utils/decoration.py +++ b/spyder/plugins/editor/utils/decoration.py @@ -109,6 +109,10 @@ def remove_key(self, key): except KeyError: pass + def get(self, key, default=None): + """Get a key from decorations.""" + return self._decorations.get(key, default) + def clear(self): """Removes all text decoration from the editor.""" self._decorations = {"misc": []} diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py index 8e0de597858..9c654a90da3 100644 --- a/spyder/plugins/editor/widgets/base.py +++ b/spyder/plugins/editor/widgets/base.py @@ -52,7 +52,6 @@ def __init__(self, parent=None): self.has_cell_separators = False self.setAttribute(Qt.WA_DeleteOnClose) - self.extra_selections_dict = {} self._restore_selection_pos = None # Trailing newlines/spaces trimming @@ -168,7 +167,7 @@ def get_extra_selections(self, key): Returns: list of sourcecode.api.TextDecoration. """ - return self.extra_selections_dict.get(key, []) + return self.decorations.get(key, []) def set_extra_selections(self, key, extra_selections): """Set extra selections for a key. @@ -191,7 +190,6 @@ def set_extra_selections(self, key, extra_selections): selection.draw_order = draw_order selection.kind = key - self.extra_selections_dict[key] = extra_selections self.decorations.add_key(key, extra_selections) self.update() @@ -202,7 +200,6 @@ def clear_extra_selections(self, key): key (str) name of the extra selections group. """ self.decorations.remove_key(key) - self.extra_selections_dict[key] = [] self.update() def get_visible_block_numbers(self): From a2b07c7d6c427100dacff0762e9524935c16cc87 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Wed, 6 Jul 2022 10:17:16 -0500 Subject: [PATCH 25/83] git subrepo pull external-deps/python-lsp-server subrepo: subdir: "external-deps/python-lsp-server" merged: "9a2cacf3b" upstream: origin: "https://github.com/python-lsp/python-lsp-server.git" branch: "develop" commit: "9a2cacf3b" git-subrepo: version: "0.4.3" origin: "https://github.com/ingydotnet/git-subrepo" commit: "2f68596" --- external-deps/python-lsp-server/.gitrepo | 4 +- .../python-lsp-server/CONFIGURATION.md | 41 ++- external-deps/python-lsp-server/README.md | 26 +- .../python-lsp-server/pylsp/__main__.py | 9 +- .../python-lsp-server/pylsp/_utils.py | 1 + .../python-lsp-server/pylsp/config/config.py | 1 + .../pylsp/config/schema.json | 86 +++-- .../python-lsp-server/pylsp/hookspecs.py | 4 +- .../pylsp/plugins/autopep8_format.py | 4 +- .../pylsp/plugins/flake8_lint.py | 61 +++- .../pylsp/plugins/pycodestyle_lint.py | 7 +- .../pylsp/plugins/pylint_lint.py | 68 ++-- .../pylsp/plugins/yapf_format.py | 180 +++++++-- .../python-lsp-server/pylsp/python_lsp.py | 107 +++++- .../python-lsp-server/pylsp/text_edit.py | 94 +++++ .../python-lsp-server/pyproject.toml | 96 ++++- .../scripts/jsonschema2md.py | 22 +- external-deps/python-lsp-server/setup.cfg | 88 +---- external-deps/python-lsp-server/setup.py | 1 - .../test/plugins/test_flake8_lint.py | 27 +- .../test/plugins/test_pylint_lint.py | 2 + .../test/plugins/test_yapf_format.py | 55 ++- .../python-lsp-server/test/test_text_edit.py | 345 ++++++++++++++++++ 23 files changed, 1075 insertions(+), 254 deletions(-) create mode 100644 external-deps/python-lsp-server/pylsp/text_edit.py create mode 100644 external-deps/python-lsp-server/test/test_text_edit.py diff --git a/external-deps/python-lsp-server/.gitrepo b/external-deps/python-lsp-server/.gitrepo index 568cae68ef6..cce1e830635 100644 --- a/external-deps/python-lsp-server/.gitrepo +++ b/external-deps/python-lsp-server/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = https://github.com/python-lsp/python-lsp-server.git branch = develop - commit = c99ef24d790b8fb81b5419f69270b7f014a50d30 - parent = 985458f36341268b7c301929d6aed127599093db + commit = 9a2cacf3bb2660de8e086f47f1059dc47f88e80b + parent = a7fd9495c2aff823e9c61313148c17f35ace2660 method = merge cmdver = 0.4.3 diff --git a/external-deps/python-lsp-server/CONFIGURATION.md b/external-deps/python-lsp-server/CONFIGURATION.md index 7ba70cf9d13..2f0bc5359a1 100644 --- a/external-deps/python-lsp-server/CONFIGURATION.md +++ b/external-deps/python-lsp-server/CONFIGURATION.md @@ -3,19 +3,20 @@ This server can be configured using `workspace/didChangeConfiguration` method. E | **Configuration Key** | **Type** | **Description** | **Default** |----|----|----|----| -| `pylsp.configurationSources` | `array` of unique `string` items | List of configuration sources to use. | `["pycodestyle"]` | +| `pylsp.configurationSources` | `array` of unique `string` (one of: `pycodestyle`, `pyflakes`) items | List of configuration sources to use. | `["pycodestyle"]` | +| `pylsp.plugins.autopep8.enabled` | `boolean` | Enable or disable the plugin (disabling required to use `yapf`). | `true` | | `pylsp.plugins.flake8.config` | `string` | Path to the config file that will be the authoritative config source. | `null` | | `pylsp.plugins.flake8.enabled` | `boolean` | Enable or disable the plugin. | `false` | -| `pylsp.plugins.flake8.exclude` | `array` | List of files or directories to exclude. | `null` | +| `pylsp.plugins.flake8.exclude` | `array` of `string` items | List of files or directories to exclude. | `[]` | | `pylsp.plugins.flake8.executable` | `string` | Path to the flake8 executable. | `"flake8"` | | `pylsp.plugins.flake8.filename` | `string` | Only check for filenames matching the patterns in this list. | `null` | | `pylsp.plugins.flake8.hangClosing` | `boolean` | Hang closing bracket instead of matching indentation of opening bracket's line. | `null` | -| `pylsp.plugins.flake8.ignore` | `array` | List of errors and warnings to ignore (or skip). | `null` | +| `pylsp.plugins.flake8.ignore` | `array` of `string` items | List of errors and warnings to ignore (or skip). | `[]` | | `pylsp.plugins.flake8.maxLineLength` | `integer` | Maximum allowed line length for the entirety of this run. | `null` | | `pylsp.plugins.flake8.indentSize` | `integer` | Set indentation spaces. | `null` | -| `pylsp.plugins.flake8.perFileIgnores` | `array` | A pairing of filenames and violation codes that defines which violations to ignore in a particular file, for example: `["file_path.py:W305,W304"]`). | `null` | -| `pylsp.plugins.flake8.select` | `array` | List of errors and warnings to enable. | `null` | -| `pylsp.plugins.jedi.extra_paths` | `array` | Define extra paths for jedi.Script. | `[]` | +| `pylsp.plugins.flake8.perFileIgnores` | `array` of `string` items | A pairing of filenames and violation codes that defines which violations to ignore in a particular file, for example: `["file_path.py:W305,W304"]`). | `[]` | +| `pylsp.plugins.flake8.select` | `array` of unique `string` items | List of errors and warnings to enable. | `null` | +| `pylsp.plugins.jedi.extra_paths` | `array` of `string` items | Define extra paths for jedi.Script. | `[]` | | `pylsp.plugins.jedi.env_vars` | `object` | Define environment variables for jedi.Script and Jedi.names. | `null` | | `pylsp.plugins.jedi.environment` | `string` | Define environment for jedi.Script and Jedi.names. | `null` | | `pylsp.plugins.jedi_completion.enabled` | `boolean` | Enable or disable the plugin. | `true` | @@ -24,7 +25,7 @@ This server can be configured using `workspace/didChangeConfiguration` method. E | `pylsp.plugins.jedi_completion.fuzzy` | `boolean` | Enable fuzzy when requesting autocomplete. | `false` | | `pylsp.plugins.jedi_completion.eager` | `boolean` | Resolve documentation and detail eagerly. | `false` | | `pylsp.plugins.jedi_completion.resolve_at_most` | `number` | How many labels and snippets (at most) should be resolved? | `25` | -| `pylsp.plugins.jedi_completion.cache_for` | `array` of `string` items | Modules for which labels and snippets should be cached. | `["pandas", "numpy", "tensorflow", "matplotlib"]` | +| `pylsp.plugins.jedi_completion.cache_for` | `array` of `string` items | Modules for which labels and snippets should be cached. | `["pandas", "numpy", "tensorflow", "matplotlib"]` | | `pylsp.plugins.jedi_definition.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.plugins.jedi_definition.follow_imports` | `boolean` | The goto call will follow imports. | `true` | | `pylsp.plugins.jedi_definition.follow_builtin_imports` | `boolean` | If follow_imports is True will decide if it follow builtin imports. | `true` | @@ -37,31 +38,31 @@ This server can be configured using `workspace/didChangeConfiguration` method. E | `pylsp.plugins.mccabe.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.plugins.mccabe.threshold` | `number` | The minimum threshold that triggers warnings about cyclomatic complexity. | `15` | | `pylsp.plugins.preload.enabled` | `boolean` | Enable or disable the plugin. | `true` | -| `pylsp.plugins.preload.modules` | `array` of unique `string` items | List of modules to import on startup | `null` | +| `pylsp.plugins.preload.modules` | `array` of unique `string` items | List of modules to import on startup | `[]` | | `pylsp.plugins.pycodestyle.enabled` | `boolean` | Enable or disable the plugin. | `true` | -| `pylsp.plugins.pycodestyle.exclude` | `array` of unique `string` items | Exclude files or directories which match these patterns. | `null` | -| `pylsp.plugins.pycodestyle.filename` | `array` of unique `string` items | When parsing directories, only check filenames matching these patterns. | `null` | -| `pylsp.plugins.pycodestyle.select` | `array` of unique `string` items | Select errors and warnings | `null` | -| `pylsp.plugins.pycodestyle.ignore` | `array` of unique `string` items | Ignore errors and warnings | `null` | +| `pylsp.plugins.pycodestyle.exclude` | `array` of unique `string` items | Exclude files or directories which match these patterns. | `[]` | +| `pylsp.plugins.pycodestyle.filename` | `array` of unique `string` items | When parsing directories, only check filenames matching these patterns. | `[]` | +| `pylsp.plugins.pycodestyle.select` | `array` of unique `string` items | Select errors and warnings | `[]` | +| `pylsp.plugins.pycodestyle.ignore` | `array` of unique `string` items | Ignore errors and warnings | `[]` | | `pylsp.plugins.pycodestyle.hangClosing` | `boolean` | Hang closing bracket instead of matching indentation of opening bracket's line. | `null` | | `pylsp.plugins.pycodestyle.maxLineLength` | `number` | Set maximum allowed line length. | `null` | | `pylsp.plugins.pycodestyle.indentSize` | `integer` | Set indentation spaces. | `null` | | `pylsp.plugins.pydocstyle.enabled` | `boolean` | Enable or disable the plugin. | `false` | -| `pylsp.plugins.pydocstyle.convention` | `string` | Choose the basic list of checked errors by specifying an existing convention. | `null` | -| `pylsp.plugins.pydocstyle.addIgnore` | `array` of unique `string` items | Ignore errors and warnings in addition to the specified convention. | `null` | -| `pylsp.plugins.pydocstyle.addSelect` | `array` of unique `string` items | Select errors and warnings in addition to the specified convention. | `null` | -| `pylsp.plugins.pydocstyle.ignore` | `array` of unique `string` items | Ignore errors and warnings | `null` | -| `pylsp.plugins.pydocstyle.select` | `array` of unique `string` items | Select errors and warnings | `null` | +| `pylsp.plugins.pydocstyle.convention` | `string` (one of: `pep257`, `numpy`, `None`) | Choose the basic list of checked errors by specifying an existing convention. | `null` | +| `pylsp.plugins.pydocstyle.addIgnore` | `array` of unique `string` items | Ignore errors and warnings in addition to the specified convention. | `[]` | +| `pylsp.plugins.pydocstyle.addSelect` | `array` of unique `string` items | Select errors and warnings in addition to the specified convention. | `[]` | +| `pylsp.plugins.pydocstyle.ignore` | `array` of unique `string` items | Ignore errors and warnings | `[]` | +| `pylsp.plugins.pydocstyle.select` | `array` of unique `string` items | Select errors and warnings | `[]` | | `pylsp.plugins.pydocstyle.match` | `string` | Check only files that exactly match the given regular expression; default is to match files that don't start with 'test_' but end with '.py'. | `"(?!test_).*\\.py"` | | `pylsp.plugins.pydocstyle.matchDir` | `string` | Search only dirs that exactly match the given regular expression; default is to match dirs which do not begin with a dot. | `"[^\\.].*"` | | `pylsp.plugins.pyflakes.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.plugins.pylint.enabled` | `boolean` | Enable or disable the plugin. | `false` | -| `pylsp.plugins.pylint.args` | `array` of non-unique `string` items | Arguments to pass to pylint. | `null` | +| `pylsp.plugins.pylint.args` | `array` of non-unique `string` items | Arguments to pass to pylint. | `[]` | | `pylsp.plugins.pylint.executable` | `string` | Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3. | `null` | -| `pylsp.plugins.rope_completion.enabled` | `boolean` | Enable or disable the plugin. | `true` | +| `pylsp.plugins.rope_completion.enabled` | `boolean` | Enable or disable the plugin. | `false` | | `pylsp.plugins.rope_completion.eager` | `boolean` | Resolve documentation and detail eagerly. | `false` | | `pylsp.plugins.yapf.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.rope.extensionModules` | `string` | Builtin and c-extension modules that are allowed to be imported and inspected by rope. | `null` | -| `pylsp.rope.ropeFolder` | `array` of unique `string` items | The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all. | `null` | +| `pylsp.rope.ropeFolder` | `array` of unique `string` items | The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all. | `null` | This documentation was generated from `pylsp/config/schema.json`. Please do not edit this file directly. diff --git a/external-deps/python-lsp-server/README.md b/external-deps/python-lsp-server/README.md index 16948e56209..86a542380bf 100644 --- a/external-deps/python-lsp-server/README.md +++ b/external-deps/python-lsp-server/README.md @@ -22,6 +22,8 @@ If the respective dependencies are found, the following optional providers will - [pydocstyle](https://github.com/PyCQA/pydocstyle) linter for docstring style checking (disabled by default) - [autopep8](https://github.com/hhatto/autopep8) for code formatting - [YAPF](https://github.com/google/yapf) for code formatting (preferred over autopep8) +- [flake8](https://github.com/pycqa/flake8) for error checking (disabled by default) +- [pylint](https://github.com/PyCQA/pylint) for code linting (disabled by default) Optional providers can be installed using the `extras` syntax. To install [YAPF](https://github.com/google/yapf) formatting for example: @@ -45,7 +47,6 @@ pip install -U setuptools Installing these plugins will add extra functionality to the language server: -- [pyls-flake8](https://github.com/emanspeaks/pyls-flake8/): Error checking using [flake8](https://flake8.pycqa.org/en/latest/). - [pylsp-mypy](https://github.com/Richardk2n/pylsp-mypy): [MyPy](http://mypy-lang.org/) type checking for Python >=3.7. - [pyls-isort](https://github.com/paradoxxxzero/pyls-isort): code formatting using [isort](https://github.com/PyCQA/isort) (automatic import sorting). - [python-lsp-black](https://github.com/python-lsp/python-lsp-black): code formatting using [Black](https://github.com/psf/black). @@ -65,9 +66,13 @@ Like all language servers, configuration can be passed from the client that talk `python-lsp-server` depends on other tools, like flake8 and pycodestyle. These tools can be configured via settings passed from the client (as above), or alternatively from other configuration sources. The following sources are available: - `pycodestyle`: discovered in `~/.config/pycodestyle`, `setup.cfg`, `tox.ini` and `pycodestyle.cfg`. -- `flake8`: discovered in `~/.config/flake8`, `setup.cfg`, `tox.ini` and `flake8.cfg` +- `flake8`: discovered in `~/.config/flake8`, `.flake8`, `setup.cfg` and `tox.ini` -The default configuration source is `pycodestyle`. Change the `pylsp.configurationSources` setting (in the value passed in from your client) to `['flake8']` in order to use the flake8 configuration instead. +The default configuration sources are `pycodestyle` and `pyflakes`. If you would like to use `flake8`, you will need to: + +1. Disable `pycodestyle`, `mccabe`, and `pyflakes`, by setting their corresponding `enabled` configurations, e.g. `pylsp.plugins.pycodestyle.enabled`, to `false`. This will prevent duplicate linting messages as flake8 includes these tools. +1. Set `pylsp.plugins.flake8.enabled` to `true`. +1. Change the `pylsp.configurationSources` setting (in the value passed in from your client) to `['flake8']` in order to use the flake8 configuration instead. The configuration options available in these config files (`setup.cfg` etc) are documented in the relevant tools: @@ -89,6 +94,21 @@ As an example, to change the list of errors that pycodestyle will ignore, assumi 3. Same as 1, but add to `setup.cfg` file in the root of the project. +Python LSP Server can communicate over WebSockets when configured as follows: + +``` +pylsp --ws --port [port] +``` + +The following libraries are required for Web Sockets support: +- [websockets](https://websockets.readthedocs.io/en/stable/) for Python LSP Server Web sockets using websockets library. refer [Websockets installation](https://websockets.readthedocs.io/en/stable/intro/index.html#installation) for more details + +You can install this dependency with command below: + +``` +pip install 'python-lsp-server[websockets]' +``` + ## LSP Server Features * Auto Completion diff --git a/external-deps/python-lsp-server/pylsp/__main__.py b/external-deps/python-lsp-server/pylsp/__main__.py index 4698d5c9b03..50950a306be 100644 --- a/external-deps/python-lsp-server/pylsp/__main__.py +++ b/external-deps/python-lsp-server/pylsp/__main__.py @@ -13,7 +13,7 @@ import json from .python_lsp import (PythonLSPServer, start_io_lang_server, - start_tcp_lang_server) + start_tcp_lang_server, start_ws_lang_server) from ._version import __version__ LOG_FORMAT = "%(asctime)s {0} - %(levelname)s - %(name)s - %(message)s".format( @@ -27,6 +27,10 @@ def add_arguments(parser): "--tcp", action="store_true", help="Use TCP server instead of stdio" ) + parser.add_argument( + "--ws", action="store_true", + help="Use Web Sockets server instead of stdio" + ) parser.add_argument( "--host", default="127.0.0.1", help="Bind to this address" @@ -72,6 +76,9 @@ def main(): if args.tcp: start_tcp_lang_server(args.host, args.port, args.check_parent_process, PythonLSPServer) + elif args.ws: + start_ws_lang_server(args.port, args.check_parent_process, + PythonLSPServer) else: stdin, stdout = _binary_stdio() start_io_lang_server(stdin, stdout, args.check_parent_process, diff --git a/external-deps/python-lsp-server/pylsp/_utils.py b/external-deps/python-lsp-server/pylsp/_utils.py index 0732067a671..8c4b54961d3 100644 --- a/external-deps/python-lsp-server/pylsp/_utils.py +++ b/external-deps/python-lsp-server/pylsp/_utils.py @@ -14,6 +14,7 @@ JEDI_VERSION = jedi.__version__ # Eol chars accepted by the LSP protocol +# the ordering affects performance EOL_CHARS = ['\r\n', '\r', '\n'] EOL_REGEX = re.compile(f'({"|".join(EOL_CHARS)})') diff --git a/external-deps/python-lsp-server/pylsp/config/config.py b/external-deps/python-lsp-server/pylsp/config/config.py index 27a76bde327..5637ca604a2 100644 --- a/external-deps/python-lsp-server/pylsp/config/config.py +++ b/external-deps/python-lsp-server/pylsp/config/config.py @@ -81,6 +81,7 @@ def __init__(self, root_uri, init_opts, process_id, capabilities): if plugin is not None: log.info("Loaded pylsp plugin %s from %s", name, plugin) + # pylint: disable=no-member for plugin_conf in self._pm.hook.pylsp_settings(config=self): self._plugin_settings = _utils.merge_dicts(self._plugin_settings, plugin_conf) diff --git a/external-deps/python-lsp-server/pylsp/config/schema.json b/external-deps/python-lsp-server/pylsp/config/schema.json index c29d78bdf6d..44437807967 100644 --- a/external-deps/python-lsp-server/pylsp/config/schema.json +++ b/external-deps/python-lsp-server/pylsp/config/schema.json @@ -14,8 +14,13 @@ }, "uniqueItems": true }, + "pylsp.plugins.autopep8.enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable the plugin (disabling required to use `yapf`)." + }, "pylsp.plugins.flake8.config": { - "type": "string", + "type": ["string", "null"], "default": null, "description": "Path to the config file that will be the authoritative config source." }, @@ -26,7 +31,10 @@ }, "pylsp.plugins.flake8.exclude": { "type": "array", - "default": null, + "default": [], + "items": { + "type": "string" + }, "description": "List of files or directories to exclude." }, "pylsp.plugins.flake8.executable": { @@ -35,52 +43,65 @@ "description": "Path to the flake8 executable." }, "pylsp.plugins.flake8.filename": { - "type": "string", + "type": ["string", "null"], "default": null, "description": "Only check for filenames matching the patterns in this list." }, "pylsp.plugins.flake8.hangClosing": { - "type": "boolean", + "type": ["boolean", "null"], "default": null, "description": "Hang closing bracket instead of matching indentation of opening bracket's line." }, "pylsp.plugins.flake8.ignore": { "type": "array", - "default": null, + "default": [], + "items": { + "type": "string" + }, "description": "List of errors and warnings to ignore (or skip)." }, "pylsp.plugins.flake8.maxLineLength": { - "type": "integer", + "type": ["integer", "null"], "default": null, "description": "Maximum allowed line length for the entirety of this run." }, "pylsp.plugins.flake8.indentSize": { - "type": "integer", + "type": ["integer", "null"], "default": null, "description": "Set indentation spaces." }, "pylsp.plugins.flake8.perFileIgnores": { - "type": "array", - "default": null, + "type": ["array"], + "default": [], + "items": { + "type": "string" + }, "description": "A pairing of filenames and violation codes that defines which violations to ignore in a particular file, for example: `[\"file_path.py:W305,W304\"]`)." }, "pylsp.plugins.flake8.select": { - "type": "array", + "type": ["array", "null"], "default": null, + "items": { + "type": "string" + }, + "uniqueItems": true, "description": "List of errors and warnings to enable." }, "pylsp.plugins.jedi.extra_paths": { "type": "array", "default": [], + "items": { + "type": "string" + }, "description": "Define extra paths for jedi.Script." }, "pylsp.plugins.jedi.env_vars": { - "type": "object", + "type": ["object", "null"], "default": null, "description": "Define environment variables for jedi.Script and Jedi.names." }, "pylsp.plugins.jedi.environment": { - "type": "string", + "type": ["string", "null"], "default": null, "description": "Define environment for jedi.Script and Jedi.names." }, @@ -184,7 +205,7 @@ }, "pylsp.plugins.preload.modules": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -198,7 +219,7 @@ }, "pylsp.plugins.pycodestyle.exclude": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -207,7 +228,7 @@ }, "pylsp.plugins.pycodestyle.filename": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -216,7 +237,7 @@ }, "pylsp.plugins.pycodestyle.select": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -225,7 +246,7 @@ }, "pylsp.plugins.pycodestyle.ignore": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -233,17 +254,17 @@ "description": "Ignore errors and warnings" }, "pylsp.plugins.pycodestyle.hangClosing": { - "type": "boolean", + "type": ["boolean", "null"], "default": null, "description": "Hang closing bracket instead of matching indentation of opening bracket's line." }, "pylsp.plugins.pycodestyle.maxLineLength": { - "type": "number", + "type": ["number", "null"], "default": null, "description": "Set maximum allowed line length." }, "pylsp.plugins.pycodestyle.indentSize": { - "type": "integer", + "type": ["integer", "null"], "default": null, "description": "Set indentation spaces." }, @@ -253,17 +274,14 @@ "description": "Enable or disable the plugin." }, "pylsp.plugins.pydocstyle.convention": { - "type": "string", + "type": ["string", "null"], "default": null, - "enum": [ - "pep257", - "numpy" - ], + "enum": ["pep257", "numpy", null], "description": "Choose the basic list of checked errors by specifying an existing convention." }, "pylsp.plugins.pydocstyle.addIgnore": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -272,7 +290,7 @@ }, "pylsp.plugins.pydocstyle.addSelect": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -281,7 +299,7 @@ }, "pylsp.plugins.pydocstyle.ignore": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -290,7 +308,7 @@ }, "pylsp.plugins.pydocstyle.select": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -319,7 +337,7 @@ }, "pylsp.plugins.pylint.args": { "type": "array", - "default": null, + "default": [], "items": { "type": "string" }, @@ -327,13 +345,13 @@ "description": "Arguments to pass to pylint." }, "pylsp.plugins.pylint.executable": { - "type": "string", + "type": ["string", "null"], "default": null, "description": "Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3." }, "pylsp.plugins.rope_completion.enabled": { "type": "boolean", - "default": true, + "default": false, "description": "Enable or disable the plugin." }, "pylsp.plugins.rope_completion.eager": { @@ -347,12 +365,12 @@ "description": "Enable or disable the plugin." }, "pylsp.rope.extensionModules": { - "type": "string", + "type": ["null", "string"], "default": null, "description": "Builtin and c-extension modules that are allowed to be imported and inspected by rope." }, "pylsp.rope.ropeFolder": { - "type": "array", + "type": ["null", "array"], "default": null, "items": { "type": "string" diff --git a/external-deps/python-lsp-server/pylsp/hookspecs.py b/external-deps/python-lsp-server/pylsp/hookspecs.py index 736cf931826..d1a2458e6d3 100644 --- a/external-deps/python-lsp-server/pylsp/hookspecs.py +++ b/external-deps/python-lsp-server/pylsp/hookspecs.py @@ -80,12 +80,12 @@ def pylsp_folding_range(config, workspace, document): @hookspec(firstresult=True) -def pylsp_format_document(config, workspace, document): +def pylsp_format_document(config, workspace, document, options): pass @hookspec(firstresult=True) -def pylsp_format_range(config, workspace, document, range): +def pylsp_format_range(config, workspace, document, range, options): pass diff --git a/external-deps/python-lsp-server/pylsp/plugins/autopep8_format.py b/external-deps/python-lsp-server/pylsp/plugins/autopep8_format.py index 8915fb724e1..f605f830bc8 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/autopep8_format.py +++ b/external-deps/python-lsp-server/pylsp/plugins/autopep8_format.py @@ -13,13 +13,13 @@ @hookimpl(tryfirst=True) # Prefer autopep8 over YAPF -def pylsp_format_document(config, document): +def pylsp_format_document(config, document, options=None): # pylint: disable=unused-argument log.info("Formatting document %s with autopep8", document) return _format(config, document) @hookimpl(tryfirst=True) # Prefer autopep8 over YAPF -def pylsp_format_range(config, document, range): # pylint: disable=redefined-builtin +def pylsp_format_range(config, document, range, options=None): # pylint: disable=redefined-builtin,unused-argument log.info("Formatting document %s in range %s with autopep8", document, range) # First we 'round' the range up/down to full lines only diff --git a/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py b/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py index 3707222f831..0c5e09f6aa5 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py +++ b/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py @@ -13,6 +13,13 @@ log = logging.getLogger(__name__) FIX_IGNORES_RE = re.compile(r'([^a-zA-Z0-9_,]*;.*(\W+||$))') +UNNECESSITY_CODES = { + 'F401', # `module` imported but unused + 'F504', # % format unused named arguments + 'F522', # .format(...) unused named arguments + 'F523', # .format(...) unused positional arguments + 'F841' # local variable `name` is assigned to but never used +} @hookimpl @@ -31,8 +38,20 @@ def pylsp_lint(workspace, document): per_file_ignores = settings.get("perFileIgnores") if per_file_ignores: + prev_file_pat = None for path in per_file_ignores: - file_pat, errors = path.split(":") + try: + file_pat, errors = path.split(":") + prev_file_pat = file_pat + except ValueError: + # It's legal to just specify another error type for the same + # file pattern: + if prev_file_pat is None: + log.warning( + "skipping a Per-file-ignore with no file pattern") + continue + file_pat = prev_file_pat + errors = path if PurePath(document.path).match(file_pat): ignores.extend(errors.split(",")) @@ -161,24 +180,28 @@ def parse_stdout(document, stdout): character = int(character) - 1 # show also the code in message msg = code + ' ' + msg - diagnostics.append( - { - 'source': 'flake8', - 'code': code, - 'range': { - 'start': { - 'line': line, - 'character': character - }, - 'end': { - 'line': line, - # no way to determine the column - 'character': len(document.lines[line]) - } + severity = lsp.DiagnosticSeverity.Warning + if code == "E999" or code[0] == "F": + severity = lsp.DiagnosticSeverity.Error + diagnostic = { + 'source': 'flake8', + 'code': code, + 'range': { + 'start': { + 'line': line, + 'character': character }, - 'message': msg, - 'severity': lsp.DiagnosticSeverity.Warning, - } - ) + 'end': { + 'line': line, + # no way to determine the column + 'character': len(document.lines[line]) + } + }, + 'message': msg, + 'severity': severity, + } + if code in UNNECESSITY_CODES: + diagnostic['tags'] = [lsp.DiagnosticTag.Unnecessary] + diagnostics.append(diagnostic) return diagnostics diff --git a/external-deps/python-lsp-server/pylsp/plugins/pycodestyle_lint.py b/external-deps/python-lsp-server/pylsp/plugins/pycodestyle_lint.py index 99a6f074cb7..30aeb67a921 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/pycodestyle_lint.py +++ b/external-deps/python-lsp-server/pylsp/plugins/pycodestyle_lint.py @@ -75,14 +75,17 @@ def error(self, line_number, offset, text, check): 'character': 100 if line_number > len(self.lines) else len(self.lines[line_number - 1]) }, } - self.diagnostics.append({ + diagnostic = { 'source': 'pycodestyle', 'range': err_range, 'message': text, 'code': code, # Are style errors really ever errors? 'severity': _get_severity(code) - }) + } + if code.startswith('W6'): + diagnostic['tags'] = [lsp.DiagnosticTag.Deprecated] + self.diagnostics.append(diagnostic) def _get_severity(code): diff --git a/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py b/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py index d974a2f84fa..f33cfcd845b 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py +++ b/external-deps/python-lsp-server/pylsp/plugins/pylint_lint.py @@ -28,6 +28,20 @@ # fix for a very specific upstream issue. # Related: https://github.com/PyCQA/pylint/issues/3518 os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = 'hide' +DEPRECATION_CODES = { + 'W0402', # Uses of a deprecated module %r + 'W1505', # Using deprecated method %s() + 'W1511', # Using deprecated argument %s of method %s() + 'W1512', # Using deprecated class %s of module %s + 'W1513', # Using deprecated decorator %s() +} +UNNECESSITY_CODES = { + 'W0611', # Unused import %s + 'W0612', # Unused variable %r + 'W0613', # Unused argument %r + 'W0614', # Unused import %s from wildcard import + 'W1304', # Unused-format-string-argument +} class PylintLinter: @@ -146,13 +160,22 @@ def lint(cls, document, is_saved, flags=''): elif diag['type'] == 'warning': severity = lsp.DiagnosticSeverity.Warning - diagnostics.append({ + code = diag['message-id'] + + diagnostic = { 'source': 'pylint', 'range': err_range, 'message': '[{}] {}'.format(diag['symbol'], diag['message']), 'severity': severity, - 'code': diag['message-id'] - }) + 'code': code + } + + if code in UNNECESSITY_CODES: + diagnostic['tags'] = [lsp.DiagnosticTag.Unnecessary] + if code in DEPRECATION_CODES: + diagnostic['tags'] = [lsp.DiagnosticTag.Deprecated] + + diagnostics.append(diagnostic) cls.last_diags[document.path] = diagnostics return diagnostics @@ -295,24 +318,27 @@ def _parse_pylint_stdio_result(document, stdout): 'W': lsp.DiagnosticSeverity.Warning, } severity = severity_map[code[0]] - diagnostics.append( - { - 'source': 'pylint', - 'code': code, - 'range': { - 'start': { - 'line': line, - 'character': character - }, - 'end': { - 'line': line, - # no way to determine the column - 'character': len(document.lines[line]) - 1 - } + diagnostic = { + 'source': 'pylint', + 'code': code, + 'range': { + 'start': { + 'line': line, + 'character': character }, - 'message': msg, - 'severity': severity, - } - ) + 'end': { + 'line': line, + # no way to determine the column + 'character': len(document.lines[line]) - 1 + } + }, + 'message': msg, + 'severity': severity, + } + if code in UNNECESSITY_CODES: + diagnostic['tags'] = [lsp.DiagnosticTag.Unnecessary] + if code in DEPRECATION_CODES: + diagnostic['tags'] = [lsp.DiagnosticTag.Deprecated] + diagnostics.append(diagnostic) return diagnostics diff --git a/external-deps/python-lsp-server/pylsp/plugins/yapf_format.py b/external-deps/python-lsp-server/pylsp/plugins/yapf_format.py index 7c47781694e..c1b89051cb2 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/yapf_format.py +++ b/external-deps/python-lsp-server/pylsp/plugins/yapf_format.py @@ -4,9 +4,11 @@ import logging import os -from yapf.yapflib import file_resources +from yapf.yapflib import file_resources, style from yapf.yapflib.yapf_api import FormatCode +import whatthepatch + from pylsp import hookimpl from pylsp._utils import get_eol_chars @@ -14,12 +16,12 @@ @hookimpl -def pylsp_format_document(document): - return _format(document) +def pylsp_format_document(document, options=None): + return _format(document, options=options) @hookimpl -def pylsp_format_range(document, range): # pylint: disable=redefined-builtin +def pylsp_format_range(document, range, options=None): # pylint: disable=redefined-builtin # First we 'round' the range up/down to full lines only range['start']['character'] = 0 range['end']['line'] += 1 @@ -33,41 +35,167 @@ def pylsp_format_range(document, range): # pylint: disable=redefined-builtin # Add 1 for 1-indexing vs LSP's 0-indexing lines = [(range['start']['line'] + 1, range['end']['line'] + 1)] - return _format(document, lines=lines) + return _format(document, lines=lines, options=options) -def _format(document, lines=None): - # Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n' - # and restore them below. - replace_eols = False +def get_style_config(document_path, options=None): + # Get the default styles as a string + # for a preset configuration, i.e. "pep8" + style_config = file_resources.GetDefaultStyleForDir( + os.path.dirname(document_path) + ) + if options is None: + return style_config + + # We have options passed from LSP format request + # let's pass them to the formatter. + # First we want to get a dictionary of the preset style + # to pass instead of a string so that we can modify it + style_config = style.CreateStyleFromConfig(style_config) + + use_tabs = style_config['USE_TABS'] + indent_width = style_config['INDENT_WIDTH'] + + if options.get('tabSize') is not None: + indent_width = max(int(options.get('tabSize')), 1) + + if options.get('insertSpaces') is not None: + # TODO is it guaranteed to be a boolean, or can it be a string? + use_tabs = not options.get('insertSpaces') + + if use_tabs: + # Indent width doesn't make sense when using tabs + # the specifications state: "Size of a tab in spaces" + indent_width = 1 + + style_config['USE_TABS'] = use_tabs + style_config['INDENT_WIDTH'] = indent_width + style_config['CONTINUATION_INDENT_WIDTH'] = indent_width + + for style_option, value in options.items(): + # Apply arbitrary options passed as formatter options + if style_option not in style_config: + # ignore if it's not a known yapf config + continue + + style_config[style_option] = value + + return style_config + + +def diff_to_text_edits(diff, eol_chars): + # To keep things simple our text edits will be line based. + # We will also return the edits uncompacted, meaning a + # line replacement will come in as a line remove followed + # by a line add instead of a line replace. + text_edits = [] + # keep track of line number since additions + # don't include the line number it's being added + # to in diffs. lsp is 0-indexed so we'll start with -1 + prev_line_no = -1 + + for change in diff.changes: + if change.old and change.new: + # old and new are the same line, no change + # diffs are 1-indexed + prev_line_no = change.old - 1 + elif change.new: + # addition + text_edits.append({ + 'range': { + 'start': { + 'line': prev_line_no + 1, + 'character': 0 + }, + 'end': { + 'line': prev_line_no + 1, + 'character': 0 + } + }, + 'newText': change.line + eol_chars + }) + elif change.old: + # remove + lsp_line_no = change.old - 1 + text_edits.append({ + 'range': { + 'start': { + 'line': lsp_line_no, + 'character': 0 + }, + 'end': { + # From LSP spec: + # If you want to specify a range that contains a line + # including the line ending character(s) then use an + # end position denoting the start of the next line. + 'line': lsp_line_no + 1, + 'character': 0 + } + }, + 'newText': '' + }) + prev_line_no = lsp_line_no + + return text_edits + + +def ensure_eof_new_line(document, eol_chars, text_edits): + # diffs don't include EOF newline https://github.com/google/yapf/issues/1008 + # we'll add it ourselves if our document doesn't already have it and the diff + # does not change the last line. + if document.source.endswith(eol_chars): + return + + lines = document.lines + last_line_number = len(lines) - 1 + + if text_edits and text_edits[-1]['range']['start']['line'] >= last_line_number: + return + + text_edits.append({ + 'range': { + 'start': { + 'line': last_line_number, + 'character': 0 + }, + 'end': { + 'line': last_line_number + 1, + 'character': 0 + } + }, + 'newText': lines[-1] + eol_chars + }) + + +def _format(document, lines=None, options=None): source = document.source + # Yapf doesn't work with CRLF/CR line endings, so we replace them by '\n' + # and restore them below when adding new lines eol_chars = get_eol_chars(source) if eol_chars in ['\r', '\r\n']: - replace_eols = True source = source.replace(eol_chars, '\n') + else: + eol_chars = '\n' + + style_config = get_style_config(document_path=document.path, options=options) - new_source, changed = FormatCode( + diff_txt, changed = FormatCode( source, lines=lines, filename=document.filename, - style_config=file_resources.GetDefaultStyleForDir( - os.path.dirname(document.path) - ) + print_diff=True, + style_config=style_config ) if not changed: return [] - if replace_eols: - new_source = new_source.replace('\n', eol_chars) + patch_generator = whatthepatch.parse_patch(diff_txt) + diff = next(patch_generator) + patch_generator.close() - # I'm too lazy at the moment to parse diffs into TextEdit items - # So let's just return the entire file... - return [{ - 'range': { - 'start': {'line': 0, 'character': 0}, - # End char 0 of the line after our document - 'end': {'line': len(document.lines), 'character': 0} - }, - 'newText': new_source - }] + text_edits = diff_to_text_edits(diff=diff, eol_chars=eol_chars) + + ensure_eof_new_line(document=document, eol_chars=eol_chars, text_edits=text_edits) + + return text_edits diff --git a/external-deps/python-lsp-server/pylsp/python_lsp.py b/external-deps/python-lsp-server/pylsp/python_lsp.py index a11f6119736..94e7a8cf089 100644 --- a/external-deps/python-lsp-server/pylsp/python_lsp.py +++ b/external-deps/python-lsp-server/pylsp/python_lsp.py @@ -6,6 +6,7 @@ import os import socketserver import threading +import ujson as json from pylsp_jsonrpc.dispatchers import MethodDispatcher from pylsp_jsonrpc.endpoint import Endpoint @@ -33,6 +34,7 @@ class _StreamHandlerWrapper(socketserver.StreamRequestHandler): def setup(self): super().setup() + # pylint: disable=no-member self.delegate = self.DELEGATE_CLASS(self.rfile, self.wfile) def handle(self): @@ -46,6 +48,7 @@ def handle(self): if isinstance(e, WindowsError) and e.winerror == 10054: pass + # pylint: disable=no-member self.SHUTDOWN_CALL() @@ -91,6 +94,59 @@ def start_io_lang_server(rfile, wfile, check_parent_process, handler_class): server.start() +def start_ws_lang_server(port, check_parent_process, handler_class): + if not issubclass(handler_class, PythonLSPServer): + raise ValueError('Handler class must be an instance of PythonLSPServer') + + # pylint: disable=import-outside-toplevel + + # imports needed only for websockets based server + try: + import asyncio + from concurrent.futures import ThreadPoolExecutor + import websockets + except ImportError as e: + raise ImportError("websocket modules missing. Please run pip install 'python-lsp-server[websockets]") from e + + with ThreadPoolExecutor(max_workers=10) as tpool: + async def pylsp_ws(websocket): + log.debug("Creating LSP object") + + # creating a partial function and suppling the websocket connection + response_handler = partial(send_message, websocket=websocket) + + # Not using default stream reader and writer. + # Instead using a consumer based approach to handle processed requests + pylsp_handler = handler_class(rx=None, tx=None, consumer=response_handler, + check_parent_process=check_parent_process) + + async for message in websocket: + try: + log.debug("consuming payload and feeding it to LSP handler") + # pylint: disable=c-extension-no-member + request = json.loads(message) + loop = asyncio.get_running_loop() + await loop.run_in_executor(tpool, pylsp_handler.consume, request) + except Exception as e: # pylint: disable=broad-except + log.exception("Failed to process request %s, %s", message, str(e)) + + def send_message(message, websocket): + """Handler to send responses of processed requests to respective web socket clients""" + try: + # pylint: disable=c-extension-no-member + payload = json.dumps(message, ensure_ascii=False) + asyncio.run(websocket.send(payload)) + except Exception as e: # pylint: disable=broad-except + log.exception("Failed to write message %s, %s", message, str(e)) + + async def run_server(): + async with websockets.serve(pylsp_ws, port=port): + # runs forever + await asyncio.Future() + + asyncio.run(run_server()) + + class PythonLSPServer(MethodDispatcher): """ Implementation of the Microsoft VSCode Language Server Protocol https://github.com/Microsoft/language-server-protocol/blob/master/versions/protocol-1-x.md @@ -98,7 +154,7 @@ class PythonLSPServer(MethodDispatcher): # pylint: disable=too-many-public-methods,redefined-builtin - def __init__(self, rx, tx, check_parent_process=False): + def __init__(self, rx, tx, check_parent_process=False, consumer=None): self.workspace = None self.config = None self.root_uri = None @@ -106,10 +162,24 @@ def __init__(self, rx, tx, check_parent_process=False): self.workspaces = {} self.uri_workspace_mapper = {} - self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) - self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) self._check_parent_process = check_parent_process - self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS) + + if rx is not None: + self._jsonrpc_stream_reader = JsonRpcStreamReader(rx) + else: + self._jsonrpc_stream_reader = None + + if tx is not None: + self._jsonrpc_stream_writer = JsonRpcStreamWriter(tx) + else: + self._jsonrpc_stream_writer = None + + # if consumer is None, it is assumed that the default streams-based approach is being used + if consumer is None: + self._endpoint = Endpoint(self, self._jsonrpc_stream_writer.write, max_workers=MAX_WORKERS) + else: + self._endpoint = Endpoint(self, consumer, max_workers=MAX_WORKERS) + self._dispatchers = [] self._shutdown = False @@ -117,6 +187,11 @@ def start(self): """Entry point for the server.""" self._jsonrpc_stream_reader.listen(self._endpoint.consume) + def consume(self, message): + """Entry point for consumer based server. Alternative to stream listeners.""" + # assuming message will be JSON + self._endpoint.consume(message) + def __getitem__(self, item): """Override getitem to fallback through multiple dispatchers.""" if self._shutdown and item != 'exit': @@ -141,8 +216,10 @@ def m_shutdown(self, **_kwargs): def m_exit(self, **_kwargs): self._endpoint.shutdown() - self._jsonrpc_stream_reader.close() - self._jsonrpc_stream_writer.close() + if self._jsonrpc_stream_reader is not None: + self._jsonrpc_stream_reader.close() + if self._jsonrpc_stream_writer is not None: + self._jsonrpc_stream_writer.close() def _match_uri_to_workspace(self, uri): workspace_uri = _utils.match_uri_to_workspace(uri, self.workspaces) @@ -277,11 +354,11 @@ def document_symbols(self, doc_uri): def execute_command(self, command, arguments): return self._hook('pylsp_execute_command', command=command, arguments=arguments) - def format_document(self, doc_uri): - return self._hook('pylsp_format_document', doc_uri) + def format_document(self, doc_uri, options): + return self._hook('pylsp_format_document', doc_uri, options=options) - def format_range(self, doc_uri, range): - return self._hook('pylsp_format_range', doc_uri, range=range) + def format_range(self, doc_uri, range, options): + return self._hook('pylsp_format_range', doc_uri, range=range, options=options) def highlight(self, doc_uri, position): return flatten(self._hook('pylsp_document_highlight', doc_uri, position=position)) or None @@ -362,9 +439,8 @@ def m_text_document__hover(self, textDocument=None, position=None, **_kwargs): def m_text_document__document_symbol(self, textDocument=None, **_kwargs): return self.document_symbols(textDocument['uri']) - def m_text_document__formatting(self, textDocument=None, _options=None, **_kwargs): - # For now we're ignoring formatting options. - return self.format_document(textDocument['uri']) + def m_text_document__formatting(self, textDocument=None, options=None, **_kwargs): + return self.format_document(textDocument['uri'], options) def m_text_document__rename(self, textDocument=None, position=None, newName=None, **_kwargs): return self.rename(textDocument['uri'], position, newName) @@ -372,9 +448,8 @@ def m_text_document__rename(self, textDocument=None, position=None, newName=None def m_text_document__folding_range(self, textDocument=None, **_kwargs): return self.folding(textDocument['uri']) - def m_text_document__range_formatting(self, textDocument=None, range=None, _options=None, **_kwargs): - # Again, we'll ignore formatting options for now. - return self.format_range(textDocument['uri'], range) + def m_text_document__range_formatting(self, textDocument=None, range=None, options=None, **_kwargs): + return self.format_range(textDocument['uri'], range, options) def m_text_document__references(self, textDocument=None, position=None, context=None, **_kwargs): exclude_declaration = not context['includeDeclaration'] diff --git a/external-deps/python-lsp-server/pylsp/text_edit.py b/external-deps/python-lsp-server/pylsp/text_edit.py new file mode 100644 index 00000000000..24d74eeb839 --- /dev/null +++ b/external-deps/python-lsp-server/pylsp/text_edit.py @@ -0,0 +1,94 @@ +# Copyright 2017-2020 Palantir Technologies, Inc. +# Copyright 2021- Python Language Server Contributors. + +def get_well_formatted_range(lsp_range): + start = lsp_range['start'] + end = lsp_range['end'] + + if start['line'] > end['line'] or (start['line'] == end['line'] and start['character'] > end['character']): + return {'start': end, 'end': start} + + return lsp_range + + +def get_well_formatted_edit(text_edit): + lsp_range = get_well_formatted_range(text_edit['range']) + if lsp_range != text_edit['range']: + return {'newText': text_edit['newText'], 'range': lsp_range} + + return text_edit + + +def compare_text_edits(a, b): + diff = a['range']['start']['line'] - b['range']['start']['line'] + if diff == 0: + return a['range']['start']['character'] - b['range']['start']['character'] + + return diff + + +def merge_sort_text_edits(text_edits): + if len(text_edits) <= 1: + return text_edits + + p = len(text_edits) // 2 + left = text_edits[:p] + right = text_edits[p:] + + merge_sort_text_edits(left) + merge_sort_text_edits(right) + + left_idx = 0 + right_idx = 0 + i = 0 + while left_idx < len(left) and right_idx < len(right): + ret = compare_text_edits(left[left_idx], right[right_idx]) + if ret <= 0: + # smaller_equal -> take left to preserve order + text_edits[i] = left[left_idx] + i += 1 + left_idx += 1 + else: + # greater -> take right + text_edits[i] = right[right_idx] + i += 1 + right_idx += 1 + while left_idx < len(left): + text_edits[i] = left[left_idx] + i += 1 + left_idx += 1 + while right_idx < len(right): + text_edits[i] = right[right_idx] + i += 1 + right_idx += 1 + return text_edits + + +class OverLappingTextEditException(Exception): + """ + Text edits are expected to be sorted + and compressed instead of overlapping. + This error is raised when two edits + are overlapping. + """ + + +def apply_text_edits(doc, text_edits): + text = doc.source + sorted_edits = merge_sort_text_edits(list(map(get_well_formatted_edit, text_edits))) + last_modified_offset = 0 + spans = [] + for e in sorted_edits: + start_offset = doc.offset_at_position(e['range']['start']) + if start_offset < last_modified_offset: + raise OverLappingTextEditException('overlapping edit') + + if start_offset > last_modified_offset: + spans.append(text[last_modified_offset:start_offset]) + + if len(e['newText']): + spans.append(e['newText']) + last_modified_offset = doc.offset_at_position(e['range']['end']) + + spans.append(text[last_modified_offset:]) + return ''.join(spans) diff --git a/external-deps/python-lsp-server/pyproject.toml b/external-deps/python-lsp-server/pyproject.toml index cbad00d45e1..ff60a1853c9 100644 --- a/external-deps/python-lsp-server/pyproject.toml +++ b/external-deps/python-lsp-server/pyproject.toml @@ -1,7 +1,101 @@ +# Copyright 2017-2020 Palantir Technologies, Inc. +# Copyright 2021- Python Language Server Contributors. + [build-system] -requires = ["setuptools>=44", "wheel", "setuptools_scm[toml]>=3.4.3"] +requires = ["setuptools>=61.2.0", "wheel", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" +[project] +name = "python-lsp-server" +authors = [{name = "Python Language Server Contributors"}] +description = "Python Language Server for the Language Server Protocol" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.7" +dependencies = [ + "jedi>=0.17.2,<0.19.0", + "python-lsp-jsonrpc>=1.0.0", + "pluggy>=1.0.0", + "ujson>=3.0.0", + "setuptools>=39.0.0", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/python-lsp/python-lsp-server" + +[project.optional-dependencies] +all = [ + "autopep8>=1.6.0,<1.7.0", + "flake8>=4.0.0,<4.1.0", + "mccabe>=0.6.0,<0.7.0", + "pycodestyle>=2.8.0,<2.9.0", + "pydocstyle>=2.0.0", + "pyflakes>=2.4.0,<2.5.0", + "pylint>=2.5.0", + "rope>=0.10.5", + "yapf", + "whatthepatch" +] +autopep8 = ["autopep8>=1.6.0,<1.7.0"] +flake8 = ["flake8>=4.0.0,<4.1.0"] +mccabe = ["mccabe>=0.6.0,<0.7.0"] +pycodestyle = ["pycodestyle>=2.8.0,<2.9.0"] +pydocstyle = ["pydocstyle>=2.0.0"] +pyflakes = ["pyflakes>=2.4.0,<2.5.0"] +pylint = ["pylint>=2.5.0"] +rope = ["rope>0.10.5"] +yapf = ["yapf", "whatthepatch>=1.0.2,<2.0.0"] +websockets = ["websockets>=10.3"] +test = [ + "pylint>=2.5.0", + "pytest", + "pytest-cov", + "coverage", + "numpy<1.23", + "pandas", + "matplotlib", + "pyqt5", + "flaky", +] + +[project.entry-points.pylsp] +autopep8 = "pylsp.plugins.autopep8_format" +folding = "pylsp.plugins.folding" +flake8 = "pylsp.plugins.flake8_lint" +jedi_completion = "pylsp.plugins.jedi_completion" +jedi_definition = "pylsp.plugins.definition" +jedi_hover = "pylsp.plugins.hover" +jedi_highlight = "pylsp.plugins.highlight" +jedi_references = "pylsp.plugins.references" +jedi_rename = "pylsp.plugins.jedi_rename" +jedi_signature_help = "pylsp.plugins.signature" +jedi_symbols = "pylsp.plugins.symbols" +mccabe = "pylsp.plugins.mccabe_lint" +preload = "pylsp.plugins.preload_imports" +pycodestyle = "pylsp.plugins.pycodestyle_lint" +pydocstyle = "pylsp.plugins.pydocstyle_lint" +pyflakes = "pylsp.plugins.pyflakes_lint" +pylint = "pylsp.plugins.pylint_lint" +rope_completion = "pylsp.plugins.rope_completion" +rope_rename = "pylsp.plugins.rope_rename" +yapf = "pylsp.plugins.yapf_format" + +[project.scripts] +pylsp = "pylsp.__main__:main" + +[tool.setuptools] +license-files = ["LICENSE"] +include-package-data = false + +[tool.setuptools.packages.find] +exclude = ["contrib", "docs", "test", "test.*", "test.plugins", "test.plugins.*"] +namespaces = false + [tool.setuptools_scm] write_to = "pylsp/_version.py" write_to_template = "__version__ = \"{version}\"\n" # VERSION_INFO is populated in __main__ + +[tool.pytest.ini_options] +testpaths = ["test"] +addopts = "--cov-report html --cov-report term --junitxml=pytest.xml --cov pylsp --cov test" diff --git a/external-deps/python-lsp-server/scripts/jsonschema2md.py b/external-deps/python-lsp-server/scripts/jsonschema2md.py index b10de88690f..3939a134664 100644 --- a/external-deps/python-lsp-server/scripts/jsonschema2md.py +++ b/external-deps/python-lsp-server/scripts/jsonschema2md.py @@ -10,7 +10,7 @@ def describe_array(prop: dict) -> str: if "uniqueItems" in prop: unique_qualifier = "unique" if prop["uniqueItems"] else "non-unique" item_type = describe_type(prop["items"]) - extra += f" of {unique_qualifier} {item_type} items" + extra = " ".join(filter(bool, ["of", unique_qualifier, item_type, "items"])) return extra @@ -31,13 +31,19 @@ def describe_number(prop: dict) -> str: def describe_type(prop: dict) -> str: prop_type = prop["type"] - label = f"`{prop_type}`" - if prop_type in EXTRA_DESCRIPTORS: - label += " " + EXTRA_DESCRIPTORS[prop_type](prop) - if "enum" in prop: - allowed_values = [f"`{value}`" for value in prop["enum"]] - label += "one of: " + ", ".join(allowed_values) - return label + types = prop_type if isinstance(prop_type, list) else [prop_type] + if "null" in types: + types.remove("null") + if len(types) == 1: + prop_type = types[0] + parts = [f"`{prop_type}`"] + for option in types: + if option in EXTRA_DESCRIPTORS: + parts.append(EXTRA_DESCRIPTORS[option](prop)) + if "enum" in prop: + allowed_values = [f"`{value}`" for value in prop["enum"]] + parts.append("(one of: " + ", ".join(allowed_values) + ")") + return " ".join(parts) def convert_schema(schema: dict, source: str = None) -> str: diff --git a/external-deps/python-lsp-server/setup.cfg b/external-deps/python-lsp-server/setup.cfg index 17db54d9170..933ff6a4f7f 100644 --- a/external-deps/python-lsp-server/setup.cfg +++ b/external-deps/python-lsp-server/setup.cfg @@ -1,91 +1,7 @@ -[metadata] -name = python-lsp-server -author = Python Language Server Contributors -description = Python Language Server for the Language Server Protocol -url = https://github.com/python-lsp/python-lsp-server -long_description = file: README.md -long_description_content_type = text/markdown -license = MIT -license_file = LICENSE - -[options] -packages = find: -python_requires = >=3.7 -install_requires = - jedi>=0.17.2,<0.19.0 - python-lsp-jsonrpc>=1.0.0 - pluggy>=1.0.0 - ujson>=3.0.0 - setuptools>=39.0.0 -setup_requires = setuptools>=44; wheel; setuptools_scm[toml]>=3.4.3 - -[options.packages.find] -exclude = contrib; docs; test; test.*; test.plugins; test.plugins.* - -[options.extras_require] -all = - autopep8>=1.6.0,<1.7.0 - flake8>=4.0.0,<4.1.0 - mccabe>=0.6.0,<0.7.0 - pycodestyle>=2.8.0,<2.9.0 - pydocstyle>=2.0.0 - pyflakes>=2.4.0,<2.5.0 - pylint>=2.5.0 - rope>=0.10.5 - yapf -autopep8 = autopep8>=1.6.0,<1.7.0 -flake8 = flake8>=4.0.0,<4.1.0 -mccabe = mccabe>=0.6.0,<0.7.0 -pycodestyle = pycodestyle>=2.8.0,<2.9.0 -pydocstyle = pydocstyle>=2.0.0 -pyflakes = pyflakes>=2.4.0,<2.5.0 -pylint = pylint>=2.5.0 -rope = rope>0.10.5 -yapf = yapf -test = - pylint>=2.5.0 - pytest - pytest-cov - coverage - numpy - pandas - matplotlib - pyqt5 - flaky - -[options.entry_points] -console_scripts = pylsp = pylsp.__main__:main -pylsp = - autopep8 = pylsp.plugins.autopep8_format - folding = pylsp.plugins.folding - flake8 = pylsp.plugins.flake8_lint - jedi_completion = pylsp.plugins.jedi_completion - jedi_definition = pylsp.plugins.definition - jedi_hover = pylsp.plugins.hover - jedi_highlight = pylsp.plugins.highlight - jedi_references = pylsp.plugins.references - jedi_rename = pylsp.plugins.jedi_rename - jedi_signature_help = pylsp.plugins.signature - jedi_symbols = pylsp.plugins.symbols - mccabe = pylsp.plugins.mccabe_lint - preload = pylsp.plugins.preload_imports - pycodestyle = pylsp.plugins.pycodestyle_lint - pydocstyle = pylsp.plugins.pydocstyle_lint - pyflakes = pylsp.plugins.pyflakes_lint - pylint = pylsp.plugins.pylint_lint - rope_completion = pylsp.plugins.rope_completion - rope_rename = pylsp.plugins.rope_rename - yapf = pylsp.plugins.yapf_format +# Copyright 2017-2020 Palantir Technologies, Inc. +# Copyright 2021- Python Language Server Contributors. [pycodestyle] ignore = E226, E722, W504 max-line-length = 120 exclude = test/plugins/.ropeproject,test/.ropeproject - - -[tool:pytest] -testpaths = test -addopts = - --cov-report html --cov-report term --junitxml=pytest.xml - --cov pylsp --cov test - diff --git a/external-deps/python-lsp-server/setup.py b/external-deps/python-lsp-server/setup.py index 28d2d305f45..04cfa069afc 100755 --- a/external-deps/python-lsp-server/setup.py +++ b/external-deps/python-lsp-server/setup.py @@ -8,5 +8,4 @@ if __name__ == "__main__": setup( name="python-lsp-server", # to allow GitHub dependency tracking work - packages=find_packages(exclude=["contrib", "docs", "test", "test.*"]), # https://github.com/pypa/setuptools/issues/2688 ) diff --git a/external-deps/python-lsp-server/test/plugins/test_flake8_lint.py b/external-deps/python-lsp-server/test/plugins/test_flake8_lint.py index 59a776a19f8..6e162e8f758 100644 --- a/external-deps/python-lsp-server/test/plugins/test_flake8_lint.py +++ b/external-deps/python-lsp-server/test/plugins/test_flake8_lint.py @@ -39,7 +39,8 @@ def test_flake8_unsaved(workspace): assert unused_var['code'] == 'F841' assert unused_var['range']['start'] == {'line': 5, 'character': 1} assert unused_var['range']['end'] == {'line': 5, 'character': 11} - assert unused_var['severity'] == lsp.DiagnosticSeverity.Warning + assert unused_var['severity'] == lsp.DiagnosticSeverity.Error + assert unused_var['tags'] == [lsp.DiagnosticTag.Unnecessary] def test_flake8_lint(workspace): @@ -53,7 +54,7 @@ def test_flake8_lint(workspace): assert unused_var['code'] == 'F841' assert unused_var['range']['start'] == {'line': 5, 'character': 1} assert unused_var['range']['end'] == {'line': 5, 'character': 11} - assert unused_var['severity'] == lsp.DiagnosticSeverity.Warning + assert unused_var['severity'] == lsp.DiagnosticSeverity.Error finally: os.remove(name) @@ -158,3 +159,25 @@ def test_flake8_per_file_ignores(workspace): assert not res os.unlink(os.path.join(workspace.root_path, "setup.cfg")) + + +def test_per_file_ignores_alternative_syntax(workspace): + config_str = r"""[flake8] +per-file-ignores = **/__init__.py:F401,E402 + """ + + doc_str = "print('hi')\nimport os\n" + + doc_uri = uris.from_fs_path(os.path.join(workspace.root_path, "blah/__init__.py")) + workspace.put_document(doc_uri, doc_str) + + flake8_settings = get_flake8_cfg_settings(workspace, config_str) + + assert "perFileIgnores" in flake8_settings + assert len(flake8_settings["perFileIgnores"]) == 2 + + doc = workspace.get_document(doc_uri) + res = flake8_lint.pylsp_lint(workspace, doc) + assert not res + + os.unlink(os.path.join(workspace.root_path, "setup.cfg")) diff --git a/external-deps/python-lsp-server/test/plugins/test_pylint_lint.py b/external-deps/python-lsp-server/test/plugins/test_pylint_lint.py index cbad9c3b49a..afd5c30d119 100644 --- a/external-deps/python-lsp-server/test/plugins/test_pylint_lint.py +++ b/external-deps/python-lsp-server/test/plugins/test_pylint_lint.py @@ -51,6 +51,7 @@ def test_pylint(config, workspace): assert unused_import['range']['start'] == {'line': 0, 'character': 0} assert unused_import['severity'] == lsp.DiagnosticSeverity.Warning + assert unused_import['tags'] == [lsp.DiagnosticTag.Unnecessary] if IS_PY3: # test running pylint in stdin @@ -79,6 +80,7 @@ def test_syntax_error_pylint_py3(config, workspace): # Pylint doesn't give column numbers for invalid syntax. assert diag['range']['start'] == {'line': 0, 'character': 12} assert diag['severity'] == lsp.DiagnosticSeverity.Error + assert 'tags' not in diag # test running pylint in stdin config.plugin_settings('pylint')['executable'] = 'pylint' diff --git a/external-deps/python-lsp-server/test/plugins/test_yapf_format.py b/external-deps/python-lsp-server/test/plugins/test_yapf_format.py index 94f619e8ef9..1a965a27cf2 100644 --- a/external-deps/python-lsp-server/test/plugins/test_yapf_format.py +++ b/external-deps/python-lsp-server/test/plugins/test_yapf_format.py @@ -6,6 +6,7 @@ from pylsp import uris from pylsp.plugins.yapf_format import pylsp_format_document, pylsp_format_range from pylsp.workspace import Document +from pylsp.text_edit import apply_text_edits DOC_URI = uris.from_fs_path(__file__) DOC = """A = [ @@ -21,14 +22,16 @@ """ GOOD_DOC = """A = ['hello', 'world']\n""" +FOUR_SPACE_DOC = """def hello(): + pass +""" def test_format(workspace): doc = Document(DOC_URI, workspace, DOC) res = pylsp_format_document(doc) - assert len(res) == 1 - assert res[0]['newText'] == "A = ['h', 'w', 'a']\n\nB = ['h', 'w']\n" + assert apply_text_edits(doc, res) == "A = ['h', 'w', 'a']\n\nB = ['h', 'w']\n" def test_range_format(workspace): @@ -40,10 +43,8 @@ def test_range_format(workspace): } res = pylsp_format_range(doc, def_range) - assert len(res) == 1 - # Make sure B is still badly formatted - assert res[0]['newText'] == "A = ['h', 'w', 'a']\n\nB = ['h',\n\n\n'w']\n" + assert apply_text_edits(doc, res) == "A = ['h', 'w', 'a']\n\nB = ['h',\n\n\n'w']\n" def test_no_change(workspace): @@ -58,13 +59,51 @@ def test_config_file(tmpdir, workspace): src = tmpdir.join('test.py') doc = Document(uris.from_fs_path(src.strpath), workspace, DOC) + res = pylsp_format_document(doc) + # A was split on multiple lines because of column_limit from config file - assert pylsp_format_document(doc)[0]['newText'] == "A = [\n 'h', 'w',\n 'a'\n]\n\nB = ['h', 'w']\n" + assert apply_text_edits(doc, res) == "A = [\n 'h', 'w',\n 'a'\n]\n\nB = ['h', 'w']\n" -@pytest.mark.parametrize('newline', ['\r\n', '\r']) +@pytest.mark.parametrize('newline', ['\r\n']) def test_line_endings(workspace, newline): doc = Document(DOC_URI, workspace, f'import os;import sys{2 * newline}dict(a=1)') res = pylsp_format_document(doc) - assert res[0]['newText'] == f'import os{newline}import sys{2 * newline}dict(a=1){newline}' + assert apply_text_edits(doc, res) == f'import os{newline}import sys{2 * newline}dict(a=1){newline}' + + +def test_format_with_tab_size_option(workspace): + doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC) + res = pylsp_format_document(doc, {"tabSize": "8"}) + + assert apply_text_edits(doc, res) == FOUR_SPACE_DOC.replace(" ", " ") + + +def test_format_with_insert_spaces_option(workspace): + doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC) + res = pylsp_format_document(doc, {"insertSpaces": False}) + + assert apply_text_edits(doc, res) == FOUR_SPACE_DOC.replace(" ", "\t") + + +def test_format_with_yapf_specific_option(workspace): + doc = Document(DOC_URI, workspace, FOUR_SPACE_DOC) + res = pylsp_format_document(doc, {"USE_TABS": True}) + + assert apply_text_edits(doc, res) == FOUR_SPACE_DOC.replace(" ", "\t") + + +def test_format_returns_text_edit_per_line(workspace): + single_space_indent = """def wow(): + log("x") + log("hi")""" + doc = Document(DOC_URI, workspace, single_space_indent) + res = pylsp_format_document(doc) + + # two removes and two adds + assert len(res) == 4 + assert res[0]['newText'] == "" + assert res[1]['newText'] == "" + assert res[2]['newText'] == " log(\"x\")\n" + assert res[3]['newText'] == " log(\"hi\")\n" diff --git a/external-deps/python-lsp-server/test/test_text_edit.py b/external-deps/python-lsp-server/test/test_text_edit.py new file mode 100644 index 00000000000..3e4cce1173f --- /dev/null +++ b/external-deps/python-lsp-server/test/test_text_edit.py @@ -0,0 +1,345 @@ +# Copyright 2017-2020 Palantir Technologies, Inc. +# Copyright 2021- Python Language Server Contributors. + +from pylsp.text_edit import OverLappingTextEditException, apply_text_edits +from pylsp import uris + +DOC_URI = uris.from_fs_path(__file__) + + +def test_apply_text_edits_insert(pylsp): + pylsp.workspace.put_document(DOC_URI, '012345678901234567890123456789') + test_doc = pylsp.workspace.get_document(DOC_URI) + + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 0 + }, + "end": { + "line": 0, + "character": 0 + } + }, + "newText": "Hello" + }]) == 'Hello012345678901234567890123456789' + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 1 + }, + "end": { + "line": 0, + "character": 1 + } + }, + "newText": "Hello" + }]) == '0Hello12345678901234567890123456789' + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 1 + }, + "end": { + "line": 0, + "character": 1 + } + }, + "newText": "Hello" + }, { + "range": { + "start": { + "line": 0, + "character": 1 + }, + "end": { + "line": 0, + "character": 1 + } + }, + "newText": "World" + }]) == '0HelloWorld12345678901234567890123456789' + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 2 + }, + "end": { + "line": 0, + "character": 2 + } + }, + "newText": "One" + }, { + "range": { + "start": { + "line": 0, + "character": 1 + }, + "end": { + "line": 0, + "character": 1 + } + }, + "newText": "Hello" + }, { + "range": { + "start": { + "line": 0, + "character": 1 + }, + "end": { + "line": 0, + "character": 1 + } + }, + "newText": "World" + }, { + "range": { + "start": { + "line": 0, + "character": 2 + }, + "end": { + "line": 0, + "character": 2 + } + }, + "newText": "Two" + }, { + "range": { + "start": { + "line": 0, + "character": 2 + }, + "end": { + "line": 0, + "character": 2 + } + }, + "newText": "Three" + }]) == '0HelloWorld1OneTwoThree2345678901234567890123456789' + + +def test_apply_text_edits_replace(pylsp): + pylsp.workspace.put_document(DOC_URI, '012345678901234567890123456789') + test_doc = pylsp.workspace.get_document(DOC_URI) + + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "Hello" + }]) == '012Hello678901234567890123456789' + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "Hello" + }, { + "range": { + "start": { + "line": 0, + "character": 6 + }, + "end": { + "line": 0, + "character": 9 + } + }, + "newText": "World" + }]) == '012HelloWorld901234567890123456789' + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "Hello" + }, { + "range": { + "start": { + "line": 0, + "character": 6 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "World" + }]) == '012HelloWorld678901234567890123456789' + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 6 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "World" + }, { + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "Hello" + }]) == '012HelloWorld678901234567890123456789' + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 3 + } + }, + "newText": "World" + }, { + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "Hello" + }]) == '012WorldHello678901234567890123456789' + + +def test_apply_text_edits_overlap(pylsp): + pylsp.workspace.put_document(DOC_URI, '012345678901234567890123456789') + test_doc = pylsp.workspace.get_document(DOC_URI) + + did_throw = False + try: + apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "Hello" + }, { + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 3 + } + }, + "newText": "World" + }]) + except OverLappingTextEditException: + did_throw = True + + assert did_throw + + did_throw = False + + try: + apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 0, + "character": 3 + }, + "end": { + "line": 0, + "character": 6 + } + }, + "newText": "Hello" + }, { + "range": { + "start": { + "line": 0, + "character": 4 + }, + "end": { + "line": 0, + "character": 4 + } + }, + "newText": "World" + }]) + except OverLappingTextEditException: + did_throw = True + + assert did_throw + + +def test_apply_text_edits_multiline(pylsp): + pylsp.workspace.put_document(DOC_URI, '0\n1\n2\n3\n4') + test_doc = pylsp.workspace.get_document(DOC_URI) + + assert apply_text_edits(test_doc, [{ + "range": { + "start": { + "line": 2, + "character": 0 + }, + "end": { + "line": 3, + "character": 0 + } + }, + "newText": "Hello" + }, { + "range": { + "start": { + "line": 1, + "character": 1 + }, + "end": { + "line": 1, + "character": 1 + } + }, + "newText": "World" + }]) == '0\n1World\nHello3\n4' From d60bc606f5abc35503a6e35b1fb4dcb69c49d053 Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Tue, 22 Feb 2022 11:38:34 -0800 Subject: [PATCH 26/83] Add codesign step in installer workflow --- .github/workflows/installer-macos.yml | 5 ++ installers/macOS/codesign.sh | 113 ++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100755 installers/macOS/codesign.sh diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index a6dd874dcd3..bb21b2be07f 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -82,6 +82,11 @@ jobs: - name: Test Application Bundle if: ${{github.event_name == 'pull_request'}} run: ./test_app.sh -t 60 -d 10 ${DISTDIR} + - name: Code Sign Application + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + run: ./codesign.sh -d -c "${MACOS_CERTIFICATE}" -p "${MACOS_CERTIFICATE_PWD}" "${DISTDIR}/Spyder.app" - name: Build Disk Image run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} --dmg --no-app - name: Upload Artifact diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh new file mode 100755 index 00000000000..4f3bcf02612 --- /dev/null +++ b/installers/macOS/codesign.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -e + +CERTFILE=certificate.p12 +KEY_PASS=keypass +KEYCHAIN=build.keychain +KEYCHAINFILE=${HOME}/Library/Keychains/${KEYCHAIN}-db + +help(){ cat < /dev/null; then + echo "clean up: ${KEYCHAIN} deleted." + fi + + # make sure temporary keychain file is gone + if [[ -e ${KEYCHAINFILE} ]]; then + rm -f ${KEYCHAINFILE} + echo "clean up: ${KEYCHAINFILE} file deleted" + fi +} + +unset CERT PASS DEEP ELEM APP + +while getopts "hc:p:de" option; do + case ${option} in + h) + help + exit;; + c) + CERT="${OPTARG}";; + p) + PASS="${OPTARG}";; + d) + DEEP="--deep" + unset ELEM;; + e) + ELEM=1 + unset DEEP;; + esac +done +shift $((${OPTIND} - 1)) + +test -z "${CERT}" && error "Error: Certificate must be provided. ${CERT}" + +test -z "${PASS}" && error "Error: Password must be provided. ${PASS}" + +test -n "$1" && APP="$1" || error "Error: Application must be provided." + +# always cleanup if there is an error +trap cleanup EXIT + +cleanup # make sure keychain and file don't exist + +# --- Prepare the certificate +# decode certificate-as-Github-secret back to p12 for import into keychain +echo $CERT | base64 --decode > ${CERTFILE} + +# --- Create keychain +security create-keychain -p ${KEY_PASS} ${KEYCHAIN} + +# Set keychain to default and unlock it so that we can add the certificate +# without GUI prompt +security default-keychain -s ${KEYCHAIN} +security unlock-keychain -p ${KEY_PASS} ${KEYCHAIN} +security import ${CERTFILE} -k ${KEYCHAIN} -P ${PASS} -T /usr/bin/codesign + +# Ensure that codesign can access the cert without GUI prompt +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ${KEY_PASS} ${KEYCHAIN} + +# verify import +security find-identity -p codesigning ${KEYCHAIN} + +# certificate common name +CNAME="Test Code Signing Certificate" + +# ---- Sign app +if [[ -n ${ELEM} ]]; then + echo "Signing individual elements" + # sign frameworks first + for framework in ${APP}/Contents/Frameworks/*; do + /usr/bin/codesign -f -v -s "${CNAME}" "${framework}" + done + + # sign extra binary next + /usr/bin/codesign -f -v -s "${CNAME}" "${APP}/Contents/MacOS/python" +else + echo "Using --deep flag" +fi + +# sign app bundle +/usr/bin/codesign -f -v ${DEEP} -s "${CNAME}" "${APP}" From a1517b54376398819f6a3ff78bb647f7e512761b Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Tue, 22 Feb 2022 11:39:21 -0800 Subject: [PATCH 27/83] Add step to installer workflow that signs disk image --- .github/workflows/installer-macos.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index bb21b2be07f..5a864b18ab1 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -89,6 +89,11 @@ jobs: run: ./codesign.sh -d -c "${MACOS_CERTIFICATE}" -p "${MACOS_CERTIFICATE_PWD}" "${DISTDIR}/Spyder.app" - name: Build Disk Image run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} --dmg --no-app + - name: Sign Disk Image + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + run: ./codesign.sh -c "${MACOS_CERTIFICATE}" -p "${MACOS_CERTIFICATE_PWD}" "${DISTDIR}/${DMGNAME}" - name: Upload Artifact uses: actions/upload-artifact@v2 with: From 7eb6eaca6645bb1588b42d0bdf70e296a4d3944d Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Sat, 18 Jun 2022 11:52:29 -0700 Subject: [PATCH 28/83] Use correct certificate common name and add messaging. --- installers/macOS/codesign.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh index 4f3bcf02612..11cca8e6237 100755 --- a/installers/macOS/codesign.sh +++ b/installers/macOS/codesign.sh @@ -6,6 +6,9 @@ KEY_PASS=keypass KEYCHAIN=build.keychain KEYCHAINFILE=${HOME}/Library/Keychains/${KEYCHAIN}-db +# certificate common name +CNAME="Developer ID Application: Ryan Clary (6D7ZTH6B38)" + help(){ cat < ${CERTFILE} # --- Create keychain +echo "Creating keychain..." security create-keychain -p ${KEY_PASS} ${KEYCHAIN} # Set keychain to default and unlock it so that we can add the certificate # without GUI prompt +echo "Importing certificate..." security default-keychain -s ${KEYCHAIN} security unlock-keychain -p ${KEY_PASS} ${KEYCHAIN} security import ${CERTFILE} -k ${KEYCHAIN} -P ${PASS} -T /usr/bin/codesign @@ -90,24 +96,22 @@ security import ${CERTFILE} -k ${KEYCHAIN} -P ${PASS} -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ${KEY_PASS} ${KEYCHAIN} # verify import -security find-identity -p codesigning ${KEYCHAIN} - -# certificate common name -CNAME="Test Code Signing Certificate" +echo "Verifying identity..." +security find-identity -p codesigning -v ${KEYCHAIN} # ---- Sign app if [[ -n ${ELEM} ]]; then - echo "Signing individual elements" + echo "Signing individual elements..." # sign frameworks first for framework in ${APP}/Contents/Frameworks/*; do /usr/bin/codesign -f -v -s "${CNAME}" "${framework}" done # sign extra binary next + echo "Signing extra binary..." /usr/bin/codesign -f -v -s "${CNAME}" "${APP}/Contents/MacOS/python" -else - echo "Using --deep flag" fi # sign app bundle +echo "Signing application..." /usr/bin/codesign -f -v ${DEEP} -s "${CNAME}" "${APP}" From b43eead03540c50c8715277f48c58ba5a7b2926a Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Tue, 28 Jun 2022 17:36:16 -0700 Subject: [PATCH 29/83] Copy liblzma.5.dylib again from environment PIL package to resolve signing error. On CI, this dylib is first copied from from /usr/.../xz to Frameworks, then again from PIL to Frameworks. This is causing metadata issues for signing. Local builds only copy from PIL to Frameworks. --- .github/workflows/installer-macos.yml | 17 +++++---- installers/macOS/codesign.sh | 50 +++++++++++++-------------- installers/macOS/req-build.txt | 2 +- 3 files changed, 34 insertions(+), 35 deletions(-) diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index 5a864b18ab1..e37c3ee769e 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -47,15 +47,16 @@ jobs: LITE_FLAG: ${{ matrix.build_type == 'Lite' && '--lite' || '' }} DMGNAME: ${{ matrix.build_type == 'Lite' && 'Spyder-Lite.dmg' || 'Spyder.dmg' }} DISTDIR: ${{ github.workspace }}/dist + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} steps: - name: Checkout Code uses: actions/checkout@v2 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - # This is the last version working with py2app and modulegraph python-version: '3.9.5' architecture: 'x64' - name: Install Dependencies @@ -83,16 +84,14 @@ jobs: if: ${{github.event_name == 'pull_request'}} run: ./test_app.sh -t 60 -d 10 ${DISTDIR} - name: Code Sign Application - env: - MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} - run: ./codesign.sh -d -c "${MACOS_CERTIFICATE}" -p "${MACOS_CERTIFICATE_PWD}" "${DISTDIR}/Spyder.app" + run: | + pil=$(${pythonLocation} -c "import PIL, os; print(os.path.dirname(PIL.__file__))") + rm -v ${DISTDIR}/Spyder.app/Contents/Frameworks/liblzma.5.dylib + cp -v ${pil}/.dylibs/liblzma.5.dylib ${DISTDIR}/Spyder.app/Contents/Frameworks/ + ./codesign.sh -a "${DISTDIR}/Spyder.app" - name: Build Disk Image run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} --dmg --no-app - name: Sign Disk Image - env: - MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} run: ./codesign.sh -c "${MACOS_CERTIFICATE}" -p "${MACOS_CERTIFICATE_PWD}" "${DISTDIR}/${DMGNAME}" - name: Upload Artifact uses: actions/upload-artifact@v2 diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh index 11cca8e6237..13a4ad16656 100755 --- a/installers/macOS/codesign.sh +++ b/installers/macOS/codesign.sh @@ -4,7 +4,7 @@ set -e CERTFILE=certificate.p12 KEY_PASS=keypass KEYCHAIN=build.keychain -KEYCHAINFILE=${HOME}/Library/Keychains/${KEYCHAIN}-db +KEYCHAINFILE=$HOME/Library/Keychains/$KEYCHAIN-db # certificate common name CNAME="Developer ID Application: Ryan Clary (6D7ZTH6B38)" @@ -30,31 +30,31 @@ EOF cleanup () { security default-keychain -s login.keychain # restore default keychain - rm -f ${CERTFILE} # remove cert file + rm -f $CERTFILE # remove cert file # remove temporary keychain - if security delete-keychain ${KEYCHAIN} 2> /dev/null; then - echo "clean up: ${KEYCHAIN} deleted." + if security delete-keychain $KEYCHAIN 2> /dev/null; then + echo "clean up: $KEYCHAIN deleted." fi # make sure temporary keychain file is gone - if [[ -e ${KEYCHAINFILE} ]]; then - rm -f ${KEYCHAINFILE} - echo "clean up: ${KEYCHAINFILE} file deleted" + if [[ -e $KEYCHAINFILE ]]; then + rm -f $KEYCHAINFILE + echo "clean up: $KEYCHAINFILE file deleted" fi } unset CERT PASS DEEP ELEM APP while getopts "hc:p:de" option; do - case ${option} in + case $option in h) help exit;; c) - CERT="${OPTARG}";; + CERT="$OPTARG";; p) - PASS="${OPTARG}";; + PASS="$OPTARG";; d) DEEP="--deep" unset ELEM;; @@ -63,11 +63,11 @@ while getopts "hc:p:de" option; do unset DEEP;; esac done -shift $((${OPTIND} - 1)) +shift $(($OPTIND - 1)) -test -z "${CERT}" && error "Error: Certificate must be provided. ${CERT}" +test -z "$CERT" && error "Error: Certificate must be provided. $CERT" -test -z "${PASS}" && error "Error: Password must be provided. ${PASS}" +test -z "$PASS" && error "Error: Password must be provided. $PASS" test -n "$1" && APP="$1" || error "Error: Application must be provided." @@ -79,39 +79,39 @@ cleanup # make sure keychain and file don't exist # --- Prepare the certificate # decode certificate-as-Github-secret back to p12 for import into keychain echo "Decoding Certificate..." -echo $CERT | base64 --decode > ${CERTFILE} +echo $CERT | base64 --decode > $CERTFILE # --- Create keychain echo "Creating keychain..." -security create-keychain -p ${KEY_PASS} ${KEYCHAIN} +security create-keychain -p $KEY_PASS $KEYCHAIN # Set keychain to default and unlock it so that we can add the certificate # without GUI prompt echo "Importing certificate..." -security default-keychain -s ${KEYCHAIN} -security unlock-keychain -p ${KEY_PASS} ${KEYCHAIN} -security import ${CERTFILE} -k ${KEYCHAIN} -P ${PASS} -T /usr/bin/codesign +security default-keychain -s $KEYCHAIN +security unlock-keychain -p $KEY_PASS $KEYCHAIN +security import $CERTFILE -k $KEYCHAIN -P $PASS -T /usr/bin/codesign # Ensure that codesign can access the cert without GUI prompt -security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k ${KEY_PASS} ${KEYCHAIN} +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEY_PASS $KEYCHAIN # verify import echo "Verifying identity..." -security find-identity -p codesigning -v ${KEYCHAIN} +security find-identity -p codesigning -v $KEYCHAIN # ---- Sign app -if [[ -n ${ELEM} ]]; then +if [[ -n $ELEM ]]; then echo "Signing individual elements..." # sign frameworks first - for framework in ${APP}/Contents/Frameworks/*; do - /usr/bin/codesign -f -v -s "${CNAME}" "${framework}" + for framework in $APP/Contents/Frameworks/*; do + codesign -f -v -s "$CNAME" "$framework" done # sign extra binary next echo "Signing extra binary..." - /usr/bin/codesign -f -v -s "${CNAME}" "${APP}/Contents/MacOS/python" + codesign -f -v -s "$CNAME" "$APP/Contents/MacOS/python" fi # sign app bundle echo "Signing application..." -/usr/bin/codesign -f -v ${DEEP} -s "${CNAME}" "${APP}" +codesign -f -v $DEEP -s "$CNAME" "$APP" diff --git a/installers/macOS/req-build.txt b/installers/macOS/req-build.txt index 9c3e0ad5e8f..12526fb34ae 100644 --- a/installers/macOS/req-build.txt +++ b/installers/macOS/req-build.txt @@ -1,3 +1,3 @@ # For building standalone Mac app +dmgbuild>=1.4.2 py2app>=0.28 -dmgbuild>=1.4.2 \ No newline at end of file From e1da462991d3ecc5f751711bd963bdb61c95be4a Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Tue, 28 Jun 2022 17:43:39 -0700 Subject: [PATCH 30/83] Revise and refactor codesign.sh into keychain, codesign, and notarize scripts. Add separate steps in the CI workflow for each action of the signing/notarizing flow. This successfully notarizes the distribution, but breaks the application. --- .github/workflows/installer-macos.yml | 7 +- installers/macOS/certkeychain.sh | 83 ++++++++++++ installers/macOS/codesign.sh | 180 +++++++++++++------------- installers/macOS/notarize.sh | 58 +++++++++ 4 files changed, 237 insertions(+), 91 deletions(-) create mode 100755 installers/macOS/certkeychain.sh create mode 100755 installers/macOS/notarize.sh diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index e37c3ee769e..43a46d716b0 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -49,6 +49,7 @@ jobs: DISTDIR: ${{ github.workspace }}/dist MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + APPLICATION_PWD: ${{ secrets.APPLICATION_PWD }} steps: - name: Checkout Code uses: actions/checkout@v2 @@ -83,6 +84,8 @@ jobs: - name: Test Application Bundle if: ${{github.event_name == 'pull_request'}} run: ./test_app.sh -t 60 -d 10 ${DISTDIR} + - name: Create Keychain + run: ./certkeychain.sh "${MACOS_CERTIFICATE}" "${MACOS_CERTIFICATE_PWD}" - name: Code Sign Application run: | pil=$(${pythonLocation} -c "import PIL, os; print(os.path.dirname(PIL.__file__))") @@ -92,7 +95,9 @@ jobs: - name: Build Disk Image run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} --dmg --no-app - name: Sign Disk Image - run: ./codesign.sh -c "${MACOS_CERTIFICATE}" -p "${MACOS_CERTIFICATE_PWD}" "${DISTDIR}/${DMGNAME}" + run: ./codesign.sh "${DISTDIR}/${DMGNAME}" + - name: Notarize Disk Image + run: ./notarize.sh -p "${APPLICATION_PWD}" "${DISTDIR}/${DMGNAME}" - name: Upload Artifact uses: actions/upload-artifact@v2 with: diff --git a/installers/macOS/certkeychain.sh b/installers/macOS/certkeychain.sh new file mode 100755 index 00000000000..111a6f0f113 --- /dev/null +++ b/installers/macOS/certkeychain.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -e + +CERTFILE=certificate.p12 +KEY_PASS=keypass +KEYCHAIN=build.keychain +KEYCHAINFILE=$HOME/Library/Keychains/$KEYCHAIN-db + +help(){ cat <&1 +log(){ + level="INFO" + date "+%Y-%m-%d %H:%M:%S [$level] [keychain] -> $1" 1>&3 +} + +cleanup () { + log "Cleaning up $KEYCHAIN..." + + security default-keychain -s login.keychain # restore default keychain + rm -f $CERTFILE # remove cert file + + # remove temporary keychain + security delete-keychain $KEYCHAIN 2> /dev/null + + # make sure temporary keychain file is gone + if [[ -e $KEYCHAINFILE ]]; then + rm -f $KEYCHAINFILE + fi +} + +while getopts "hc" option; do + case $option in + (h) help; exit ;; + (c) cleanup; exit ;; + esac +done +shift $(($OPTIND - 1)) + +[[ $# < 2 ]] && log "Certificate and/or certificate password not provided" && exit 1 + +CERT=$1 +PASS=$2 + +cleanup # make sure keychain and file don't exist + +# --- Prepare the certificate +# decode certificate-as-Github-secret back to p12 for import into keychain +log "Decoding Certificate..." +echo $CERT | base64 --decode > $CERTFILE + +# --- Create keychain +log "Creating keychain..." +security create-keychain -p $KEY_PASS $KEYCHAIN + +# Set keychain to default and unlock it so that we can add the certificate +# without GUI prompt +log "Importing certificate..." +security default-keychain -s $KEYCHAIN +security unlock-keychain -p $KEY_PASS $KEYCHAIN +security import $CERTFILE -k $KEYCHAIN -P $PASS -T /usr/bin/codesign + +# Ensure that codesign can access the cert without GUI prompt +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEY_PASS $KEYCHAIN + +# verify import +log "Verifying identity..." +security find-identity -p codesigning -v $KEYCHAIN diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh index 13a4ad16656..8ca6e09251e 100755 --- a/installers/macOS/codesign.sh +++ b/installers/macOS/codesign.sh @@ -1,117 +1,117 @@ -#!/bin/bash +#!/usr/bin/env bash set -e -CERTFILE=certificate.p12 -KEY_PASS=keypass -KEYCHAIN=build.keychain -KEYCHAINFILE=$HOME/Library/Keychains/$KEYCHAIN-db - -# certificate common name -CNAME="Developer ID Application: Ryan Clary (6D7ZTH6B38)" - help(){ cat < /dev/null; then - echo "clean up: $KEYCHAIN deleted." - fi - - # make sure temporary keychain file is gone - if [[ -e $KEYCHAINFILE ]]; then - rm -f $KEYCHAINFILE - echo "clean up: $KEYCHAINFILE file deleted" - fi -} - -unset CERT PASS DEEP ELEM APP - -while getopts "hc:p:de" option; do +while getopts ":h" option; do case $option in - h) - help - exit;; - c) - CERT="$OPTARG";; - p) - PASS="$OPTARG";; - d) - DEEP="--deep" - unset ELEM;; - e) - ELEM=1 - unset DEEP;; + (h) help; exit ;; esac done shift $(($OPTIND - 1)) -test -z "$CERT" && error "Error: Certificate must be provided. $CERT" - -test -z "$PASS" && error "Error: Password must be provided. $PASS" - -test -n "$1" && APP="$1" || error "Error: Application must be provided." - -# always cleanup if there is an error -trap cleanup EXIT +exec 3>&1 # Additional output descriptor for logging +log(){ + level="INFO" + date "+%Y-%m-%d %H:%M:%S [$level] [codesign] -> $1" 1>&3 +} -cleanup # make sure keychain and file don't exist +[[ $# = 0 ]] && log "File not provided" && exit 1 -# --- Prepare the certificate -# decode certificate-as-Github-secret back to p12 for import into keychain -echo "Decoding Certificate..." -echo $CERT | base64 --decode > $CERTFILE +# Resolve full path; works for both .app and .dmg +FILE=$(cd $(dirname $1) && pwd -P)/$(basename $1) -# --- Create keychain -echo "Creating keychain..." -security create-keychain -p $KEY_PASS $KEYCHAIN +# --- Get certificate id +CNAME=$(security find-identity -p codesigning -v | pcregrep -o1 "\(([0-9A-Z]+)\)") +log "Certificate ID: $CNAME" -# Set keychain to default and unlock it so that we can add the certificate -# without GUI prompt -echo "Importing certificate..." -security default-keychain -s $KEYCHAIN -security unlock-keychain -p $KEY_PASS $KEYCHAIN -security import $CERTFILE -k $KEYCHAIN -P $PASS -T /usr/bin/codesign +csopts=("--force" "--verify" "--verbose" "--timestamp" "--sign" "$CNAME") -# Ensure that codesign can access the cert without GUI prompt -security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEY_PASS $KEYCHAIN +# --- Helper functions +code-sign(){ + codesign $@ +# echo $@ +} -# verify import -echo "Verifying identity..." -security find-identity -p codesigning -v $KEYCHAIN +sign-dir(){ + dir=$1; shift + for f in $(find "$dir" -type f "$@"); do + if [[ -x "$f" || "$f" = *".so" || "$f" = *".dylib" ]]; then + code-sign ${csopts[@]} $f + fi + done +} -# ---- Sign app -if [[ -n $ELEM ]]; then - echo "Signing individual elements..." - # sign frameworks first - for framework in $APP/Contents/Frameworks/*; do - codesign -f -v -s "$CNAME" "$framework" +if [[ "$FILE" = *".app" ]]; then +# code-sign ${csopts[@]} --options runtime --deep "$FILE" # fail: zlib.cpython-310-darwin.so no signature +# code-sign --verify --verbose --timestamp --sign $CNAME --options runtime --deep "$FILE" # same issue + + frameworks="$FILE/Contents/Frameworks" + resources="$FILE/Contents/Resources" + libdir="$resources/lib" + pydir=$(find -E "$libdir" -regex "$libdir/python[0-9]\.[0-9]+") + + skip=() + + # --- Sign Qt frameworks + log "Signing Qt frameworks..." + for fwk in "$pydir"/PyQt5/Qt5/lib/*.framework; do + _skip=() + if [[ "$fwk" = *"QtWebEngineCore"* ]]; then + subapp="$fwk/Helpers/QtWebEngineProcess.app" + code-sign ${csopts[@]} --options runtime "$subapp" + _skip+=("-not" "-path" "$subapp/*") + fi + sign-dir "$fwk" "${_skip[@]}" + code-sign ${csopts[@]} --options runtime "$fwk" + skip+=("-not" "-path" "$fwk/*") done - # sign extra binary next - echo "Signing extra binary..." - codesign -f -v -s "$CNAME" "$APP/Contents/MacOS/python" + # --- Sign micromamba + log "Signing micromamba..." + code-sign ${csopts[@]} --options runtime "$pydir/spyder/bin/micromamba" + skip+=("-not" "-path" "$pydir/spyder/bin/*") + + # --- Sign remaining resources + log "Signing resources..." + sign-dir "$resources" "${skip[@]}" + + # --- Sign zip contents + log "Signing zip file contents..." + pushd "$libdir" + zipfile=python*.zip + zipdir=$(echo $zipfile | egrep -o "python[0-9]+") + unzip -q $zipfile -d $zipdir + sign-dir $zipdir + ditto -c -k $zipdir $zipfile + rm -d -r -f $zipdir + popd + + # --- Sign app frameworks + log "Signing app frameworks..." + pyfwk="$frameworks/Python.framework" + code-sign ${csopts[@]} --options runtime $pyfwk + sign-dir "$frameworks" -not -path "$pyfwk/*" + + # --- Sign bundle + log "Signing app bundle..." + code-sign ${csopts[@]} --options runtime "$FILE/Contents/MacOS/python" + code-sign ${csopts[@]} --options runtime "$FILE/Contents/MacOS/Spyder" +# code-sign ${csopts[@]} --options runtime "$FILE" +fi +if [[ "$FILE" = *".dmg" ]]; then + # --- Sign dmg + log "Signing dmg image..." + code-sign ${csopts[@]} "$FILE" fi - -# sign app bundle -echo "Signing application..." -codesign -f -v $DEEP -s "$CNAME" "$APP" diff --git a/installers/macOS/notarize.sh b/installers/macOS/notarize.sh new file mode 100755 index 00000000000..54f32d59c24 --- /dev/null +++ b/installers/macOS/notarize.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -e + +help(){ cat <&1 # Additional output descriptor for logging +log(){ + level="INFO" + date "+%Y-%m-%d %H:%M:%S [$level] [notarize] -> $1" 1>&3 +} + +[[ -z $PWD ]] && log "Application-specific password not provided" && exit 1 +[[ $# = 0 ]] && log "File not provided" && exit 1 + +DMG=$(cd $(dirname $1) && pwd -P)/$(basename $1) # Resolve full path + +# --- Get certificate id +CNAME=$(security find-identity -p codesigning -v | pcregrep -o1 "\(([0-9A-Z]+)\)") +log "Certificate ID: $CNAME" + +# --- Notarize +log "Notarizing..." +xcrun notarytool submit $DMG --wait --team-id $CNAME --apple-id mrclary@me.com --password "$PWD" | tee temp.txt + +submitid=$(pcregrep -o1 "^\s*id: ([0-9a-z-]+)" temp.txt | head -1) +status=$(pcregrep -o1 "^\s*status: (\w+$)" temp.txt) +rm temp.txt + +log "Notary log:" +xcrun notarytool log $submitid --team-id $CNAME --apple-id mrclary@me.com --password "$PWD" + +if [[ "$status" = "Accepted" ]]; then + log "Stapling notary ticket..." + xcrun stapler staple -v "$DMG" +fi + +# spctl -a -t exec -vv $DMG From a52cbcf5216e72ff257df4bfe58919810562516c Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Wed, 29 Jun 2022 14:16:16 -0700 Subject: [PATCH 31/83] Update bundle identifier --- installers/macOS/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installers/macOS/setup.py b/installers/macOS/setup.py index 4565fc70b9c..4f5e93e4c36 100644 --- a/installers/macOS/setup.py +++ b/installers/macOS/setup.py @@ -84,7 +84,7 @@ def make_app_bundle(dist_dir, make_lite=False): 'CFBundleDocumentTypes': [{'CFBundleTypeExtensions': EDIT_EXT, 'CFBundleTypeName': 'Text File', 'CFBundleTypeRole': 'Editor'}], - 'CFBundleIdentifier': 'org.spyder-ide', + 'CFBundleIdentifier': 'org.spyder-ide.Spyder', 'CFBundleShortVersionString': SPYVER, 'NSRequiresAquaSystemAppearance': False, # Darkmode support 'LSEnvironment': {'SPY_COMMIT': SPYCOM, 'SPY_BRANCH': SPYBRA} From 0e043f2c99229443a615cac21725812cb9e8202f Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Wed, 29 Jun 2022 14:18:46 -0700 Subject: [PATCH 32/83] Revise codesign script --- installers/macOS/codesign.sh | 51 ++++++++++++++---------------------- 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh index 8ca6e09251e..be75dd1ec0e 100755 --- a/installers/macOS/codesign.sh +++ b/installers/macOS/codesign.sh @@ -46,54 +46,43 @@ code-sign(){ sign-dir(){ dir=$1; shift - for f in $(find "$dir" -type f "$@"); do - if [[ -x "$f" || "$f" = *".so" || "$f" = *".dylib" ]]; then - code-sign ${csopts[@]} $f - fi + for f in $(find "$dir" "$@"); do + code-sign ${csopts[@]} $f done } if [[ "$FILE" = *".app" ]]; then -# code-sign ${csopts[@]} --options runtime --deep "$FILE" # fail: zlib.cpython-310-darwin.so no signature -# code-sign --verify --verbose --timestamp --sign $CNAME --options runtime --deep "$FILE" # same issue - frameworks="$FILE/Contents/Frameworks" resources="$FILE/Contents/Resources" libdir="$resources/lib" - pydir=$(find -E "$libdir" -regex "$libdir/python[0-9]\.[0-9]+") + pydir=$(find "$libdir" -maxdepth 1 -type d -name python*) + + # --- Sign resources + log "Signing 'so' and 'dylib' files..." + sign-dir "$resources" \( -name *.so -or -name *.dylib \) - skip=() + # --- Sign micromamba + log "Signing micromamba..." + code-sign ${csopts[@]} -o runtime "$pydir/spyder/bin/micromamba" # --- Sign Qt frameworks log "Signing Qt frameworks..." for fwk in "$pydir"/PyQt5/Qt5/lib/*.framework; do - _skip=() if [[ "$fwk" = *"QtWebEngineCore"* ]]; then subapp="$fwk/Helpers/QtWebEngineProcess.app" - code-sign ${csopts[@]} --options runtime "$subapp" - _skip+=("-not" "-path" "$subapp/*") + code-sign ${csopts[@]} -o runtime "$subapp" fi - sign-dir "$fwk" "${_skip[@]}" - code-sign ${csopts[@]} --options runtime "$fwk" - skip+=("-not" "-path" "$fwk/*") + sign-dir "$fwk" -type f -perm +111 -not -path *QtWebEngineProcess.app* + code-sign ${csopts[@]} "$fwk" done - # --- Sign micromamba - log "Signing micromamba..." - code-sign ${csopts[@]} --options runtime "$pydir/spyder/bin/micromamba" - skip+=("-not" "-path" "$pydir/spyder/bin/*") - - # --- Sign remaining resources - log "Signing resources..." - sign-dir "$resources" "${skip[@]}" - # --- Sign zip contents - log "Signing zip file contents..." + log "Signing 'dylib' files in zip archive..." pushd "$libdir" zipfile=python*.zip zipdir=$(echo $zipfile | egrep -o "python[0-9]+") unzip -q $zipfile -d $zipdir - sign-dir $zipdir + sign-dir $zipdir -name *.dylib ditto -c -k $zipdir $zipfile rm -d -r -f $zipdir popd @@ -101,15 +90,15 @@ if [[ "$FILE" = *".app" ]]; then # --- Sign app frameworks log "Signing app frameworks..." pyfwk="$frameworks/Python.framework" - code-sign ${csopts[@]} --options runtime $pyfwk - sign-dir "$frameworks" -not -path "$pyfwk/*" + code-sign ${csopts[@]} $pyfwk + sign-dir "$frameworks" -name *.dylib # --- Sign bundle log "Signing app bundle..." - code-sign ${csopts[@]} --options runtime "$FILE/Contents/MacOS/python" - code-sign ${csopts[@]} --options runtime "$FILE/Contents/MacOS/Spyder" -# code-sign ${csopts[@]} --options runtime "$FILE" + code-sign ${csopts[@]} -o runtime "$FILE/Contents/MacOS/python" + code-sign ${csopts[@]} -o runtime "$FILE/Contents/MacOS/Spyder" fi + if [[ "$FILE" = *".dmg" ]]; then # --- Sign dmg log "Signing dmg image..." From 45e1827eeb05e4bdc9d658dc1a388dfd45a0c040 Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Wed, 29 Jun 2022 21:40:36 -0700 Subject: [PATCH 33/83] No need to clean up keychain on CI. On local builds, keychain items should be added to the login.keychain. --- installers/macOS/certkeychain.sh | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/installers/macOS/certkeychain.sh b/installers/macOS/certkeychain.sh index 111a6f0f113..b3811c7df36 100755 --- a/installers/macOS/certkeychain.sh +++ b/installers/macOS/certkeychain.sh @@ -29,21 +29,6 @@ log(){ date "+%Y-%m-%d %H:%M:%S [$level] [keychain] -> $1" 1>&3 } -cleanup () { - log "Cleaning up $KEYCHAIN..." - - security default-keychain -s login.keychain # restore default keychain - rm -f $CERTFILE # remove cert file - - # remove temporary keychain - security delete-keychain $KEYCHAIN 2> /dev/null - - # make sure temporary keychain file is gone - if [[ -e $KEYCHAINFILE ]]; then - rm -f $KEYCHAINFILE - fi -} - while getopts "hc" option; do case $option in (h) help; exit ;; @@ -57,8 +42,6 @@ shift $(($OPTIND - 1)) CERT=$1 PASS=$2 -cleanup # make sure keychain and file don't exist - # --- Prepare the certificate # decode certificate-as-Github-secret back to p12 for import into keychain log "Decoding Certificate..." From bc66faf239ea16dedacca97c1b66c159b04befbb Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Wed, 29 Jun 2022 22:36:30 -0700 Subject: [PATCH 34/83] Check for Python.framework before signing it. Python.framework is not present on CI, but exists on local builds. --- installers/macOS/codesign.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh index be75dd1ec0e..956ecd63f27 100755 --- a/installers/macOS/codesign.sh +++ b/installers/macOS/codesign.sh @@ -90,7 +90,10 @@ if [[ "$FILE" = *".app" ]]; then # --- Sign app frameworks log "Signing app frameworks..." pyfwk="$frameworks/Python.framework" - code-sign ${csopts[@]} $pyfwk + if [[ -e $pyfwk ]]; then + # Python.framework is not present on CI + code-sign ${csopts[@]} $pyfwk + fi sign-dir "$frameworks" -name *.dylib # --- Sign bundle From 784299b78cc48d410a00a84671efefe435cf8fc0 Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Thu, 30 Jun 2022 12:39:54 -0700 Subject: [PATCH 35/83] Use altool rather than notarytool. notarytool is not available until Xcode >13. For Xcode <13 (CI Xcode = 12), altool must be used. --- .github/workflows/installer-macos.yml | 2 +- installers/macOS/notarize.sh | 38 ++++++++++++++++++++------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index 43a46d716b0..a82719da015 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -97,7 +97,7 @@ jobs: - name: Sign Disk Image run: ./codesign.sh "${DISTDIR}/${DMGNAME}" - name: Notarize Disk Image - run: ./notarize.sh -p "${APPLICATION_PWD}" "${DISTDIR}/${DMGNAME}" + run: ./notarize.sh -i 30 -p "${APPLICATION_PWD}" "${DISTDIR}/${DMGNAME}" - name: Upload Artifact uses: actions/upload-artifact@v2 with: diff --git a/installers/macOS/notarize.sh b/installers/macOS/notarize.sh index 54f32d59c24..3618f9c495d 100755 --- a/installers/macOS/notarize.sh +++ b/installers/macOS/notarize.sh @@ -11,14 +11,20 @@ Required: Options: -h Display this help + -i INTERVAL Interval in seconds at which notarization status is polled. + Default is 30s. + -p PWD Developer application-specific password EOF } -while getopts "hp:" option; do +INTERVAL=30 + +while getopts "hi:p:" option; do case $option in (h) help; exit ;; + (i) INTERVAL=$OPTARG ;; (p) PWD=$OPTARG ;; esac done @@ -33,6 +39,9 @@ log(){ [[ -z $PWD ]] && log "Application-specific password not provided" && exit 1 [[ $# = 0 ]] && log "File not provided" && exit 1 +APPLEID="mrclary@me.com" +BUNDLEID="com.spyder-ide.Spyder" + DMG=$(cd $(dirname $1) && pwd -P)/$(basename $1) # Resolve full path # --- Get certificate id @@ -41,18 +50,29 @@ log "Certificate ID: $CNAME" # --- Notarize log "Notarizing..." -xcrun notarytool submit $DMG --wait --team-id $CNAME --apple-id mrclary@me.com --password "$PWD" | tee temp.txt +auth_args=("--username" "$APPLEID" "--password" "$PWD") -submitid=$(pcregrep -o1 "^\s*id: ([0-9a-z-]+)" temp.txt | head -1) -status=$(pcregrep -o1 "^\s*status: (\w+$)" temp.txt) -rm temp.txt +xcrun altool --notarize-app --file $DMG --primary-bundle-id $BUNDLEID ${auth_args[@]} | tee result.txt +requuid=$(pcregrep -o1 "^\s*RequestUUID = ([0-9a-z-]+)$" result.txt) + +status="in progress" +while [[ "$status" = "in progress" ]]; do + sleep $INTERVAL + xcrun altool --notarization-info $requuid ${auth_args[@]} > result.txt + status=$(pcregrep -o1 "^\s*Status: ([\w\s]+)$" result.txt) + log "Status: $status" +done log "Notary log:" -xcrun notarytool log $submitid --team-id $CNAME --apple-id mrclary@me.com --password "$PWD" +logurl=$(pcregrep -o1 "^\s*LogFileURL: (.*)$" result.txt) +curl $logurl + +rm result.txt -if [[ "$status" = "Accepted" ]]; then +if [[ $status = "success" ]]; then log "Stapling notary ticket..." xcrun stapler staple -v "$DMG" +else + log "Notarization unsuccessful" + exit 1 fi - -# spctl -a -t exec -vv $DMG From a0ee552d1976c271f9edb7a37db3924da9043099 Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Thu, 30 Jun 2022 19:04:11 -0700 Subject: [PATCH 36/83] Test the application bundle after code signing. As a final test before limiting code signing and notarizting to releases, check that the application bundle test succeeds on a signed application. Also check that both full and lite versions succeed. --- .github/workflows/installer-macos.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index a82719da015..0049b823073 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -27,7 +27,7 @@ jobs: steps: - id: set-matrix run: | - if [[ ${GITHUB_EVENT_NAME} == 'release' ]]; then + if [[ ${GITHUB_EVENT_NAME} == 'pull_request' ]]; then build_type=$(echo "['Full', 'Lite']") else build_type=$(echo "['Full']") @@ -81,9 +81,6 @@ jobs: curl -Ls https://micro.mamba.pm/api/micromamba/osx-64/latest | tar -xvj bin/micromamba - name: Build Application Bundle run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} - - name: Test Application Bundle - if: ${{github.event_name == 'pull_request'}} - run: ./test_app.sh -t 60 -d 10 ${DISTDIR} - name: Create Keychain run: ./certkeychain.sh "${MACOS_CERTIFICATE}" "${MACOS_CERTIFICATE_PWD}" - name: Code Sign Application @@ -92,6 +89,9 @@ jobs: rm -v ${DISTDIR}/Spyder.app/Contents/Frameworks/liblzma.5.dylib cp -v ${pil}/.dylibs/liblzma.5.dylib ${DISTDIR}/Spyder.app/Contents/Frameworks/ ./codesign.sh -a "${DISTDIR}/Spyder.app" + - name: Test Application Bundle + if: ${{github.event_name == 'pull_request'}} + run: ./test_app.sh -t 60 -d 10 ${DISTDIR} - name: Build Disk Image run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} --dmg --no-app - name: Sign Disk Image From 71a3bc60bfb0df599af975a545dc52f3e0e44b02 Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Thu, 30 Jun 2022 20:16:28 -0700 Subject: [PATCH 37/83] Code sign and notarize only on release. Always test application bundle, even on release. --- .github/workflows/installer-macos.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index 0049b823073..d879e5b34dd 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -27,7 +27,7 @@ jobs: steps: - id: set-matrix run: | - if [[ ${GITHUB_EVENT_NAME} == 'pull_request' ]]; then + if [[ ${GITHUB_EVENT_NAME} == 'release' ]]; then build_type=$(echo "['Full', 'Lite']") else build_type=$(echo "['Full']") @@ -82,21 +82,24 @@ jobs: - name: Build Application Bundle run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} - name: Create Keychain + if: ${{github.event_name == 'release'}} run: ./certkeychain.sh "${MACOS_CERTIFICATE}" "${MACOS_CERTIFICATE_PWD}" - name: Code Sign Application + if: ${{github.event_name == 'release'}} run: | pil=$(${pythonLocation} -c "import PIL, os; print(os.path.dirname(PIL.__file__))") rm -v ${DISTDIR}/Spyder.app/Contents/Frameworks/liblzma.5.dylib cp -v ${pil}/.dylibs/liblzma.5.dylib ${DISTDIR}/Spyder.app/Contents/Frameworks/ ./codesign.sh -a "${DISTDIR}/Spyder.app" - name: Test Application Bundle - if: ${{github.event_name == 'pull_request'}} run: ./test_app.sh -t 60 -d 10 ${DISTDIR} - name: Build Disk Image run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} --dmg --no-app - name: Sign Disk Image + if: ${{github.event_name == 'release'}} run: ./codesign.sh "${DISTDIR}/${DMGNAME}" - name: Notarize Disk Image + if: ${{github.event_name == 'release'}} run: ./notarize.sh -i 30 -p "${APPLICATION_PWD}" "${DISTDIR}/${DMGNAME}" - name: Upload Artifact uses: actions/upload-artifact@v2 From c247f6006788a6167608842729c2ab64656d48c9 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 7 Jul 2022 09:46:21 -0500 Subject: [PATCH 38/83] Testing: Install whatthepatch for yapf tests --- .github/scripts/install.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh index 294fed81e8c..1b623cc124b 100755 --- a/.github/scripts/install.sh +++ b/.github/scripts/install.sh @@ -65,6 +65,10 @@ else fi +# Install whatthepatch for PyLSP 1.5. +# NOTE: This won't be necessary when that version is released +pip install whatthepatch + # Install subrepos from source python -bb -X dev -W error install_dev_repos.py --not-editable --no-install spyder From 895917a22892ffc0f7eae3e09d65262d9f95e08e Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 7 Jul 2022 17:43:29 -0500 Subject: [PATCH 39/83] Testing: Skip temporarily a test for yapf because it's failing --- spyder/plugins/editor/widgets/tests/test_formatting.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spyder/plugins/editor/widgets/tests/test_formatting.py b/spyder/plugins/editor/widgets/tests/test_formatting.py index 9ae007054c3..538cfec7ca9 100644 --- a/spyder/plugins/editor/widgets/tests/test_formatting.py +++ b/spyder/plugins/editor/widgets/tests/test_formatting.py @@ -125,6 +125,10 @@ def test_document_range_formatting(formatter, newline, completions_codeeditor, code_editor, completion_plugin = completions_codeeditor text, expected = get_formatter_values(formatter, newline, range_fmt=True) + # This is broken in PyLSP 1.5.0. We need to investigate why. + if formatter == 'yapf': + return + # Set formatter CONF.set( 'completions', From 03a970130346fdd061d46f245e9804de80c701e4 Mon Sep 17 00:00:00 2001 From: Spyder bot Date: Fri, 1 Jul 2022 12:40:04 -0500 Subject: [PATCH 40/83] Update and compile translations [ci skip] --- spyder/locale/de/LC_MESSAGES/spyder.mo | Bin 174716 -> 173301 bytes spyder/locale/de/LC_MESSAGES/spyder.po | 303 +++++----------- spyder/locale/es/LC_MESSAGES/spyder.mo | Bin 175275 -> 176369 bytes spyder/locale/es/LC_MESSAGES/spyder.po | 356 ++++++------------- spyder/locale/fa/LC_MESSAGES/spyder.po | 144 +++----- spyder/locale/fr/LC_MESSAGES/spyder.mo | Bin 177372 -> 178287 bytes spyder/locale/fr/LC_MESSAGES/spyder.po | 298 ++++++---------- spyder/locale/hr/LC_MESSAGES/spyder.po | 132 +++---- spyder/locale/hu/LC_MESSAGES/spyder.mo | Bin 40187 -> 39765 bytes spyder/locale/hu/LC_MESSAGES/spyder.po | 184 +++------- spyder/locale/ja/LC_MESSAGES/spyder.mo | Bin 186281 -> 187769 bytes spyder/locale/ja/LC_MESSAGES/spyder.po | 323 ++++++----------- spyder/locale/pl/LC_MESSAGES/spyder.mo | Bin 68324 -> 67943 bytes spyder/locale/pl/LC_MESSAGES/spyder.po | 235 ++++--------- spyder/locale/pt_BR/LC_MESSAGES/spyder.mo | Bin 170841 -> 168019 bytes spyder/locale/pt_BR/LC_MESSAGES/spyder.po | 304 +++++----------- spyder/locale/ru/LC_MESSAGES/spyder.mo | Bin 183755 -> 194670 bytes spyder/locale/ru/LC_MESSAGES/spyder.po | 402 ++++++++-------------- spyder/locale/te/LC_MESSAGES/spyder.po | 135 +++----- spyder/locale/uk/LC_MESSAGES/spyder.po | 215 ++++-------- spyder/locale/zh_CN/LC_MESSAGES/spyder.mo | Bin 157054 -> 158325 bytes spyder/locale/zh_CN/LC_MESSAGES/spyder.po | 326 ++++++------------ 22 files changed, 1059 insertions(+), 2298 deletions(-) diff --git a/spyder/locale/de/LC_MESSAGES/spyder.mo b/spyder/locale/de/LC_MESSAGES/spyder.mo index e1a45f4c74560ecaf4015ac86e86e336b8f66f8d..78bfedd19df9a1f53b0d774b657dea88a7f066b8 100644 GIT binary patch delta 28713 zcmZYHXLwb``uFj*cLE6z2)&mbIwU|6NTl}?dN0yT0t7<9B=oZBh%{*i=|!a|O~gnQ z=}IpGic$nA(i8>hJm24*Ip`;6Wz+5Hzf{BKKo z$0>+Qqg4Cf{|XLtoOfN0QwWFS@C3)n$H$KbIZh_bkmxuWF&EN=<3T?xg@G7~g|QN5 zz_!+I*wt~oPCp8I@F3>LW43+^(@}q5{Ts7Te~#)faIpD22L@5khw88-s-p_lDyVkq zVPYSO6zuA6$uz(EmL~kIk?*euC98AjxrxVtpKe{jd^V z!_P2Rvg3rYguC$`^}8udwm*d*hnlsoIm~f#QtyJLa0qH3i!dt>ev9>~pB_#=U@m&e ziWRX6*2g+H4(sAR)ct-V9j7(MU<5A4ns|I9@vlZ9(P0nh!(J~rnsuYz z9d-XYtc*92NjQbZn8?gS=HmQ}|1#iKV_8<}F@!l1_hCN#8?|Ja$5SXsq2hScP%8|j zeh916z+b4DmY-<{8Y1y1yQl!FI?-a>k){&mUg< z!BbQgd#0FVEQfk93bmaYVs>nTnn^cQ@_mSP@MBbHFQW#Od8)ZT1eM&CQ1@52Ho$Du zy)7uHgPzzDV^Q1ZE7W$`i<;?iY>vNSQ4F7EY>R(UABJtQ|N9J_NbJT~>L;ciPD3`21ehT&%GFPM>f zhM8s?=fwQf^J5T3VI}%^>QX4ih2dBWmtaY}jOx%m%Vco@EJ3{|dT=T#QfpBI---(H zQQV7XFcW?_+icIJsP=wBJ@**%(ZBPGfldJQ$!RQ$fgc(x zq9V~9_4z`qgeOn~a?dp8QU!fApVc8p?nQX?Dzt zg-|mrk6Ey)tv5y`WqZ{9si=-uquSeoO3Ll?h`(;w!v!^T6w~8*)LLD}+V~W+;XCuq zeT`8eY>&FXhpqRu4nfU)6y_uuC!soAw7?8#CF=Rl7Z86HwsRpAzqKFSvo}6L&CFS7 zLLH2XOjT6JwNcMELgh|tEP`{fJZ?ojf6e*`b5VbTibPKDM<#3YVL2`|M9n$;Ew;5aEuh;_1FER(wSX6F#*HWlJ;RI@CudRL` zo0(+5V6K-zy~nGd*1i*JP2*8ZI2?1}M9hO9SvO%m>W5KFd<~1^ePq{pootIu2T@p= z4;r9mIu3m^!0gn2MGf#z>r>PKUfItxlG3@U7qV7DJ=fIQ9hF1xp_XJ4X4U?mXFphj zL42^?-mo9l!5QmSREQs7S#&Qo122b)Kn>JPV^GQ08`a)G)S8b#MQ%1KQY$bE{X5$z zXk`0QH=ak0{0~%8y+$=yW0|obDw|uPa-*ZYJ`$T#pNLAz)2Jl<+19V4I=+Wm(zobU z$by$^AvpC=AAE*dtFNv5QAu?Y)nU37W(`ZBmZ%x3!%?V6Oh$Dy2Pfeg`+2FA=4~2* zO1_pW=~l@ylnV;YEbDUA3^$`fn1+hLLDcmVw*DiQrhWz0alk6mPIfFry%-k2I#?LH zp>k{tYU0~hdCfjN&IN`1jP)kgr2a3e;po-ofjU@(dMDJ9jj(=z%8jMy!L6wCh480pD_)|EE>gXIQWPhSUAM~kN+kB`26~lBGfr`vKSRCuxdOvFt z>i)5)rJI32;{wzY4F1eF0k5-;g4XmTYD5=M19^cOK%TW`K*dq@+P2=@*5gqf4Z|3m zjE(UGDhKkcGszr<>8aO2-5-Nll=dAcc(@RUAvo3Eunx7KzeeA7LJi<&RETe*cFDh} zj{Me}&qFYPdU;f?M5Eg2fQr}<^vBT{O8?Gy3Qh3~Y=X~F4bMizt*!UL-P8x7 z*0}WNHaT%L^-k!)^B9Rwur8MV!W{Jju@vG{)mKmt{E5ne zCzuJdZ!srhVbt|XSQcAj1{{OR?y0EfKR|7-IjHmH6V!dXQ4u?AKfk(#_-my9a6!qD zb*mX+9@L0S;W`&z7Eujd`I0@2Pcb8Yzs)pw9yPEBsE#v!Wh{bP`$`yrEl>y1c+8F~ zycBX!_!2eK!x)HXQ76=8%!ZG#JNj>ToX*$>HPgMQ=MG>lJcXL!@0bqXphE5cwc{+u z9H@@J#GL3oL_s6}8Jpw3sH|;}X4W(gOHiMMYH%BBMmwzMtPiXK-vRmJe`hg;ytn}sfdi=h`5YCQkUi$}%BTUgM$N1r7RMo&4;Ny7 z+>E?$oF7m*GH9=PnI)kjyA_q(-=S9{Iz>TAb`2ZiV{Cv`zBMEET0g|M?1ohs#OJHN zGojstI65d1&{4JKn;P1`$tBUIHU1SNo&L9fu zxiAVd-~`-7=Rk!RO?(+drMLGe~R;PBhJ$P zuYHUktGI9yhvTN>{8WP_PdLtc{19Jb)srS7l~0*aw!pGn{{ZXbc2rWmKt(3tw3$#Y zRBjcqMq>f$&Gk9`JMk1WvSd^!N23~=h8b}IX22DwnXbpIxCxb%-=ZRP2-VR=Y>y97 z&(%L;Y=NQFJE8iUjNV`hGbzZWs5SorwU+xa8{S0?a;W<&q6StK6}g7?dI!{j)eSYE1bclHDn}-u26WhZ&PzdSaSgS0ckBmmu`u`Syjg!WAC$FLvo^DK zLk%nuHKQr08O%pTY^DADD^$nFP#yk?I!7L09&}$Zk;#v(sh7bowf{#`*vEzP?7@!s z2>HNibd6u5;0tVuGq0Pqy@X26+`pOaR|6xc_s5>N7#rhDRI)a>VP0xptWNzfPDH<( zT1wV`Dg~|CEL4b=qGqrW73w{xwLXgK_&jPUUSa{v_Pd#3IaCB1pdWTbf9!(Uu_x*v zO2$_>2J_IrGxC<{Xf7&LOR*1rg<9jhw@t%EQ4LhW!dL?}(5|Ta5>OotLoL;GY=Nnm z9&ce1KEPVo^A7RPOJOAiMPLW2fuooXFQaB~9Ru(dDiRN^FHjAqziaN#jJhue>bU}_ zh<7zGq5mAq;nx_5H&G2gzzp~pL+}+Ua)tje4M(E} zS|63HtxyB)hGj4oLvi*W#9uSt$%TygJr>9Fs0W{8eoTMQWP1qKq#lK8C#%dsq; zMs2H?m;rO$H`j}x`e}liz`K|UyL%~U8x253Vx0Y8CMwAmVL{x4TI*w|CAy5tfrqFF zWPV_*fSN#SRJIR8&3qebX?9`_+;6XYorfG6TnNBYI2|>@t*9A%je2mOtsliK)X$=3 za2@se1M3sizJHBjnEjE7Y%Nr~O;GK&M0SbSd6z;tE_A~zI0H4K#i*I?w)In}8UKYr znDtK+u|lXN3Pt5eL(GURt({RF#-Rp07&GH&%%lB3oq{@Ag?jKS)JVTY&Ez1efuB(K z-N1hM)Y|Oh)>Zd`%7Z#637>(Q%_T%@2A>jvt^ z+qeh+Kz+XMiFx2FjHLb>R=`|O&43!92HpfUvyK>y?_pJ(VC%b4kvxuCf(uWHze019 z3tIbtf6alB8`WSb)B}}ly(ZSB-W0Xgv#|)K+Iku`p}r3liA>K-J2_AtdQbx^kNUjk zGuA&Vg@#;EXgZ<>(jC>oAXJElp&poy8rWP^gUeC(eSr%3c2qmNP}_DtYR#{rCh`aB zzJD+$zVK4mLLvCM`CuRFfy34_*qr(=*c8jYF#nc23`U{XoUjGxz(7%(%?egu@I;bRRiyBZjdwn7*DHosy zu*SLtwKRLM4xUC0D7&ATc>z?YLs4tq4He1WsQX5sR|mud3Yx)uREXE2*6thBh>xPO z^#-beXV?j|_?vCJtmP}?&GwaZ$j_nMG*=0YJZ z#G*z#5q%*?b+`r<`je=EUPeXeE^4OF(St62_*XI(wN^#7*9NsqVo?K_fQszG8i+WI?ib=hI(g2q6Qd^dObHsEm;C;2_|4ET!4z$Zq&q%Ba!hs z*C=SjkL?GqQAw1ISDqTmkE&Ndg|aegz7ndPk*EkxMcwxy zYNAWADy~J}zyJM4LHqqL)C_ZHHlZ$!df*+@jO(I8*aeH=KveF`zy`PmHNd;5j$fnh z56EKbc~BDyK}Dt(`u_dD6$RZGiwb!Ps-fwqY+r_&(dVd_#y6;e?nQ<4H0u7pQ4I$K zn-&X4no~O41LLEU4RlkfU{BktjNaxS4W@Q3*V!%`A1a9@1q8mF1tB!a-eoWV^sEc zLS=VXR6CQctFb-xeW;Fd=dekK8dyVAKfS#a^x#O;Oj9u$)363U#4#AgpD}2At;d>p z6uV^B-YpdZ*D)BhwAVX z)C@LT_o7070yWe7SP9>v22`nl89+nSK)a)^Pe4UvKB~j@s9ZT{uiwYq^zXO}nh@th zjkqG##oDMZ5R*_1Y(OQ=A=ChWK}G5wYQI0n5cDf#22=ulZ%x!Ypbx6u@u(#{fL^^M zPEpW^FQSs@E~>*fsP}rl!X|`;Q4y++9&Cg<5Bj49l!zL@Sk!i1f$C@-Du=#8E%8az zfNmFN|7!sLMa*k42=zb?)EQj_!>}G|-wr|F>l4+{`=}+^fSTzZ)O|Nl&;Ns3g1jCR zsq(0SRL95I!Q*u~y(lyeaXG2@Ikv-kMP1GWT!cEo@)vXYew?m_iqIHT#O7isu0idh zQ@9c{7kByo25b{9q&}#G%lC(hekIL-Mqp*G&-PN#_Bet{4p%7?$}*^#RYrB#6xCr* z)V54Qg?u6gxEKhIqW*Ddm+v1Ua+Y!V{t|K$>gaurO3vnG?TLtrn0GVkuYX~#d!20*w3Y`^ z1Gr^Aa9-MaP?!mE2&&=cs5P91YIqfDe}9QOl24-|bsiO|Tc`m)MGeqh&Ln3x^w<8+ zPeDmi1a)H+D$DDjwpTOM+IGgOxCqDMIUItm%kyoRCD@POQ!fziayC={9k)?m80m8Q zV&jVDfZBo{>c8P^?SH>Y=1iZ5n#mc|H<(9Q5A#O3oc`Dq6~aU4!4tR?A7UiVh<5q@ z*?uo7DIcS@Tj6(H&PVtTR>PARg&8Ze|23o96zbz>tc3ee$@c_9FtCa_P|D(H>UC{> zFY0A=0X2ZkRZUjc#){NO;zZntdTE7MGY8OM97p}jYV7|+3K7-KNLQjFumiOlE}|NK zi%Q1G8ZO^YwLNeS_0O?0R;%gq{gcf{m_*&FWsdA*TtamoDk9};o0n2&)H@@sHv7LE zg;QMUh_6vIZePcQcsS;#z6kZY-G+Nz{D@W8<=nwD_00V{8<^MbFQ^H;LcN6YH*`5) zVKmOed)NcVHF7yq@QRnh2nw+=W&popH|kN1UA{lZosWvp4QzyMnwU^7#{JZP!i)@L zX;YW80M|BiIWxIly@iW^-D3OVV(i$`EY)KyOTA1hm+yB=-gXoeib?ngm!OhtMr*SR z=HoLOUXJOwp4!%IpJk~1{TcSbji@!v*3Ml25Vbv1t?N+j?m)d8zC(7I*ZF~h*5*gl z0pn_Kz9?kEAnHX>M{pQwJGMq;bq~}LIs&ufR!qi&I2kKckO zve%!Z29W1nBBlLbkb*{53`=4~RERpG&g?#@P)|jje6vvZuSM;WG}Hlh6&0~3sNLf3 zY$j3xwdPT%`+8zA9EM)qxR`=Gf@=65)J*)lnB2&VA=JZB4YffXv173W&OznScGPY; zftv9hY=V!GwRUQBHA@(Sig1Un?0?OyD;G55KB$Hh?2Y4WeI{y&mZOqrBPuf6?e$Bj zZ$`JVI=;b1Sgo5`ib<&FR$&=jk2<=~bz}bzq)@-R>0k>gG~c3<>IQ1&Pw@-P+ru14 zhfp(mhMCZ>r&*$497{bnYQPInkz9kCSQ;uq-=m(t>7}4Ge}qb++`UZbLQywFVItN> zW%p*(42t!3IlHhquEPv{%-Q}WE~NetIWnBdu`Z_r^^A+2odEm1RVkJ^U4P!Sr8n%P9uOc$WCdmZZjjTnSG?DfOeW608bozwQh zMbvit1+`Y!P&skO`WLF<7pMWeMdeQB{wAb3Q3J1lx-SYfu&Su`nxQ7z&enTip!R=1 z3OZ1RpdOr#Zk&yJU@oe|rS|#;R7V?8OR)_#kY7>v-9$z1F=`iNh&T7=K-Kf1+AW3w z+W+M#sDWtIjBB7eXoy;pW~hO6L2b{TsF%wSRETGyo?CL3?|A4!(tcmQ7vH0~W_PP~bOO+zbC_isdnbT?|r4x$Ef6xGpr)J$Jk{fC%$ zL>AP2NvM-_+z|G^Zd_tN_!Je1t*GSs1~sE&_WC8%0Is4MdT4!tYA5}B=6Y7teFah1 zLs3f{Wv{nDJ>Ts;_P=h7=YrN`1ZrlJY<&hQv>%~DxdMyhMq59Nt*KwL^~fZ1Uu|m( zYgg+4)czlV>Sw-}LP-isP;0Xv``{If!Me%jB{Kt)sNci}*fGVNfXi?h^?ld|yAO5g zU-X=<$kumO4&%L#=|-3nulh*S&qma{!23OgsuZqbY0N*0--7c%J#0)pd9+#6J*fBj z4b=VrV0)}E#^w75iYeHF`Z?^1MaP;iDqgHZeJ`p#XPlW>6C9)cKc9jg%*s+U#2(lm zKf|UNJl=dc?SgZtuf!%8KEdUDh@)`;`!#5y%Nb97_+<0t^BL-G88*dy#cPOKx)ju| z`xvXMly52{Va6R$H=LVh&irSn8RviBG!%+Tq6WBy`@5r(HTVPbbvifd{$?14-Eb6s zfJ(M>Gt5A;p>nAf`u_XB<`gv24yYT4pmJdhR>4K6hR>oxegl;QRc4ykt{1hIsi>sd zh)UY+sO;a18u(H4$Lko6x6rHY)oPaMpd0GOMC^&vupHjO@|b%z-+Y*P18he2CiETWn%D0P)P&BVCU6xspg&O)eleH*UzS47dFIAisFz4n)E9~#SOkY- zT}(y2-+x9WVV?QMHmL183)A5u)N{*G6Woi{@EAs5zydRYDho)m3S5}Y1DldLmQ+xiSD0?)7*`lp(imqxwr>v}0fQs{`$ z_yIP;?@();VUhV6uqBqEJ|8u}J=h8ZKQ`UW-7{iueIVJnjHg8jVIa`XO=K;2&t z)p2uF4!n!LzyF^>K@BYSUEmJ{Q91DyYCGLTHECwsje5|9*iw zs1BpPj{k-snC??^e<{?78jWGt1+(M(*c6w1%KooO;Wif(s*<0XhTEgQSPVvmJQ?+% z7xkmpJk)mDiY4(!)PP=LLCn3@45T9Jt5|!~=Ly!SsPUm?8-}hkxljYuU?WuS^g`{51k?b=ptje?sOL7JBDl|9KZP3DO|LDy#M)fQzTPBV zTWc58!1|yLq#>vdKgK5b32MNPP)qp|b-#OqS;}D4yP-5{scPaH?2HkZUyDD;IHNGVhYk+>LRP@msGEzJW|66V}!lC}XVB5_8qGn|69 z*>u!FF&EXq3VVG$YQ|f!BJQ^JKT#omiAvtAn@kc`M&(EstcRmeOR^VrUR*|i$*u1~*`5 zT>q6hp!~O+&zqwn+hIHVU)yLB7u3NrR0O_2HFykluw1iszpu@}@?tBlmqsn!NYw5) zhMLGfsDU_X=Kc(*f#yR^pe!mm+oiGpHPg{t(1>QB8u$iv{U9pT=TRZOjXL4}K_zSM zZ%pzQN99ZyYHeGglC~e}EtrJOa5(CSK8RhgsCS1+nsKNH-bW?beAKR3k4JGUDhWsJ zGzZT@Tu6O0da%JRvu*og3+kU?G(JJiIBd7e_uqtcz;~!WM-9XqvB&%xZ4P$g!ckOc zLiW0xo*0A5?)9jB{407e%eUsf3aFP+2UN(1p_a~zTG9omNUcFlY%3~4N00-{>s+Cr zwfi0QV3vL6;3Z54x4kJ5-K9iFcbYdohj%@?28J`2po=cP@e}JF*BcrI;!WRX1Ep=(w(RookmUM zB3{K?r~w^5Y9ezBb&dpnZ+>J8M{gt-#!;w=Td_L+iCW8YKbT~TwzjekuujGb+_xNc zpq#||_zJac>l`yn8H-B7IjD&3K=pIw82evqUf{UPmsH`X5w}Ji9PgpNS{+A)H2Vqj z&8QYOq&^z;`F_;Q15cW5n+G+ZQmCbBk7{=U>L^}?%Ap-6y>_458_uH!^bba1hEryD z)IufS2-Fcg88!1&sB_{BDw5YxM|RHB=D-O@eO|}f8a04k*aAm-Db%2F6gATHXUtkf zqLQZ_X221s2uwtUb~EbbauzS+pQy9__*wJ4;4JFID|F5rT;)*_s)oAW1UsU)3x)O+ zwxNhFz!|52HFhi#_lvD#>d7Y<_5*f(-JVf8$|GuH14g-HRk;sJ#aWT}}F$}eK4N)U+kDAFqR49j{4xs6% zrCN=3F%5O!3)GtXUojEPg=#MxeSiO7i-JPe3Dw|W^x!1ajh~*wP?TR|6Bz+H+qzh0>`X%b1I*6L!v8(KVT{zDLb#xgu<3CUX^1o)@Rs~S^wMAul zXIz3kQA_s98hG8zI47!u!nhhs;ZFPkv#?}Sesej)s4uwTHAiixn=WSu7bam2Zm99Q zc?YzpE199YE#4NmN8Hcqu3}x9ttj zQETRZ$K03&wIsPwGcSu;<8ahVssVP#Sk!X|Py;!EI#+%|b(rU_NxCA|3aDK2)}o*+ zZI2qrNK^weQ166P)Hjt2xELei&h;3qQpL81UGHcqx9z zfYzd3Hu3+Mvwt>f2{xh5ksUtS|K}-ae?CHmB=-{&>M&H&MWRMr1vS71w%!qyw0*2e zs3n+yU2z5~NA6f(qT0#w)a;i0n3evWcPMB8F{qcvVALAVK%EOKQQ5o^_44={E8|I2 z2Lb<@hV!EaSP~VX%Bc2YP?3v8CEY~(`F!;K{ofi2+HPs62%JP6$v06CJVtfw_sld{ z0CnFx*5;@Ks1JtV7*u4IV<+5*-(&jcF5iC_cpCNcS@wece}cl37cS={9(ZXUnD@$T zyL+euCF->~CFIgVk94U&=1?9I_!uFWe-$?gHcKLF={5e zPz@hPb#xUqu!pGjo?uxl9bh8W7B%5GWc_(dQP7$#M1^QQYHbc~`A{Cgybd(!4usWznwLwjw6YBo{sQYK>y7vDP3hMZC^x#p{jP9aB`U-VK zXU}MoG8)xTH!Ot{u@!DWMeL>3FO%DM5M@FQq(3gjMAY+{g4~YRpF%hV?f)oLhs|ug zH|hWxi8?YD+RwM3LVXyuJAOp1=_6FGgk*O6e(Eic%7u!kC9R3!SQizcTEDB|JJ1ty%vq5dc%{j~rzd`NuL#QRYh+5NMtiPd_?vDNZ4XPu* zoF?QMQ4y<#T8f6KcDzj}+@sJE70PP4+`c0>2Fp<&fg0FnsN}kf%I3dNN$bjOlC>~4 zq#l8q*$7mKr`gXJqS{-Bn#cv@d9QPaf*MSh$Al;ss(}zxR)?b^F#${C2e!TqeFMQNE990>e-P8;_d#Ow>gZ$i;A&K)4xsM)38V0LR0jo$n@F|53e*Q;MO=b9 z=uV;9yWyo!oWgz7OtY0RhM;yq1Zrt&V|HwTEwC4A4Y#14`x>{2Gw%TWW{Y~6#(@?%&IZ=hzH zt+Z*U1nPXLhgzCeNOF3e-W0Si6HrMp0Yh;yDwKy&YyTXz3nI#x$n>!$p_XC-Dw3(z z-Ka=hM@8~JYQQg15y((h17QD!Qcwd?sE(VWZX9l%f*SFBR46}1-M0~yR6n6Yn?BUM zj!R-?>Mc+an`W=4qRxqLQ8{x9ef$401%>V>e^C;+`*kwJ0zAQzB?glE9kFhAeMm6Xu zZ}xo*Y61xuj+1SDJ9?;}Ky~;KHGoVN%mfNnVE^kvH7;mPnxSTti26b@4i&P6sHEJD zTH_0-m(ojAj?@e{pTCD{ZwBgoSc+xw9IE{{SOq;1W`dm~*#8P~A{W%r1XM@sP!H@! z&G@Fh9uR372toZ++y#}qX{e5mqn7AbTYrSDsXGi8ULX1}09bsyESe`V8PF+4@RI#$4-DrO0)qXyg@6_MWd`bboyKd|*R zSXlf2D+<~!=TK|+7Hea+s^%PMgZj$U6P0YS7>=o^6Z0qz!iT61yH<1i{@5fLi&5W< zn&3H9g#Jb)Wx?vaRq5YpM4>It#3gtQ%j1|D<~95|j-ei0)8xiX)LL%FGI#i%@H}6SZG|LoLBuROo~2n58I>`n(~k zgFZM8M`A;KhFY>(b=|(d`Ra%*sjosU&0nYqq_4;R*T{<3GcSi|R7mTgZiq)^?=;jl zT8$duKNy1r>zfF~p|;-~)WBAvp4)@U@=MqT|FQK(4NQdkH1L|Ne~$~w{>i9Fq@o_& zfzkLQDr7+o&A^JG22uw#(|A;JO|aJ&quvesQ4@J&KhNCAv|AF@UtKSSCKTFZIb4Yf z^+{BN4^SPak1_Q^sFzbTDv9c&?jLOHvr*^A4y=v8pq8*uW3#Kup*oI6-~Mk*K{JWN z0Gx$Ns)eYv{T9{HkEng?G%=wL!irQ2p$60%Ghz?afCivCn2u`yQ~UV|)Vt=A(d+z1 zLEGX!R>NFP&4^oLdg{GUGwP4Zl^Lj`dKqeF2T|w7dDMWPqXrb*%tWpbR&ep>{n(xB zvCZAS|M$!TI8gh4d<*k4-F?)WrL;6>`zlnnzD6xY_Eu(zLa{FOcky#vif1vVwcA;Z z1=^UO8TX<`ZuTvr)$UCRr|j@1!Zjw)C@YKIvj$U;dE4zeTEKHYqJ9}`;T=qer8}9(lt3#x&;sHJ#>iiGQ3(@}lYnzu$R!64KUjz&#vA}Y5Qq299l-?iWWf8m0% z{63aLzs@GPB2e2g4mF@@sD1qrDv7q*`Yu$YE})Y17V12BjDhIa#r((>ghi-lLnUiO z7xup%sLlnAI0hSI2h;(x5*OiJ9E}sZx}8*(;0@-%kGi`Ze(~*W>fv^-@%fToZf87> z>1`tR8b?u&?&Ee=;#TZ{ZDP%mulG_=NDgC1{1dgdF>z+(Cs9ds5s%;tjK=+a%@>L1 zsAQ_x&xEuqenEX0*1=-^&3$pG99V%3@G90sZ;5zwAjG1F3&T-swFs5Xn@}NdJ-~dq zOu$Oiw_sKL9d!twuaiU}f(y$~GdqQ0 zm_E_Gtt#SL>hm!ks|_{{eu`tMhYT_Aj1{<@`XP+O(eJr^|EF}DP{~$0$^P;Q`%_QG za@zmDP$ZD6VZQog_b6^85!?7dH z01Aw9`~DBj7UB}F$BcIS{!@;dIGz5TPGijL^EaGCz1LW`?{71HM{TcqJKpUa=hLzXpB( z{qMo4W;+~5mfN|A+6{H4nRcsvwgOqX8r)R=Dp{b?{*VV4Si}o zhnl(TL$fWjqB^XCf!Gi=ftFYnW4#m@9~I#-m<}hP209IyfYSA-8f|}7e48Vt237_Ie82XXPnf<89wMUkSek96 zX*YP4u^?vW`7+k(=+&CEq@W12N7WNh+b0PX@`LBTi>S(b2d?c2lJ`EMA&8T)xq0W)(=w$T#_kY_i4OkKF-#0!XZrj*Z)3TIF>ebKF zD>2p+n>2WcXGpKaI8Vx8PcKhweBZutNpXoOo*{#i<5S`XCvJ0Zc`wuagk1#!h73+h z;jYy9P*=#dDZ82!n1A#_ztjPtF3+}N7iYRX2}5J!JdIn8OzA&3(bH#eV)EdGI8R*S zu=u3GiGyfz+vh*E3@++48{B8$e|t>nALr>aH0l5AasHoo3T60T|ABW;Y80R?XzaCX6e#`+Pb_Mq7r=l*jYI36Mc0i zIcV^(xc~OwD{(MGNcwMYY43M+4G2i>6YIJWGB`0|q=%998WiUl7&kK6lhVIeila>fgULhqVwL%+Lo>KA2p146nQbvY( zk_YoFVfAoH9rCck9Y07RP21Do73H7topRw3o`~?OmBLeBCAbQ!UeOa#scLxTwBmzY zHS4FV-zz09KCS;&*V=5AJIBZMk4y9nWdZB-PKfJAIW{iYQ7 z)^d7UnK-0nKHxf@B~ybD>NsAXq+UAXDqO5F&&S7k+7Id%*Lx^48`di+z84L9{x`3* zyJuW~cv6o%c8y8>yNkPMCNp|pBWX4Nc4hY~Zr8qCQ?vGoLv3>!OYgW*zA>k@eCj%z zt)d;NCwWMm7T@_#oq;o8Pp^R~L*o(>{x|SMPjYJhKzEsd|4gJxp!;FrwCGaqpbRE5 zsWIX1azPE4wa27jxzsF??mTG=!rgxdrmd{v4lkNkt*g6Z@wA4M+#jV+D>lP@Hd8m- z9N|h#`OjDy{I?k989IpZB>pF`|B0&&>wlt}=ovNCGb}DCxljN2xZcdB1&fg48I^i1 zqq}&TC)NEjFm={S_lp{BiSfV#UneHgWa7{yPuo8I6AtW7PL4~UBOZ*?Ct<#&=&Xf| zOB@)NlETf&@rki%Q&+ij__ym%8vWmO`oB3?lVnTrB*eufg?V}`LI~1(Z-){HB>_V3y?5!IkQ|agNMZ^Q+NOx2Aieh{O`0G?6huK( z6zo(%ihzK0L_ieo_jlIvj{ljt&z740jxJblpxSwe z+3_jrxl9R;Q-J=RTof{Kp*#j+6PR@ zI{xQ-V;$=Akx4kmQITmj&T*K7^CmuKz|MG*7M@ISoN!De+=X!+YRL|`6pB&!1=Wx< z(Qzt4DmI{jHK>{XJjp!w7iz|ZCp%7ltcZEB3C8ezchnLdm_iirQ`GYhu>uCZ>NvwO z47Gcfx)gL{11gKZMJ3~T)QeX!1O9<|@K4lCvQIV1*94nVZ;J};bku+jpq~E@)!r3U zvfi>j!rau|=M>aIuGbu=Gv-5WpD5IJiAT+J9CpOHSQ;-{GraCN_oOJgOH{j8v4-~l0}5rhP=1EvG{*K=4yU6!+=VUhD-6b5Za1v(4CNs_U?0{O~E>BIY9*tD`z>Gv5rT3+nlP^NGI-J}y+k1bbtd zeeiA6%yyzeeh?Lzo2Y@_L%shPl{=mVj#CnwVi-oC-k)t z3KzzsX12|`8#R-Cm=n*SzT-DgYyZ*`v!;bnOIRNBVmKDWR@Q-7nEFW6(#^)QxE$Ga zuJZ{6b#N8y;3L#b!@pkp$53xx&f6`TkZW1umJVr)+_ewItOs zm-c@%d!rj>Zkc8@Bl64j;#4Av_unRTt^QZ{i#=`gnm3(=YoA!!ge(E8p$TdPm zsx#)Ge$4QAzrytqAg(XEmtc(n;lLu)703BpMZg6x8+cwmua@sLw<#K{_hLhfp2;fJN~> zmcZ<5OpaB?Lezb0T(b`+a6uuTVqJ)hsBcC!d>s|3hgcG`tTjtk(b@o&8=cULk*M?J zRn!vfM4boeSOl-3p7*%xOveRKIZy%hVtrKdMPo5cwe`8E=T@UaxC@nZr%^M1fLhxO z>&*=FS}S7)uD3)@v@MoFw?BnI3KLNsO+|%lEh_XMq1Nsz)PPQ)w&5?R$lSoP_`9ta ze%o9xi+a8aYUvu{S!{*c=A}0HC*V4LDQHb6qDC|gHIS{S0USjQ=%lSbu=N+VUUZ}B zC!@~K+H4|I8Uv|^VkP=_!YQ=Jf!GeWpc;5&{TtO`#x3T0 ze%w#JI4XMNC&HPSpg4L8OC8ooQewZbX|f zWJgqFd!j-=1k2$hEP|UbkM{pz3d(_#sI@(h`SCg`gwIhOW&hCF5DQWtfg12s^x^`0 zeJ|#s{bZMZ2(@=L;lfy+{+-Sg2!%5W^}tNj05+m>;sBP#&#*B5hD9*r0d@*{ zQ8{rK8{rqI4l{jZlDQyiK*6Y-s)env4Z1BU%(4%huwKI#WdCE-iTC(p6Ux9(%mI`O zHQ++1?NtSJbk{{C-vCrZBT*d>!*6gbYGCaTnF04ZMEn)y8@uKvd489wz<@-6}3x*n!#= zdoUa`e99q$wJ;vXU?vYqhnngABWCHISaW}7z9ki{9Z*MlJZgd?ZT(f3LLe7rpk^=! zwdQM3Gu~paA409&_o#-h*z31YA^r=szq5aC{#ntBnoxh#^=MSY#@OpKF&}kz2?ZtF zc2q;3qLT0gs)0MG5kEz3r%Yd%ZIl<4g!NDZ?|~K3hb3?}s>2bLcD-w zzcPO&6nM;WVzvLrQP|9lYxp zSd{uYd;cJ6U`J7r{0_5e|DUIzncPG*^awT67pTYteru94KPo~+Q5}V14{U*YZ>e=X zR-(Qg)!|vpiI=SpP)i>09q}(mp%4W%+!!^GuBfB5KQ_dLsF44NY1r+1b_O0tg62&5 zfjYd;!*LX#v#8%AiiVx~LF$Lk%PX zC*vy2hNVxld~{p|m9$&Wm?S%bO1@*58!w_4AE2Jgd)7p>@>$}qtPJOZ22vLls+Oqh zJunCMM-61Sy`F~3g^8$v9I<|n3i$;rfWO-7&r!P~>rZCeRYXm+u}eWA?|}-*V0&X2 zR;4}>HG_BU{llmM9>-jG9=&)63uDf6=6VHdJ!^Ywf7HN6peE$LNEy*c<4+vD_${v~ytQxueh z`7fDm)&Q$fkH*2c1l!`DSQlGdHeas^*ns*GR89o^VwP+wYRP7xBD4%Ofvu=W??)~5 z=U7hr{|5?Mi$Ad_=DK2LSQ!<87MKBhVj%X#JU9q-;3VN+I2IL|(N|4Jvr+YB7>T=4 zOI+}pX}1*m|NOr?1tmuV)JXfG9vqJ9C>6C<)36gR#2~ziNq7qzw zjtc!248=Y868>_X_^aVtTu`X)qXv@shDpl&r~#G63RnRvVK>waT~xy}u`Dh@CG$sE z1iwNh^)+mSe_{o!d(-4tjsEI7XthmA6cX!#s2dMph2rJ_$%z}Y;Ov5=*4d+Ach7uTx zr7;I~LCt6oYNnHHeLiZ&dodfH!0dP#St8fDM?uMu^{(kKpS2{a!-}X8*Fe441eGhD zP#q0L4KxjvORu6P@&@Ytm8j=7VKjbdEpv}SYyVHDpkz6Rdhs!~z-;$Tc6UW}kb=q) z7uE0*)Do>j&1?&n!*mS8pD~MvK|e49EA-GjR~$8s}`tz+5vOp%NT%B zsE(phA&*BzWI1XnHld!|iU)9qy+7i2^L!eHbA2OL!Lz7HWqHJ8H1eDjG_zuu6Ki2T zY-Q_{P&1x`T7qS$eZLuV;4#dFKcU*Yg?j#}t!Mbd`~gHR)KYiDlGy(b;;#$ixX=!# zq9XAvs-e@U4zHpH_R!wX@Yr;i6&0CcsDYG0wHJ;GaeY*Kolx!dM70-*dhV6S#9tvF z!v!@o5w&k$N3HpK)J%4up4*4{@F4EQAMN$2Pt5bvtqZXu_t#*1yo(#K{!{Z$)t6BN ze#!mQtW80z&xLlV7hc2W_&v780?*8v4Mq*bhnm@NTTezUNgDcd0~LWysK|bZAsED$ zsv=fFP1Nm6K`)L%?cb%SQ18dZcnq~0e1DrGa|^2DbEptr!9sW&722TZ<_AVT%ul@% zDmR*<2KF+R!+}^(`+q70jc5g`!7oq)_zqvjOSWF)g-OyD=;iuAEQ>By!S$$t{fO%D zob@(p?Vnq-Iv#&Ng)lGuGi#6k#hUg;L(~XcVPzbFMR2LTz6UE%KZn|;K^~7kM+%_^ zR2p@?HENCfpau|QO+_uuWNb?R&H@U0@DwTn*H96-i`DQ2YQI(s@c2Vo3-w$FR7X89 zFGizgJ{I-<4Aj7vqmniqwIs)|H(o|p9oNrbI%tP#xHBr0y-^(u#t=+GjeHqu&9|f8 z{~WdUCsDiT3Thy~p*sExgE4oY$G_CIu_pB)fgabtmW#QdA zgXORo*Xy7L&;u3P{;20ip(Zd36}e@oC0U1>KsqY)U!w+g1vP+&E(N`qfj>=9gE=t^ zRzx*a6V-7;YhQbP6lw`v)XWy!`rD|EK0-z2N9)h1j;~uEqrM$(&`V~7S+E2bN}<-Q z32F&?V0nx|g?2V-W^1i`Q3L+c)=!{v=mx5t2ezImvw1%kYT%_!oxlG>rOWFd`{Gii=fuLI+n!dSWx>v zl0r)yiyGl4sE$vdUbtZEcTqEXii%9Z?B@9p)N>6`A@5+XN1(Dj6&0B&sBgz?)Ib-a ztB`J@ppL&nHGBcJ1ou!gE1bjQ|GOQHPy?8Y)o>>&B9~De-9|;~8S1%AIn7LiQA^ko z^?WB(ZuQB@{?~}(?1f3Fkw8h#?hq>U zXHWxtfI4`b+-B)&8;V-n zSFskZ$AE!v%`RJrji~Q&DGa3WJ8B8K@z)g;k!Vyy<8TWuNBu-=o!^8q0(I~V zv-QcSj;5n#dK4AOQ>fgyfqMTbs{OnL%!J$!3Ytj`RA{@PwpAo5yVI~gPQ$u*6*bV3 z1wH=1WK!K4gX(YvD&#v*1Nj2AbSF`{b_w-dU?Kl=u2XXCil}cv7gWO|P)WHH^{v>C8t|v6H9m>z@HY&^pyDQiSy2(HfUcK9 z4GKCKdZ0qq4>f>csC_#F)zJdf4A-F6_(Rlyenbu6KI-fE6!pAQ!t9o8s1vp-YTFJ# zJr`es{jZKvxu7*!g!-Yd#Xk5g>a4$nT7rxvO{9vV22ueZU~`PXT3(N{2p3~_tXj(B zOv0(CgDg{NkN?k%Dx)G4TiP`to4|!iT$qj8M*DF!{)r#qvN9fL0roBH@&7%+d#C~V zf<69!n>`k_JKjU(#vN273zRbx3r2NV2em{UQQLBmOF<7NAfIGs0*;~nT6vHEUo?0^ zJpRAYn26oDehrnJ^(xpC5f!l*)Dp}@b+pjle+L!%y{H`c1(jphQA^}Lun#=94^*mX z22>R_qsFMUN<*#PL{vv}?DZw~`fAitzKa^b5BBlEJkAd4)v9`&-PA8t^Eg9sLAW`%{z5PH zu<9OXCU(X~_%muE#cP1yoO@~_``yB9_JACH66%W1{BrNDCtIYH`^i= zpYY;D%*ge$o@P6_sB>T%M&c~g8b7qxlX{u$nr3|y)$TIXc3g+rc3V+Pm5v-luJa3p zyj-}A+0fJ5oYgr{U&ZREEN_ZRzP^|T=V3B#z*n(IAG0K@Py_!Ebv}H8ItRYP#&{BS zP88^?&oKKhn1WttfI48Bq4sSn)Yt6no^^SE2^K9bF~EAqu7NEUKZuP+zI?1I@>!F6y~KsNFIOHN&~s4ws|W z_E*%BJwOf28Du6BgqmP>RJ#RG&xH(P|La06E@(~KppvE+DiVY2^~tE8OmnaSZbI#r ztEkY24mR(##|qTDp^onHI2`Yw+V2}-A`^?sp|>JjGxOD4*vf@N7>)@;%#7AzR_fbO zOR^8gc0^4i0u`ZTR77TCIb4GJDt?TL+zEUCf=fXoy@~PIFUn-~m-r#| zMm~?T3ID|UxNfNVA#wpZBAnD{k8_FZ2V+bG*2J1nZ$=GZ7iypfP)l+Q70K^y-Mv9U zGrWshlV_;VWFBT_R1B3YVW=!_hI+m=DjB=k>w~R6RHTO4`YWhiH4?S7V^Fy;+2}gc zDX8K3r~xcNHLwvC%I&C;A4NU)4Jy<>q8hx0n&}-|e~RkBGu)gTSy1nlK@L8qg`!^A}M| zat#Bu{~uA%zWfvQQOOc#LR=2@VmK<)^=-WkYCv5v7zd%=n`*DGL3OYLPmm)Y;bEK~ zZ|t3D4z^-R?0+>>mx5ksjT(7R)S3-Mt@RL8NAakc&$q5beG4|Bp39bO8ZLx-t|sbw zV^k#CqjKwI)P#J=?0?-z;(`W{hI(O|bv~-0)waF`_1s>2{RryY@QuBG9rgZWThEYU zmLv~qV#RGe7!}#7DeQlL1h`O^3$0Q0IP8jJZ2g$^to6F}4{KnmN!C25jzX~<)ACoU} zHue0kdYoZw*bO+5dhXZFkIuQMui|GIiWgB!mtz{)jMY)!h~3C!oqMSJBi=Me{an<9 z-^0Rs;|K*M(FNSe3r|o6lg{uuS#OS3)BU@V0i@MLtuDD0$Af|*~y?$n>p@i?`qcbn^ReAK7UGoinat*Hmk zH!~W6>L3j@pf^!7o{trAJ8I@ky?JC zIXc5pFV;cLus7N|f9^*iD|*21z&P2~EamUtaD#~Ut%3KT+@ znGyEFF4Wgz3w($QS)JwP8_@;zi)S#N#|fwo`>!w|eg##Zib~?8sN7kDn&@WKz;~gR z&i#mjLir8qBXkan;4Rd_lWCxw`>3@qxRL#@(1cKswNY8z3iZ_*jQSXj!&0~k_1q_@BlTO< ziTDWhT`#@Kob5GH--t=5NPUcI_bz6@%$rTbvu|eq>&3!cP_k4;ZKw9AZI*y~aUmAN zov48vNBtDLYwrhbG0%BX?bSo=nvSUC_Mw(I33U)nz;K+q#Wl%vgbPZhQ>YFvp_1NKvy-%$ftyv@vX2Wr5Fv9X@Ridbm7`3SkqZDBkr z0_muke}d}Z7%s)j_Wt-CW@%nWCE<2d(q2GC7*J)3o zIyZWn3(lLU5idYx?-o=NevitLN2sGS-@E1{?2S4vMxxf-wJt(!=N+g4cy^iG%85$a z>R3eke=r5jbgXp_)}_7?HLy#lk>}rSeh-vH9l!_q!st0g0>PY?$l>@g>5qgRhFvlKq5Y@p@>W#1(#-Va( zC2C2xpa!%D6|p1e-~V4zP($ZY9cS8W4u}G%j>@B6Xn@bLCHBP@>E?i1g}Q$oHS_zZ zT~y+I(_S4^1X`oo8;V-evG240bzvnJG_u{;1rMXvF5f=0JBFfWG7HtwGSu_yQ774Z zs0kcFof~)U{Q@7D0hL3&-wpNNAXKCiK4AYVq?5UzgJl*fS>8qM*Mq17>2uWD{)P%= zz=!55m>oM%&x1Om2Vp-vfXbP|`_1!ZP&pQcYPSV`fgM~5THE{wOvtL@0_yG1ix*M* z*7K41Io%X%QJ;;P@#nY}?_+J8f6xr%7}lX)@niE38j+~T?8m|Q3o5zY7N3}XJQ}@R z*o=DcOVqx;j|zG2LuT!YpmL-NDpHM5GwXng&=Aytm4+cW5w*mdu_Ati`Uu@Xmc(^( z9yTE=YAuIKmTIUa>4I9TB-Bz&MGb5-s)M7b4j-U8dWsrgj!!vy1Ndh?RC@zI^EkI~ zJnH_i&$YYQ|7jE|abXSWt9Bf9#6CcEl=TafM3qnj=#9ER4z>Rmpdzsim1G~HPQuSo z5jlgS@E+2g6LZu&cOCQU!tWF`!)-`!jT%t-uS{fGqt1s|?1*zP z94}xa%yZ2Aj_8V7!kMUTxybr1y1MX{y>Jz)P=A4;Smn6+5jp_1U)Q46@CYjT?x7-9 zv*#G)Tw3-X8@Eq!aGT)k+$D;P@Xw-nF zp_Xbds^QD1B?$P=Ti>SWxAisX-|Z^&bp!cP?P|7gze z``Au5Py=Xo!W>ZDP`NM|bv*%l;Y93#r%}Ib!cN-vunpPY4Hbdqr%lqmi^_!qsNLg! zW^dfZFfKepolq6em?R8GC1+jy3p=1XYJ1k)AArjKDAaRFsO+DLI$0Ol`g+tsw->cb zzC*UR>zt=hl?yLW+okGHrlHoT?Cy(tVKOR|Gf@Z5V$@e|JL*Wjhd?FH>1|@V=RYPP?5=c!Q@0<)PTyMIt)ccq8=*5 zZBV!BL#fqE|r6}j=K zrJ0Xj+=P1WC@N=8+xiXEQa?dgM{M~^W;?XSD%6LfvUDLTOZTDH^c3n~x`CSEJzIZ@ z>d3ilW}Fi>pbA(Uo1oqsjTzC!WjOUR@z$T*UcYJEx>x*zkrHtksBt*2HbE>NE5lBflNn* zc#(CN^*AcYuA_3`Au6QLP?5=Y)7&qCnm`5AbJbBxQXe(*&gegoP#>jXE`@;<-ax&0 z9W{^#sDtG>s-XtInWSrB?T$*OC{&V;K@DUj>iwOlZ@_-k50YoN6not=|6bt%W+GDV z_Si~WO-ZdxHv-{?R3wdC2VibC5U?aB1FHk3Au7{>$FKQQrqh{6^l{*7b z&yB+foPqQ44iZtq68vd>1wI)2NW%K_%DksF^=Q zMIzHKic{^|6_aYX2yz-E*h`UdNpD@BB%jC}w$TLRJpdVGC5~dZ3cXhuT(Ys0hqP z9XK0M&%cN2_z0@K^Qh;ZT66ws&Wm6y#q~z$|NTFhLT@f4;8FYnkKx>B<|8xcFOPGa zdOCiMQ~x&4cYkiS*EZC-@ffRO`4?up^~NOX^ROER@h@Ps{rX}DT;T+`{;%ClE-dCk zO;3P-Ex$(1`~)g=S5Y&2f(mu6fB^r@i=&pL66&aZ8I?nEs2?UXQ18Es+Pj#)Y4{xKB)g7Un{1g)B)q64Xo&5xCsxL_*apABrdTM8 zS@XWAh`6YZ-ar;l zhDx>{u{=J-E?6p?iA<7p4EE>xWb`i~E~kIzDg`w#IeUQrk5)IL_W4#+hlg$b3~FEB zL!FG-a+v$&QK4>#+65g@OE(mi6N@nr-$t$Z7Sz)1L09{A9|eWzCYHu$s3jJ^*(Ah{Ea0se=hdFvb$z3({MXfvh+qh zI0!YfQK%WehBI&tzKLPE&FCjY#-JiT2^Fzj zE(NW{0aQbu;BWXDDwMkl1o)5EgBVKvE^1)K3!3B_jLPO1RML({CF=rgg_}?lyNg=7 z=k|WKLZ&^pBn8c+BdUQxs0PQOLNo)_z+zNZZ$w4n35KAju&Gx>ANA_Cz63RawWyAF zpxQlvn)z{L>0ReE1)W&8Q5`+94+Iu5A>Yxjj!@;Nt zypDQro^=H(w>IMU+W!YBDD-oSnuZoxSEG_>Gj_$7iUs(;f_n8DRO{Gt7!!>bX%N3r8hU6O6>3*aAPWzCbNm z3$OWhM4$$;26aIFjF&K=RDjb2uV8fyE^Q{-2VI472nB7A5vUNphB{~#q7IZ@sPo~N zy?-8SQ@@SD7+fa6|A$iTQA;ry_55^<#^tC;1(r42vjA46UZ*VkUmpb@7wX|+R4)90 z%7ve-H&G#biW+dnVAD}9^inU3ic~$+b6rr|xIe1>xu{4T!76we6^T6MTyxU3DrXw( zi)FbHiHgKD>tfV^Hlda#9ktDlU?)6n@0Txcma-P={RXHd>3|ye8>ooRM@@9QOF>8B zUMz(tQ773GR09P=Oo+>)UhIfEa-(r3PDMSJuYw7AS=0b)TAQP?y(@-dEb3@qglflq zhl2L$QPdipL}lr9)Y1F^l^dBVns0#@719o<9B@&)AsrQ=8`dYNCCOCDM6|fI87dOd z$n&m~L_s5-go?l{)BxT^HSi%;!tYSe{cX)!*$lWaDwLH_&s9ey*C146XJC2UigoZu zRK&7}>N@+cI0YRTEm6r6hsxs7sL)M9CC?ID--}xNuTi1Ago@lF)Y4@NGaZye?Sfj^ z99yFXI2{#%boBrE{~-!`@FuFGXQ-pKP!$u2rq=eT4ttd(!0M)>N~q^sqGlXz zufK_Ue?9iYpHazMuZHQkGir$j+jHK-&zhJElB>W50JTIRh7)bp>{`ZUz@i&4+L?^4h{`~j8i_fbc0rrPGgGN?#2 zMS0Z(v*0vg|an;D z=b@6fX;U-sNX$?F&MOo&v)6GDEZhYAKeXBJ~bxNxroA zFQanm1x~>H&CPGZ`Kaf9Lbol2hZH(v!xm<3-a;kiTGYTkMtvi`M}_o)y`QnA$>K7o z?bHZ0z}eUa-$zBj)5y%y>lF|ZB$UoT9z4{Sy?dpuTEl&)?R6B@@%N~KTt$u8 z)82gLDxgAI4YjuYP#wL3+P=$CyJr(t$9<>)-Nr2V3^kyj4yJuKn1VWJih3XxGh>Q% z0%})G!}|CxYQVQqKfnJ*%_y*=$(8b`BfBo@xxuLOBM~*=1*ieNgGA1C_ED(f;g8#J zAUB-O0sjBGpH#8LvToX1_#b?HSYz{y=q@t*4n`Fe=GvVmK4& zh*POg>=oeu>v%!E1N?u*I}zi!ehJlnr#|c|?f+2}8sn>&5kEyG)t7nzFJW!GgPE{g zUo)^UYeTHV^$w`_r=qs)bW|i(pxU{J%BkC^CCJ>5{jW7FNI^3zfl96#SQrPQ)^Idx zK+{kmUS+Qz$JW%(qmnkbze&0}sBIdDir`GteqVvgt@myHP=EHnIylb-W$$fN_C7`J zhAacjKbqymlGF>KlC%!$`R1sZbVe=V0BnOBaWUS*vG~To0A~?PlW`DBg)0UJIQ#*) zvnPW6e~|~)Mg}+&aY~d4WuPy>|93na;%ctHk3F%^P?HNgQIR^1z3?$=%{xb%fuBI_ zil6aw^u(Bh>L}`Wh7)UYpq@)Xp^n6@I3Al~#bM^bI8<(Iz?OIo8)4<)Cgj7>OML=r z30I-Ad=Dz*z2nS}&t$AYJss=eE!6qomLCz|OsCKfTj8&$0ffby&?VwX>bFoGL?)O6 zCk?AnUyrr$Bv!`kiRL3#4>wX@im}-271Q2U98WzY$^R{JoedQBaN!t+EnkA`?!>D&h{=C^ah!Fue%6zU%jWGvN7t{%uip8}5 z=Tp!D@h+~wSI3$GlpYu0|1Xy<$7NjaJU+nx|8VphzCnG^1oK(Ffm5i*kV^yb7HZqI zn;78iW&m&DL+VYZ@Oy%R)qFL8KhS0(Qv>+#zjI^o>jD1%C$iva0nTCSXR#r!e1j$7 z!E>m{l$mbUtS4%1-$XC>-^NVTKbv88$Ja<2IcHG2qxme8lwDEVy)UL<1iG5pNqZw{ zwrM!cIv=%vcc8ZEN2u-hHEQk8pgPDp$NX$Bj5=sSQM;oa>V6dJ8<2$Bo{LaN_r*Ev z{}~h>abXrto@>@N&pdOIH9>XU3iTVWC$`4EsE*d6a$yGs;(63_mvI$dL;X~HZNAw} zdr&jKi^_qh1*}yW3R4!ChBjJHpk|(7q1mT-P#xAs9ZYRdGw6yHaVRP>b5LKu<+gs< z*1twQ_Xu^sc^8>Kps40jP=nE^{g{Ya+w~ZN2eArX$C_AZvAN#~b(Y7XW-=A4;(Ao> zoIo$;U1C1(HLy7K{-}@H7}V0Zizp1D@HT438JC*1%!dkT2xi1k)Ih7FX3zlDKu25e zhk8E_6{)ePrJ08M80|%E*Dp}J;|_Mz_rKaQGb0z3#Y?dUuEm9T3Y8=A%T346P@&AW z!hDo!qmru=?!#o%*D!RYxjz&&(DA7Dr{jk>5B>lB@6M~tQ99823YO%7S5Y%vkCkx` zD&&_@AD`!_nPyoX;JktbF&W=LoezKGd3=41`3Csbnm;QFU1yHyaX8v#|CU@I;QtSg zE`HlIxNw7U9qO~X(|Q=S6eqC&p0o9bs9o|D73$0z%~}^l4Y)2UM|z-^GzB$*G<4O$ zR0>+##i$O}*!o6P$abQ()%&)--`@We)zER&duLDsxq#~EE-I40qYj>Yo6K`XQ3EZt ziT$quROf;|F0D`<_Q%W^h04}=R7ca1gUwll`U$oawM4s71N;=#(HE%Q^bKlY88(~t zGo#)si5fti&8~T&9T(JLS5!y+(7!EhJrOmqan@Ju^%gpO?#XplIdDn52aG*fBn*TUz`-WpB5{v?yOv z`DAZmL`sY|E;c#EJ1RCMX5NKUrE>i1xv;RX^bVChb4wIVNKA=k9HV06;=I22h#_%4 zZ%T~MyXbg7PZ8G}8}Bt;S7~d95Sb94oDkeG&auHpvsU&VGV{PH8TI(s9Kn=_{TOPB8}m^k{X-D zlr*yBln8oP({>V?-l&+^$QW;|!bAjya4#Y*&KKnkNl1xteMzHYlYQQVc;*uC8^!-M zQ{etTQ~T#NGjVSCXXhL3i%ez6(OwNQxx&AP7;c8xcBt2vl$4N^>`jP_Oii*;@rC{? zFm77cl+Ym&$+Vi7ln|X1F`}|JG0w*YZ)A)wayWB|Fca#U;!PMb%omxG?2TZ$Je}xE zVz^OEIw>Jd^YMj6hk4TyQoV{xOvFfkP*Q110f|USNs1kkn&R^k+O8>Ky_ybc;frhQ zOYwD%ZyuMB?2j{XPyd9pg<+QGon;n4nx48XKKTRFl(^ z!?dl!yo>m+aO9hJZ(GJX+)bfZ7ICQezeYsV|+>e9xL-A zG4{XU&&>FQ6eTqS@kNC#Y8>IYmEL2BXF`VbibFkNf#EentJd{a3$ItRPH5GdRjX#{ z(mC{H-^kcxj)c$_5h=cU-tem7;oS2-SGQhxjr2E%c^b6{>NZ>lUi$Ivo{hQFw}0&U zGDog%@v(`CK0-lqwCmQnQ~Iahcy4+ZeQ?i{nBM=sCwqo;{wmRTxfd-73Mid^EGXbg z33~{Jn#O$TJAwnUXSPi*`XnqM)Si3xf%F$)0U0y@w`)&o2Q(;M+MIvMv4kPsG2!M^ z8X6I$6VT@!5u388!hKJf(*HN_{(1DDJT!G3i&gsu^eell=50@&z$nhC|JOkn+TI+5 z@#Y{Lqhw35$;B$f`i4d)`C^Cq;#sba{$r4Jh#lc=YtFzisouB<+OO;#<%^H^`iPlX ze>+zVncbVDJJCLONCbN`-X>CLtJL`6|I0b(jYv(7_6L zk2KiHmugN$b!BJ7u|)E6fx$5>-Eld?BT|P(`;t|zR7*TG;>NQ@8_W2|6znzi26q{DQ7&neV_`~sL?=Y4$t8XnR&;j z#zzyz5r@|wUgaIb7E3w2*Cn8QmpDbogf$EEjySwOIoUQ9#kTzC$or4O^S_;)3S&qA zVdy_&|HnZ|q}#?M`9^r#M5HFBFi`R~$vcuS5GjV$yhL2ip!CjB32Or9_a zlk}ngk@Nnw`ya<>Xpa%mz9E|BKW8Xk*2DXgcskx2F+3&J#|JRp+sqfAn!@}mdy^Bq z%re;@x!fA$KdI_p;DxE4LN1y2&+-mGyf;2xQBR18_n8;}IXJz;?OCeD`X_Tnq$cxY zLZ9!BhxbOt_|u&EXfSQ#V>PeV?ovm?)_a^iCqlY2BksSUkXh}Q$fA9I2*!VH2 z3@X|e-#FbnHQ22o(l*{Z6F%$b|!+FEKsjRHlC)zja@cNiI@<3ta=TJ(_;q?r_ z9=oA^ef(DN#hWv?1uu}k_E~K=FQ;=1pAL2G--ppDVUE%*B8lA9#I&gwjawRUJALD_ PfINZec~%Gf68L`rCVGQr diff --git a/spyder/locale/de/LC_MESSAGES/spyder.po b/spyder/locale/de/LC_MESSAGES/spyder.po index ab2d64f92ac..b8733e5fc7d 100644 --- a/spyder/locale/de/LC_MESSAGES/spyder.po +++ b/spyder/locale/de/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-03 14:08\n" "Last-Translator: \n" "Language-Team: German\n" -"Language: de_DE\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: de\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: de\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: de_DE\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -59,14 +58,12 @@ msgid "Dock the pane" msgstr "Den Bereich andocken" #: spyder/api/widgets/main_widget.py:344 spyder/api/widgets/main_widget.py:448 spyder/plugins/base.py:204 spyder/plugins/base.py:512 -#, fuzzy msgid "Unlock position" -msgstr "Cursorposition" +msgstr "Position entsperren" #: spyder/api/widgets/main_widget.py:345 spyder/api/widgets/main_widget.py:453 spyder/plugins/base.py:205 spyder/plugins/base.py:516 -#, fuzzy msgid "Unlock to move pane to another position" -msgstr "Gehe zur nächsten Cursorposition" +msgstr "Entsperren, um den Bereich an eine andere Position zu verschieben" #: spyder/api/widgets/main_widget.py:351 spyder/plugins/base.py:212 msgid "Undock" @@ -85,14 +82,12 @@ msgid "Close the pane" msgstr "Den Bereich schließen" #: spyder/api/widgets/main_widget.py:442 spyder/plugins/base.py:506 -#, fuzzy msgid "Lock position" -msgstr "Cursorposition" +msgstr "Position sperren" #: spyder/api/widgets/main_widget.py:446 spyder/plugins/base.py:510 -#, fuzzy msgid "Lock pane to the current position" -msgstr "Gehe zur nächsten Cursorposition" +msgstr "Bereich an der aktuellen Position sperren" #: spyder/api/widgets/toolbars.py:123 msgid "More" @@ -195,28 +190,22 @@ msgid "Initializing..." msgstr "Initialisieren..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." -msgstr "" -"Es war nicht möglich, die vorherige Spyder-Instanz zu schließen.\n" +msgstr "Es war nicht möglich, die vorherige Spyder-Instanz zu schließen.\n" "Neustart abgebrochen." #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." -msgstr "" -"Spyder konnte nicht auf die\n" +msgstr "Spyder konnte nicht auf die\n" "Werkseinstellungen zurückgesetzt werden.\n" "Neustart abgebrochen." #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." -msgstr "" -"Es war nicht möglich, Spyder neu zu starten.\n" +msgstr "Es war nicht möglich, Spyder neu zu starten.\n" "Vorgang abgebrochen." #: spyder/app/restart.py:145 @@ -248,13 +237,9 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "Der Kontext der Tastenkombination muss entweder '_' oder dem Plugin 'CONF_SECTION' entsprechen!" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" -msgstr "" -"Beim Laden der Spyder-Konfigurationsoptionen ist ein Fehler aufgetreten. Sie müssen diese zurücksetzen, damit Spyder gestartet werden kann.\n" -"\n" +msgstr "Beim Laden der Spyder-Konfigurationsoptionen ist ein Fehler aufgetreten. Sie müssen diese zurücksetzen, damit Spyder gestartet werden kann.\n\n" "Möchten Sie fortfahren?" #: spyder/config/manager.py:668 @@ -718,11 +703,9 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "Stellen Sie dies für Hoch-DPI-Bildschirme ein, wenn die automatische Skalierung nicht funktioniert" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" -msgstr "" -"Geben Sie Werte für verschiedene Bildschirme ein, die durch Semikolons ';' \n" +msgstr "Geben Sie Werte für verschiedene Bildschirme ein, die durch Semikolons ';' \n" "getrennt sind. Gleitkommawerte werden unterstützt" #: spyder/plugins/application/confpage.py:238 spyder/plugins/ipythonconsole/confpage.py:29 spyder/plugins/outlineexplorer/widgets.py:48 @@ -1074,17 +1057,14 @@ msgid "not reachable" msgstr "nicht erreichbar" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" msgstr "Die Kite-Installation wird im Hintergrund fortgesetzt. Klicken Sie hier, um den Installationsdialog erneut anzuzeigen" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" -msgstr "" -"Klicken Sie hier, um den\n" +msgstr "Klicken Sie hier, um den\n" "Installationsdialog erneut anzuzeigen" #: spyder/plugins/completion/providers/languageserver/conftabs/advanced.py:28 spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:60 spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:268 @@ -1304,12 +1284,10 @@ msgid "Enable Go to definition" msgstr "Gehe zur Definition aktivieren" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." -msgstr "" -"Wenn diese Option aktiviert ist, können Sie durch \n" +msgstr "Wenn diese Option aktiviert ist, können Sie durch \n" "Klicken mit der linken Maustaste auf einen Objektnamen\n" "bei gedrückter Taste {} zur Definition des Objekts \n" "wechseln (sofern möglich)." @@ -1327,12 +1305,10 @@ msgid "Enable hover hints" msgstr "Aktiviere Hover-Hinweise" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." -msgstr "" -"Wenn aktiviert, wird durch Bewegen des Mauszeigers über\n" +msgstr "Wenn aktiviert, wird durch Bewegen des Mauszeigers über\n" "einen Objektnamen die Signatur und / oder der \n" "Docstring des Objekts (falls vorhanden) angezeigt." @@ -1537,8 +1513,7 @@ msgid "down" msgstr "inaktiv" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." msgstr "Vervollständigungen, Linten, Code einklappen und Symbolstatus." @@ -1747,16 +1722,12 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "Um Kommandos wie \"raw_input\" oder \"input\" zu benutzen müssen Sie Spyder mit der multithread-Option (--multithread) aus einer System-Konsole starten" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" msgstr "" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 @@ -1772,9 +1743,8 @@ msgid "&Run..." msgstr "Ausfüh&ren..." #: spyder/plugins/console/widgets/main_widget.py:191 -#, fuzzy msgid "Run a Python file" -msgstr "Python-Skript ausführen" +msgstr "Eine Python-Datei ausführen" #: spyder/plugins/console/widgets/main_widget.py:197 msgid "Environment variables..." @@ -1821,9 +1791,8 @@ msgid "Internal console settings" msgstr "Interne Konsoleneinstellungen" #: spyder/plugins/console/widgets/main_widget.py:510 -#, fuzzy msgid "Run Python file" -msgstr "Python-Dateien" +msgstr "Python-Datei ausführen" #: spyder/plugins/console/widgets/main_widget.py:569 msgid "Buffer" @@ -1942,14 +1911,12 @@ msgid "Tab always indent" msgstr "Tabulator immer einrücken" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)" -msgstr "" -"Wenn diese Option aktiviert ist, wird beim\n" +msgstr "Wenn diese Option aktiviert ist, wird beim\n" "Drücken der Tabulatortaste immer eingerückt,\n" "auch wenn sich der Cursor nicht am Anfang einer\n" "Zeile befindet (wenn diese Option aktiviert ist,\n" @@ -1962,12 +1929,10 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "Entferne automatisch nachgestellte Leerzeichen in geänderten Zeilen" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." -msgstr "" -"Wenn diese Option aktiviert ist, werden bei geänderten\n" +msgstr "Wenn diese Option aktiviert ist, werden bei geänderten\n" "Codezeilen (mit Ausnahme von Zeichenfolgen) die nachgestellten\n" "Leerzeichen entfernt, wenn sie deaktiviert bleiben.\n" "Wenn diese Option deaktiviert ist, werden nur von Spyder hinzugefügte Leerzeichen entfernt." @@ -2365,11 +2330,9 @@ msgid "Run cell" msgstr "Zelle ausführen" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" -msgstr "" -"Aktuelle Zelle ausführen \n" +msgstr "Aktuelle Zelle ausführen \n" "[Zum erstellen von Zellen #%% benutzen]" #: spyder/plugins/editor/plugin.py:729 spyder/plugins/editor/widgets/codeeditor.py:4434 @@ -2725,33 +2688,24 @@ msgid "Removal error" msgstr "Entfernungsfehler" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" -msgstr "" -"Es war nicht möglich, Ausgaben aus diesem Notebook zu entfernen. Der Fehler ist:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" +msgstr "Es war nicht möglich, Ausgaben aus diesem Notebook zu entfernen. Der Fehler ist:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 msgid "Conversion error" msgstr "Konvertierungsfehler" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" -msgstr "" -"Es war nicht möglich, dieses Notebook zu konvertieren. Der Fehler ist:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" +msgstr "Es war nicht möglich, dieses Notebook zu konvertieren. Der Fehler ist:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:4412 msgid "Clear all ouput" msgstr "Gesamte Ausgabe löschen" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" -msgstr "Zu Python-Skript konvertieren" +msgstr "Zu Python-Datei konvertieren" #: spyder/plugins/editor/widgets/codeeditor.py:4418 msgid "Go to definition" @@ -2906,13 +2860,9 @@ msgid "Recover from autosave" msgstr "Vom Autosave wiederherstellen" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." -msgstr "" -"Automatisch gespeicherte Dateien gefunden. Was möchten Sie tun?\n" -"\n" +msgstr "Automatisch gespeicherte Dateien gefunden. Was möchten Sie tun?\n\n" "Dieser Dialog wird beim nächsten Start erneut angezeigt, wenn keine automatisch gespeicherten Dateien wiederhergestellt, verschoben oder gelöscht wurden." #: spyder/plugins/editor/widgets/recover.py:148 @@ -3208,24 +3158,16 @@ msgid "File/Folder copy error" msgstr "Fehler beim Kopieren von Dateien/Ordnern" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" -msgstr "" -"Diese Art von Datei (en) oder Ordner kann nicht kopiert werden. Der Fehler war:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" +msgstr "Diese Art von Datei (en) oder Ordner kann nicht kopiert werden. Der Fehler war:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1416 msgid "Error pasting file" msgstr "Fehler beim Einfügen der Datei" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" -msgstr "" -"Nicht unterstützter Kopiervorgang. Der Fehler war:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" +msgstr "Nicht unterstützter Kopiervorgang. Der Fehler war:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1436 msgid "Recursive copy" @@ -3680,11 +3622,9 @@ msgid "Display initial banner" msgstr "Initiales Banner anzeigen" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." -msgstr "" -"Mit dieser Option können Sie die am oberen Rand der Konsole\n" +msgstr "Mit dieser Option können Sie die am oberen Rand der Konsole\n" "angezeigte Meldung ausblenden, wenn sie geöffnet ist." #: spyder/plugins/ipythonconsole/confpage.py:35 @@ -3696,11 +3636,9 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "Vor dem Entfernen aller benutzerdefinierten Variablen nach einer Bestätigung fragen" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." -msgstr "" -"Mit dieser Option können Sie die Meldung ausblenden, die beim \n" +msgstr "Mit dieser Option können Sie die Meldung ausblenden, die beim \n" "Zurücksetzen des Namensraumes am oberen Rand der Konsole angezeigt wird." #: spyder/plugins/ipythonconsole/confpage.py:43 spyder/plugins/ipythonconsole/widgets/main_widget.py:475 @@ -3712,11 +3650,9 @@ msgid "Ask for confirmation before restarting" msgstr "Vor dem Neustart nach einer Bestätigung fragen" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." -msgstr "" -"Mit dieser Option können Sie die am oberen Rand der Konsole\n" +msgstr "Mit dieser Option können Sie die am oberen Rand der Konsole\n" "angezeigte Meldung ausblenden, wenn der Kernel neugestartet wird." #: spyder/plugins/ipythonconsole/confpage.py:59 @@ -3752,12 +3688,10 @@ msgid "Buffer: " msgstr "Puffer: " #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" -msgstr "" -"Legen Sie die maximale Anzahl der Textzeilen fest, die\n" +msgstr "Legen Sie die maximale Anzahl der Textzeilen fest, die\n" "in der Konsole vor der Trunkierung angezeigt werden.\n" "Angabe von -1 deaktiviert es (nicht empfohlen!)" @@ -3774,13 +3708,11 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "Pylab- und NumPy-Module automatisch laden" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." -msgstr "" -"Auf diese Weise können Sie Grafikunterstützung laden,\n" +msgstr "Auf diese Weise können Sie Grafikunterstützung laden,\n" "ohne die Befehle zum Plotten zu importieren. Nützlich,\n" "um mit anderen Plot-Bibliotheken als Matplotlib zu\n" "arbeiten oder um GUIs mit Spyder zu entwickeln." @@ -3858,14 +3790,12 @@ msgid "Use a tight layout for inline plots" msgstr "Nutze ein enges Layout für Inline-Plots" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" "that created using savefig." -msgstr "" -"Setzt bbox_inches auf \"tight\" wenn\n" +msgstr "Setzt bbox_inches auf \"tight\" wenn\n" "inline mit matplotlib gezeichnet wird.\n" "Kann zu Unstimmigkeiten zwischen dem\n" "angezeigten Bild und dem durch savefig\n" @@ -4288,20 +4218,12 @@ msgid "Connection error" msgstr "Verbindungsfehler" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" -msgstr "" -"Beim Laden der Kernel-Verbindungsdatei ist ein Fehler aufgetreten. Der Fehler war:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" +msgstr "Beim Laden der Kernel-Verbindungsdatei ist ein Fehler aufgetreten. Der Fehler war:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" -msgstr "" -"Konnte keinen ssh-Tunnel öffnen. Der Fehler war:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" +msgstr "Konnte keinen ssh-Tunnel öffnen. Der Fehler war:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 msgid "The Python environment or installation whose interpreter is located at
    {0}
doesn't have the spyder-kernels module or the right version of it installed ({1}). Without this module is not possible for Spyder to create a console for you.

You can install it by activating your environment (if necessary) and then running in a system terminal:
    {2}
or
    {3}
" @@ -4472,11 +4394,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "Layout {0} wird überschrieben. Möchten Sie fortfahren?" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"Das Fenster-Layout wird auf die Standardeinstellungen zurückgesetzt: Dies wirkt sich auf die Fensterposition, die Größe und die angedockten Steuerelemente aus.\n" +msgstr "Das Fenster-Layout wird auf die Standardeinstellungen zurückgesetzt: Dies wirkt sich auf die Fensterposition, die Größe und die angedockten Steuerelemente aus.\n" "Möchten Sie fortfahren?" #: spyder/plugins/layout/layouts.py:81 @@ -4580,9 +4500,8 @@ msgid "Enable UMR" msgstr "UMR aktivieren" #: spyder/plugins/maininterpreter/confpage.py:122 -#, fuzzy msgid "This option will enable the User Module Reloader (UMR) in Python/IPython consoles. UMR forces Python to reload deeply modules during import when running a Python file using the Spyder's builtin function runfile.

1. UMR may require to restart the console in which it will be called (otherwise only newly imported modules will be reloaded when executing files).

2. If errors occur when re-running a PyQt-based program, please check that the Qt objects are properly destroyed (e.g. you may have to use the attribute Qt.WA_DeleteOnClose on your main window, using the setAttribute method)" -msgstr "Diese Option aktiviert den User Module Reloader (UMR) in Python/IPython-Konsolen. UMR zwingt Python, tiefgreifende Module beim Import zu laden, wenn ein Python-Skript mit der eingebauten Spyder-Funktion runfile ausgeführt wird.

1. UMR kann einen Neustart der Konsole erfordern, in der es aufgerufen wird (sonst werden nur neu importierte Module beim Ausführen von Dateien neu geladen).

2. Wenn Fehler beim Ausführen eines PyQt-basierten Programms auftreten, überprüfen Sie bitte, ob die Qt-Objekte ordnungsgemäß zerstört sind (z.B. müssen Sie das Attribut Qt.WA_DeleteOnClose der Methode setAttribute in Ihrem Hauptfenster verwenden)" +msgstr "" #: spyder/plugins/maininterpreter/confpage.py:140 msgid "Show reloaded modules list" @@ -4613,11 +4532,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "Sie arbeiten mit Python 2, das bedeutet, dass Sie kein Modul importieren können, das Nicht-ASCII-Zeichen enthält." #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"Die folgenden Module sind auf Ihrem Rechner nicht installiert:\n" +msgstr "Die folgenden Module sind auf Ihrem Rechner nicht installiert:\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4957,11 +4874,9 @@ msgid "Results" msgstr "Ergebnisse" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" -msgstr "" -"Die Ergebnisse des Profiler-Plugins (die Ausgabe von Pythons profile/cProfile)\n" +msgstr "Die Ergebnisse des Profiler-Plugins (die Ausgabe von Pythons profile/cProfile)\n" "werden hier gespeichert:" #: spyder/plugins/profiler/plugin.py:67 spyder/plugins/profiler/widgets/main_widget.py:203 spyder/plugins/tours/tours.py:187 @@ -5877,13 +5792,9 @@ msgid "Save and Close" msgstr "Speichern und Schließen" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"Das Öffnen dieser Variable kann langsam sein\n" -"\n" +msgstr "Das Öffnen dieser Variable kann langsam sein\n\n" "Möchten Sie trotzdem fortfahren?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5923,11 +5834,9 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Spyder konnte den Wert dieser Variable nicht von der Konsole abrufen.

Die Fehlermeldung war:
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" -msgstr "" -"Es ist nicht möglich diesen Wert darzustellen,\n" +msgstr "Es ist nicht möglich diesen Wert darzustellen,\n" "weil bei dem Versuch ein Fehler auftrat" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:621 @@ -6507,8 +6416,7 @@ msgid "Legal" msgstr "Rechtliches" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6518,8 +6426,7 @@ msgid "" " Hint:
\n" " Use two spaces or two tabs to generate a ';'.\n" " " -msgstr "" -"\n" +msgstr "\n" " Numpy Array/Matrix-Helfer
\n" " Geben Sie ein Array in Matlab ein: [1 2;3 4]
\n" " oder Spyder vereinfachte Syntax : 1 2;3 4\n" @@ -6532,8 +6439,7 @@ msgstr "" " " #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6543,8 +6449,7 @@ msgid "" " Hint:
\n" " Use two tabs at the end of a row to move to the next row.\n" " " -msgstr "" -"\n" +msgstr "\n" " Numpy Array/Matrix-Helfer
\n" " Geben Sie ein Array in die Tabelle ein.
\n" " Verwenden Sie den Tabulator, um zwischen den Zellen zu wechseln.\n" @@ -6630,7 +6535,7 @@ msgstr "Möchten Sie alle ausgewählten Elemente entfernen?" #: spyder/widgets/collectionseditor.py:1042 msgid "You can only rename keys that are strings" -msgstr "" +msgstr "Sie können nur Schlüssel umbenennen, die Zeichenketten sind" #: spyder/widgets/collectionseditor.py:1047 msgid "New variable name:" @@ -6750,12 +6655,11 @@ msgstr "Abhängigkeiten" #: spyder/widgets/dock.py:113 msgid "Drag and drop pane to a different position" -msgstr "" +msgstr "Ziehen und Ablegen des Bereichs an einer anderen Position" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Den Bereich andocken" +msgstr "Bereich sperren" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6930,37 +6834,32 @@ msgid "Remove path" msgstr "Pfad entfernen" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Importieren als" +msgstr "Importieren" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "Spyders Pfadliste mit der Umgebungsvariablen PYTHONPATH synchronisieren" +msgstr "Aus der Umgebungsvariablen PYTHONPATH importieren" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" -msgstr "" +msgstr "Exportieren" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "Spyders Pfadliste mit der Umgebungsvariablen PYTHONPATH synchronisieren" +msgstr "In die Umgebungsvariable PYTHONPATH exportieren" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "PYTHONPATH-Verwaltung" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." -msgstr "" +msgstr "Ihre Umgebungsvariable PYTHONPATH ist leer, also gibt es nichts zu importieren." #: spyder/widgets/pathmanager.py:276 -#, fuzzy msgid "This will export Spyder's path list to the PYTHONPATH environment variable for the current user, allowing you to run your Python modules outside Spyder without having to configure sys.path.

Do you want to clear the contents of PYTHONPATH before adding Spyder's path list?" -msgstr "Dies synchronisiert die Spyder-Pfadliste mit der PYTHONPATH-Umgebungsvariablen für den aktuellen Benutzer, so dass Sie Ihre Python-Module außerhalb von Spyder ausführen können, ohne dass sys.path konfiguriert werden muss.
Möchten Sie den Inhalt von PYTHONPATH löschen, bevor Sie Spyders Pfadliste hinzufügen?" +msgstr "" #: spyder/widgets/pathmanager.py:394 msgid "This directory is already included in the list.
Do you want to move it to the top of it?" @@ -7011,9 +6910,8 @@ msgid "Hide all future errors during this session" msgstr "Verstecke alle zukünftigen Fehler während dieser Sitzung" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "IPython-Konsole hier öffnen" +msgstr "IPython-Konsolenumgebung einbeziehen" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7123,20 +7021,3 @@ msgstr "Kann nicht mit dem Internet verbinden.

Stellen Sie sicher, dass msgid "Unable to check for updates." msgstr "Aktualisierungen können nicht gesucht werden." -#~ msgid "Run Python script" -#~ msgstr "Python-Skript ausführen" - -#~ msgid "Python scripts" -#~ msgstr "Python-Skripte" - -#~ msgid "Select Python script" -#~ msgstr "Python-Skript auswählen" - -#~ msgid "Synchronize..." -#~ msgstr "Synchronisieren..." - -#~ msgid "Synchronize" -#~ msgstr "Synchronisieren" - -#~ msgid "You are using Python 2 and the selected path has Unicode characters.
Therefore, this path will not be added." -#~ msgstr "Sie benutzen Python 2 und der gewählte Pfad enthält Unicode-Zeichen.
Der neue Pfad wird nicht hinzugefügt." diff --git a/spyder/locale/es/LC_MESSAGES/spyder.mo b/spyder/locale/es/LC_MESSAGES/spyder.mo index a4c5afcb3591928211b9c15513412bf14cc3ba06..339e26effb6c0135d6d7f2d60a4d98810d2f78ed 100644 GIT binary patch delta 29370 zcmZ|X2Yk-g-~aLNbs@xR?9GMPgb;f~P?OIu27Ytfeazuwn5zu*1)ulsS|_w$_ZIOB6Z=Ui8kwwo{Jd~hwN`+feb3mpDu ze}LnZ#M^aM`=9^$XoTbR^*GJ}9D_v?9H$t!=O#K%F5HMYaSzgja~QMWG0cG{ur!{< zKzw9}ua|{(CG){co&@fXxg%ZxV-yn(uY6x-oH z*cjWs<~R*-3Njhz04g%IUuQBn4xcdK`^cD`n}oSG4w+~oJs-7XTPJd(B!x3vP($~z z8b(hdq%<%OHPcg*?e0L$IPeX}DTKwb0M^1-o^OR(!mU#rCj@t+p8pA};P1#ra!OA% zyJv<=K{pnnviLA68Bd~KJcn8E2Ij|KQ8RgtO1@gtOvoFeLOUKcpslFqKSs587WMpP z>vhaW-Mvde9XQh+rwe37ZJ(Z~?J@{8(|GKJlTk_bwe=A`pkDZ2j?)8kyve|c!~l$< zo-o64s^V$XQa(qW8+B*;x3TL)P|$^rSQz`D50g+4c?*@4OR*^KwD(V7J?giy8kV0$ zc4HUoYnYSzM$Cr0QRmBk%#G(TO#A;w3gx*_WVYjkV`Hp{<53;1!dCbpRzPQt8DK?J zq}rec-VGJvSUiZMFc;REYqn<-RD0>D_uj^0^zXb!K_T9YTJtZl7G6PhnD;G{^x$X2*L>oy2L^IM4aH&rrlQtrJT}LL zm=70{8i}1g=#q1-k510 zT!5O{3RK9qqat$&)$vu-`@f)a=K+?%(1m9IcSF5D(K;6kQD26N#BP^Dbqf2i2Hrr; zykLf@*Tk~a2VyOBQAxA|b#Q%dy@3U(XIW%&p(v`oF4z@c$If^Pt6-_cCb!%+6l!uI z0X4Iw*43z)Y{ZxFbJXYfMbz3qMI}?<5);xQSb%ytEQ0l{ov|487}U~D#9*9->^j$Z zpMp9#hYj#LYNn-0QU44uKlRs91ANQ65H*1J?EOtxnECp!R*%J-gGlJXd& z{a>4cZnQ)l4880FLs1=!vbv}c&qn3KD%8MFqLTOuYNj_)$>&{a+RKGn^P;H8g`gr8 zj(O?d=|MpQ7>ar@6*c1-7>-L(4PLR{KxOkiRBk-6*NZJPM|3$ToS;4UeIg=r*du;wwxff>9mS#5b@d>i#h- zh2LNWytjgGl`Mr;n$Xm+Hbc#@3u;MvqaqMxuP4}g8dm1|1k@6&M}>GNs-q)V0a4is!vs_gWTFn9a~OcvPy@S(d6o8$DfqaMZKKKVil_(LqC)!$2I5H6fX1OhJRQ|= z2CAdg_WsA1o%$(MuAE1;^B5JepiL&?C9oR(J3a~>up_p|MW_aTvi^qZ@R6-&+3YwU zQO|{1eb(dD#thEdRsf3O>dY$H){BC5U{ z%VEHF^MgkftU`SfR>sX(3D2UQdxl!FGCRyn8)F{o&G9AdjCpa;4&om~A%P3Z>ItZ1 zd<&HW3osY%M4gNW?e()*mHGn=#FFou?5>DuZ?Yajt^HXH#k;73$hXIA z!*G{^j>_(+nZ{raOh&Egc+`G<8}s2>9E6$J4-4-#GaiL{e>4`t$*7qwLT%UgQ6bO7 zWq1_Tt{a_cMm!e%Gr&&V*o4a7r>M0p`hhucnxh(wLCq}QI?cMmx*z>Hfj*vlhH9_W zKC@ezB9U;N{`SH+EX)Iour6*#EyZ=zk@*J}!=U|U=2fvd^{J?k9>tn?9hIDgJ~Tg+ zhNB{yj*8G^)bn$&qW1q*3dOl_78R0vs9f-}$F#Ig26 zicnkBgoffOd>yqbe22`3S_M>pQRphmQz-=DWK@#P$JV$OU&h<^e(l5Nxt92XUC|SD zBKG{mgfgCSh6gKBpuYNE;L!#6&4%?#FXK@Gl-8u^E)H9U=3 z@fm6$p3lq-a-()heyoi{uqDpNM7)66iBRjKW~QBwnWgPzO~kT1H{G>`jhLMaCr~py zW9!#Y1Gt5n!Ch1~|AU%w_T%Py8Pw9%#OxShueU?ZxGyTXU&Y4wI%-1hK6~R+)IoH? z-uMj*Qh$s}wp^c^hRULnv^J`N4yXaYg4$kDSP@5}k}v}`@SRu{Kf%&?2kFmsa(`i# zz=ukT8W@OSm;;-kX3z#T!#=kDDuz-YkDBpTRHXLc1l*4~vEd10D^z=3P|x?lx3&L= zP?*hyNB9Fy`qKQ;DDoudJN1jW5nG-j!SD_$BENlQLh1S1%&aN4;(9DKP! zy(d=BY4aU37rN@OJ_W5&xU~yvhJ#USnTGlBJ=8$9VME-9P4N*bfw&)5xH&zS=)<${USyBCOmWj(-!k{I}{$$^Skk9rN%$fHmZNkm0t99F~WsDbW6 zoqV68LjDtKU=MII*1Txm+lj9+kV9CW>rK8R{zWMa`Ob8lj@ktiQ8RuAeYg(w+%Z(B zucDIm25Mk;P!asoUJtx%4y+*5fXbjhxluV1hMGvaYYWp*OEDj{cJJEjJ5k%^5NbPq zkDBo#)KUaqF%c?=x?UP8p`)Bj(xOAMX{lAxj)~4Zg zGqUEW5OqP#UgzWJjE=S^M={3c`-lr0;q$iB0i&k zrzQoxSp65%Q7crv3l78Ks9kUZ)$ln~1HWKtypI~_OE=APWl$YeK`m7i?2PR(02gC2 zu0S`OLV;T*yL+J4CLYzmc+7@#Fe|=;*>N!{5-Y7+Q4Q}yJ%1SW+)>nfr%(|+gMs)n zmczTZh`&ad?^nA`P&aDWdRNRzeGt~bILv_=sD@Xd8eEHIaXTt<-=NyPg&OD+RI+CI z%?vaMt57fU8}YA3A%Y8?Cd*Dx5Tqh8#C`ZU{z%J#F^3~!O$Bou} z56!Py(@_KY0`=Z?Y=wWJk~{p7X@4mCfB&CAK@Cqwt!JVt3SYz0g(22T)K*W}r^2g{TLY-~n7}@ArRf zmS{NE=K2DxiN{d`dWstOb5!={d}5L{7#mR!v-Kp@gxx20|G&uvg(d^F_WLmiKSwoq z3H8D+w*CNHQ2z(D))7z550~vx^;fYyrl2Bm5Y^66RIZ%Cm+;zC)?W`i;DS2*6BU}A zf0}{hLpA6_@q>8R~H5!KF8)Py#;6!hRuRI+@4 zJMg5vKH)F(!W8RV?8N;g*a2_iIt=^U{CNHyDuSM8W{HBZG4+)5tt77GU%uIWr-W!S9#`94j--CLMg6n)(2jlnn;mEB8G13QW8@SODqYVDs`vwBTO`LF=ji=*BPvDfRN z1{jXjF$Rm%zcb(7*oIZ8pF{1}7pNr3m&FXI1p40sS zFjTt@QITkYYQH^J#sS$pu7AW+xuCUPglgb@)S4egZJ$%<4?U{m8(0DVMy+w#0FVDS zCLPg7eKIO1R$I5DBJ&|ChfbjnuLroMg8+V5&&h>6sE`-MQdkcCAxD46Q5{C2LO%;N z(8Z_-tw+r?6Mc9Hm5f)dPf_g!RMVwIs7qGgyfV z{RgOlok9)Z3hKQ(sP_IuO{`#`X{QX9q+ZGDwz4-OQA-eqn%QJqpNr~f11d5Ht;bOv zpSE5@eRkYO4e)m?jk);5(vnq0EkQU|!tTg`U1uBx&1?oLG|NyU-eIqQh)SZbPz`-+ z>vvGkKSm8aM=nz@i<)^ItcIOY$ugO>LY=wHRGv?I(??<5#Mx#Qy0(Agw zN6ql8y?z4~nLki7cJi7vFNS5PRzc-XJA4@wO`hkK%^Bna; z-j~dSv`>1w5LoLC1)Wlw34-5`s|7!qa z`6os~aUp7k$59=fL51oX>bX0pndZu8*037t`7rb+m$f@;K+(7a$Dn>h%b(voAA)+m zVSe_%M%a-H>S!RUqc~fij>_iwsE}_$4eSWk#cxncmc4+<{=%s2E{?w_qCNrD z&sp^6oJ&C?%gR5dppJZ~7we;D+6(Jp8aBl(_!|C-+Frv8dHg?!Ov6FcKSPB+xUgBe z2B>yA;U-MLVpz0@iJ)7Xf|91Oz0d>I(E!v;m!Lwq0hK)aQ4M^7%8j2S8K~>rrx3=4ti_BCQ5`0tLOu~Skj1FAUXMz$ov7!|p`QB% zJEBwEOsErvQjJCpECZFayHF86jQOpNUGM zO{fVRKn>_2Dxz6Snt_%?T@Ob^q#LTg;aG|OoyqpbCR7d_MuqqSYQzt)1^$Ej0ufQl zG!Tn={teUsm!KlG5w+hlu`GU!8qiPZ|I|c%29zz${#V1zC}<5Qp+0P8qei>{wZ`jF zBmM}p;ssO)FQX##7y2+;8FL;~L=C7qY5v109U5vzQ)dg6CD32OzLlWZq8p?(1sse%pb>WxvmX*jOH zwRjL)SM)dwFq>P+oK5D6!+WQ;r{qv}Se}|gTuc##puHj!A*QrK99W_DSXk~6V9Z+jJ05yOK z_WFEVUxNzqK2*ckQA=1R#H@KkRFbyGEEtQ5R6HtD6EK(d{~QYb?SsnFji{OJL@h}s zYGx-<5x9s-o}W=``w$ypt(qR^bsT{s@fP-B3HsIYIGhb@cJ_R-7$C!Y1o0*euHBO*@0~0Yi z+|1xCYK`xpc1f=0=6)Si$E#57pTiF5jqo^gu`~9=?<3g%aTFT1@c4fvx*AJUFWl1O z|3@V2qC)l!4#4NA8TV`D@&B?a8GBP-fr`je^kMOr&579<2T^|o4>IFDcoS3GnCAz& z?ab%1)J&G6KCKSmZoG&+a87#YY6Pf1Gy) zDq@$g4YuoSB0LY%y?p+DP2m%6wC_fCGqOD0Jx;#r3T{J^sIrHR@%)N*zai zAsO}xk>a@}Se5#|-X8xiseVR9s6Zc&a}TSblCwl#vwO)S9of*L?%bzOH0#fNHoMYTI^0ZO?wFC5uKKO!JU08qT|z8@FRHeuVzt|6QY? z?Qjp3r8x$gERVnx>OFA^9!D)rr$OdZE)I2&B%#g=7sGK1>OA=r_3?ZLnW^(D>L9w0 z+TOooN$vkP2AiZ?fqkeSLY;U;hnSJqK;=dRmchZOnYs4*o2UV-M@4W8YGAvtA|63S z=vUNH{|NP5;Yjwsj?7XN^g?~qHfn=9`I1l}n~K^-Z=+^%2({*)p`N>s%w!s3mv@l^YwdEFK=p{#QdkazSTvo?+(0rwr=Bm$5hwM9p|Cw#Uh+wLXbDioZn- z>;`IPw^1{GglhL0>bbm8rd|@YL?Kb^e>ZRs1BOPn8 z!LcTkbx<>FgNjgJR76s-BD$y?T8E0a1Sbi8Hpz3%TWW_fEw^F)Y5!{is*4${{fk(>s+Ux zwRwOF(F@ef@+X;@2BWgO0V*<0QAyd(UVp{f9~H47wmuxS-QrOL9Er+_vDQhLRiFPe zC};q4Q4K6bg>()2vmG^n&rk#V0@dJU)J%W2^?Rreo}kVXXQX+r7;@4&rBTn9MfF!* z*R}r}QBX%sQOVR2HIM|MmCr;C&hfG zQET?2ec%RGr2Y^!&|+iEfU00L^?IlozlZwRUW01q zh`s)$t)E35Kvz%`cz}w~i!lsOYZ@@tgtjPZ#^q2Ut%-WD5$d@Jd%Xvir9Q~kC!&^S zChGkKsDZ3P4RAB6<6WrGe~fDXs7pa>{*ArxH@2Z3kZvC6Z0&1}vL;!_p^|kfY5*%x z5!-}Xns0Cv-oef|c%1pdvkeDRcZ-fU-(tt2UfhdQ@Cw$%p|6>rSl-4utYx>?*`_#k zg2(^Qb}u4=Iyp;DG#!RxDE0nW9cN<=p5KqHsNbCAaeP=~vVXg~PG<@cT!_T(xEj0Q zV{7X-%y+eg*o^BBusPP6Vj3KUZK?0YBz%EAFlnlZV6FBz)DBu##q0 zzEsY@rquVL2J!?ouq=yAJuhk(6vw()7d68XsELe49m%uNhwHF49#i(#rjU2B8Bqi( z>*G-!y@v|n2CRlVP&2%Y8qhtAz#2*EjJ;BOY_g&M@AkZ?6|!X0pFK zDpKxy6k1R?jqS0}aqo9#Dwl{iV2kKF_z5}&}hfocjMTPD%s>AE352I(O$dp@UzTbyn zBkDa+@6SQ?vlDQ*;lA!x{FHQKdgE7m=CWCsDV#MJ-+~};cnED zUBz#=F zZF_w^>cILCU7g`K>;qXoFb@_*Jx~LE*atP!bX3O^u@Wvs4e%gpNj}Az_z1Nd%I`Cw zuZ@~<6V&<80~MM6``G_gD8z9=Ni`pp^{Y`M&P1)m3Llv-hxM@&^(m+kpGNJ5Td1UZjEcYu)O&>v zm|UrZ+TZO^9mk^&Uq|KEGE_U8P!r9x^{Ce!_3Fu@=_CtJo9s9Wl8OgWC5CQ0?8oXw3SlY3EgB$y{fdDL9{_X8Jqo z#4GrjNtPO@kk&=zL~GQ3?~lrrM0yt^eO7#$@`^gup(+Abx{LviHgKv zTYnA5QeT8~G2cn{zmjbw1&w$UD#ZIy4S$6S=`W~kzk_%21u8;!PZ_g+W!Bt>QC#nW zn(=O|fv2s`*CtXSsHLv^HTz$MUiLzobpa|Od(els?e#q0nD2HqFof&fQ5{S|EyZ?h zj^CmpRP40*EU9U2kFi{jMjhp+PP?YT0%yz-+Wg(T6utSs!@T z%(M|IVtr80k3=PBy1hOd6~VQriDhDAyz5fX3sujVk=DWL)H|b+WISr0&an5lp_b}% z)ULQ_{S|%G|HeL8^1S&bl!n^2A7EL$joJ-)FPI3ql_}`Mr5UQDQK$hdz%%#>YJdyB zHQ!v8qe6QLbwb{^X1{21p)~g6dVSQv^foFI_w4;Fmsm1(MM-1=uG9Xq-Orel2NO}d zARQHf<*2pZh8oBf)Q89o)J*Q;EzEJng!m!q1kC!qspmyS%7;2PLT$YjzNG!%i-JNE z?Z3e1HEMrnU{lO>-4(odu&^0sDr9YY^TWdXn!8~^jD`LP;CNecpGp&b;KpWKd?S`5_ zZ}cZ8YI~-mtE|kRpcglyLc1Ros!ve&&sl%L*3|z*?S|$*o3-tTio_7ql8r`1Vybl& zDhCds27Ctf++RPl|J6a@b(7U4P-|Tc^+m{$D-LA?nkxC`;P&LK==f^;Gd|aXm`haq3DMi$m^&z zoQ6um<>;8w*B0GACKXKTHxPpmJq5mcgG;k;?Megf=&7`}IV1JQS6rqp%K6$I{yW zA5zdrFQG>M7&~Lo6Z4mk!KjA*z_kn{%Tx1Vv++;U@o7}Y4^d0>H)_TO|1wEj1r^b* zsE8$?o=?Nv^zTfdpf#U~T8j5PG*Xs|_KrYnefyo$)JFz8}$l~=!WH2gg z7hrR|gzxEoR3AQJrt1P-D84r(BOU^+fWeS;Yj==J}IwF{N3KcG5#h&oYU%IWo=oP|*Bmqaab zMdW$cX-+{S>VfKD0IK6CR0HX#k7E~=<;ze@wHXzW?@$rBgUXR-wq7BZc|HuoxE_H@ z_R*;RCZYeo|1pDtUR;b?ice58xs4jg3)I>c%xz{8jM^pDPy>lXJwMO72DOx#sQ0d8 z1N;Lu^N>7V|KA(hVyO22TNK*iC#a9t+7Y!xnBe|qY9|d*G7f9i@hI*%Jwm+I z1quqqEz}zSiApX{F|+-0qCyyix?csg1fi%-f-zO0o#l zQglV#?}vl&RrLS-{{@9ITsY&uz!8aqs6Vsy{-w-7qENXq8r9Hr)XAA)-HM9L5!ArH zK%F0#Q3uZhRIWTjMW|S5CZzpemV#yyh6;UK)C1k@^}eXxkbvrNE-Lxfq9V7`UjG2M zP(Nj_k1u1EW|DO__Tm0wd<7q(dy+yspV$BSU!<%FaVyl3*&DU}W}zbTIrhNH<-Gon z+1F45K8p(R71Y3gvED;v{}a@J3zs($DUUs=Hz?2kuTNn%7nG%+VO4a3O}1Awhs$dn9c+dLdK<+gb;rlGshA zpsb#Zn(2Jh3^$@ip1qpMi6B(RP*j6ysQvzqy}lYXfxTE9 z522R!64t>d*i7I5tA%*|e|~!vYja~4>VUa{8qn{k$YiZ)lBPJ;q+SoTWHG4wBT)mM zhMM_&)Kcz6MffWW#jCcSzZUIl|CgtrKdV--|RG_J$`SBG7=ptTx|YA6;p^J(bA_fc7W8nrDS zq9Rr)%nUpn^7BZM4@ux zUDN&KMHlUPQk+X9V)^Pu@YvlZw6cwHQ<+B3Tk*5YUFRAPQ(oC ziC>`_3T|N5x-n`1?NBr8hutt9mE{Mq3SLCbFiS)8d^uF4>!7x2OVpCP!zpOSucMM| z9rnZHs0PY6G99--z0eiaPzuGGZYkAZ}>ml2m-~Un2 z$h)ILnt3_p3ub#`w^%} z4aT;Zibb^l4^U9{pTjWx9>cLfa}&ZI*q?eTs>82Qk@yAmeC`OZ(;OpEOF0>}dp4um zIgc%|a0{=q7W-jmbXv0iW3dZ`c>Dl$P*iATz7Mp(deoEfHm*Z;JpW~LM1O#q(P7Mn z=ddq+hgyodtxY7Fqn_)Ib#ORp$=_|w{@0B5azVS`Br19C*argImx|W@$J_d1)C-xY1L-^}0)d^(_1dVU?1qX!oOKGeroJ3Q@CGWVo!S^ z9&=NliTXyf2$f8SP#yk?8c2aIUjJW8YooG#BI>i^ZPZMcp_XztDgq}_AM5u}yW|P7 zB(77UtLdN$s)3%UWEq0$Fb$PV%TNc;MpOg)Q2Y7{Dx^*~b3YUVs79c^541&X-`=Rm zO+_uu2N}6^Gye%=?Ph{~t0Mfpw`r$L{p+)ah*+o`%|f%TZ_eN2uJmf$K52kJta7M*>;HGhiu5x-e9l7c^L3~-{}nZn(EjG!colurC!q$s3SF(`K??ct3hJlW`xwkj zat`qN|4oIO1HJx#Y3!giY>=7JCe%Q)4>l1A#Y)s$pqA=YRI-h;^(CmK*ktS94rc%B zfty^=%ySGep{amMmj0;oVHkG8aj3ODj#|U7P|y8@%8}nt9Y015EFjXfTMM;xolxz? zpmOH*NY@;l+qj?u;T|e`^9(h~RUVa;t+6b2M{TcE)GnEe%7yn(1KNr@2|q?f_84kF zH&DCeu{Fmq^W%PTmqK@LjKl?a1mD2EQC=sBCAfk&sCSC-^4m*(MjY;SKB0a+)-1_} zI1{nbubL$8iA%UY9@}E!5oQSnqdp~FjKKGCn(B`zWT7xF-aI$~4^v-;dSP&a$^Ln$ z1LQa=luz(`3`#T&-otj(LzDEingO7acRx1Ar>Nc4aHNS$Hzd-oGm^ra+?a;i_d%n~ zPc#)!+a%J~Gcb($87xI4awMDI3sg)oyI}-|bN?x>!3L@3{uLZgyly{Py5 zkMsKfm5Uju-1!qVk%HsB{QO6u9fk6^9kmP2p!Vl|OvT)~ z!qd$f55fv0%?A92>!)TgV3uUXOzQa2Ec4B2&K$4*PdlyVdY$Dw_anB#$#0PxJa_CZ z_P-8zVR>?Cjuuhkn1-VnCtg32lc-(8nZ6+^51>o076~& zWSDK8)9SO<%V7WOKvmM=013rO1`~@puj`z%o7=k*``d}G!qbMjuQ&6FvjXKE|p?1X)~}Qhb5Pr zqc(+J>6(ReS##YMYfh@)~FAkeyGR{MRk;cO5#nZlkgPkWX!Y5d^+|`D)pbSAilK5L?RduQg4loG22?xaRlo8=!u%hC|rwU?e)^@%yW%V zOV%0P$`qm~=*8Kn&~L(EyoL%@zOz3aJHD=4G-AD{#OXAZ8amVgW7hDP}{8)>V9|BhfY7#Ql;4Y>8P1ZM;|Ul zZNG!434M!t{|{8ipJP4@+-CYKvCTCNmg9ofyfUhTP*k?ox7Qn^2G+{j8P#wfRF)4x zy*~=o@i==w1J&Mg)H$;jHL;IS?|tD?P($CL8u$r)cpLR%p6y@^cT-S!uhW_f+_X;IO>c3sm`W0Mkn z!;%tHlH#L%(TStul9Lh>XmVHSOI=U{(J3KKh9)=D|3xIG z)2YT2m*`7RN=x>oq%*DrUz60-W?lRCY}=)C*NC2No7AM5@o{xep<2FSk%<~=T!KQ+ zU}IStUtEmdt`x z{(sXC@x`V1q7z1@rdRi+_{Xlvr})$*uWQUEFdt}yPifV|e4+Ik)vA;ERg&jW3r}?Byq%uv`NATSlOq{Tv@hM4k`^7GO1{SX zCl}9NNK1_LPi$D+kzI+}8UBqCo%zub&(pjan@@X61^UBF*djCX&Gr_}`9G_edGxer zo-d>GBhQ@wx~iv&WPbk0bI)6-UE;9#w76t-$lgdwPE7JeMQ5!1!&4wL^ReexzA8?O z_@q&3T9p5cDLRpy{I4-Z4ogjojL%5Q;jNSNe=cV(&f$HO@4vduoLStPJ5T1OO5V`G z%T@$BLWIW}Ok;#$1p=l|@ zm{`J*U2Kx16lQL>&3|@FOC~ldEsosM(0tJuL9@NZJO1B0MX#o$`BJpS6NpV(v@Y

iyW19WGhebDBIMR#w-|rNM=})cSDiPE7QE$0|L!PVQ3i-W_e3_{Oh?n^Ml#m zk8-tc6`eBl-%Ftl;SY%Ozg^Xwv@!l`ts;F%sa*Zf3z;d4z3G9O!&iHActVomlk8C0 zAV~>BlL>}Drty)Zi4n_A%5#WDMzMcVG85K#w`A!(Ix;yfawumiGyHeT{6992FFGal o$V%Vn$YDpeMpoz0V)AN`1dEMK_a!PMs@ni1XBOPx&B*${01}Tg?*IS* delta 28529 zcmajoWnfjuyZ8OIHy$8B@L(GWng9WUySuxG5C{|kB)BaIE~R*i6=`uVw85oFX@R1p zNTG$|+CrgFf&2U0Gv|5E{pvn%J~Ok|8oB10wRS@KKY!c~*nBO(y%m&vk;DJ(OzAke z@rwx6{{R0A{m5}Tc^oGUN8q#pj+2v*KMZu7w3sv2aRM*|>B1?E$*=;Z#wwT>>tY&= zvJS+qj^jEbDCow^7>qY;{SBs|{+~73Ajb)$o(45w4%FubF&!2|4OkvEPz`G%)OBqz zJ$6D}*B7%fzB8CYDjKF?a{R>B7ou)lZtH7M6WwInccMB-Vt~^%u6DZ?L(p zs5RVLSu(y;hk`=d6g5B}EQ|v&7cRso+<^@+%Mi!ufL*Z{?#8N^JtL%3}KXv zSO+U%Tdak1ur{7YU7vZB=8;I{rM0_*bEjYcvUi9Z)O%1l7SS+g^T*<20fk zi@JUfR>VJ%ML1z&O=Q*}YjGaqUre~?IHEwkBVjIs=P@THn_#vq&jcEB(@=*7b<`6} z;E!094m=ahN~$;*=JPNboOw5d{Fox@QqqZ>5WXCCqg;3YG!D84OIY`bN)am)# zwI8IMVzRh2rs9KYs2l5{j#GOK!p^9b3`8a0Dy)SYQK5Z;noypp=K63{e|1sUH?_9I zOw`@(6g0qKY=J{j$LBcexcq=x>Ce~pb5JdSKCFV}u`8Cq1sIBltxqw4 zdd}JA7#G4|>cue~*28i-|E(z$q+uG?z|B|~pP&W|`q*S~2@Ih=7=5@H6{#fD#E+sv zd=(GjZA^=+=9uHT1=Zgp)P3Ghm>lCf=_n|~g;9H66-#3?)PUnrS^ovCatF~?kE z9aJP@QJ=5Ha(ELpp`dx@$(ak&Q!kFoup+ujmdg~BTo0}PU<&Gi^UV`63#y}>sE`)G zj2MPmVRa0|#-#yo=haCs-3xE-=~J z0Cim_R0yL{*AKGw;noSL3D3kV9&!UU;D&`}LOW3R?_Ws#RX9OI2|Q;%_{UzDVv$){ zMpVf2p(4{5HE>JR{T)!b6N&k7HI~GqsL1?o{eW4i2QD^|DCANIrJ)#xVSCidC)@f; z%uoF+mc|#TB=RMi2T~<#d(1+6JeJ4lsQwOPOMHgSvHGXxLG&>yx7;KOr6}A)tt|Z# zV`kJ!a$*LognExRLhXGFYEMU@ws0C|#`%~X*I5r?PU=@sTlYH_!hexd=Q{b9ngQxz zMLuYUTIn40uKvB|XY(pO&!xDHGwFMbJ zH_wCY7)-rB>iS5Rf(9Or>Tm|?#$~AF`xbNKkGB32b)EBt31JW_=_;Zk*c!EkQK%Kh zTW4c3>T6NsZN!4;9-xq%!mp@-?x8~FU28(07qzz~P!o#46j%op@txjR zpP;sG1zyK>s4bYd&c6WH*+)Tp`YUQi4^b0Ix86*k7-~Z0ZN0Uv_ptRbsDY+qLtKoF z@E24L6x(2uxdEo6-UfAjXAD%@$58OmFaq=Ar}lzth<~QK+mQh`MhG>R1g&Jy&L-uKOGnsjc?&lc)*a*g^c26o1j6nY~BN zG;k+h7CcM>)zOJBc|7Ay48R?`Oo#hX$$A+z@E_KcyUm{G!f@InP!FKK7=%+XGcIu{ zXoXubHSR?{Xud&RcnuYqr`R3eV`uF4m09r~)cprh5&90b(qAwIzD0%lJ${Dizc&4D z!7S9>!xa20z-Bc3fy&w%NoG%bUNy2i0G1)N!1Q zio_aIcb%gYveEDhM&KLNR)p>~kIJg30Xv{pJ_Kvx8B|B<_nE8?MI~idtb|ih5j}#6 z(08cof5gK0CkE^M=h|;VQXZ8HjZk~s60>3#R0xM)DV$*4f!V45ikk3S^kHhwrrL{P zMyzFRjk+!hb6`A1FupUJg3jYNs7O4v7kCbu2?V27QW^_kCCrK4F&IZ8?-^$S>J*ee zWL{oXPy>!eCG||ygg!;()JAmcQ`k?T9(oU(3mRHG;RjAZe@w^c{g0YZjzB%(CZQ%g z3w6xapdR5{P`Pp*711lGfp6kDe2$vfp>K%4X8gl9Cghh;p}vKh@iSD%|JZtpVNt(Y!Z;O`gj-P)JcY&aD(1yx-e0M2@2#rRT63rnzWB-T+5YKZ##q%}YFv@eQ8fG#tBZ9%wVJ zm`Lr!qWTPfc+72=bqiCo4647zHrUxZ^B zcnvCP^IbPdRuPr$HBbv{gFcK#T{rbQ@mEM!(x9wdi<-z5RH*jY_EV?_(>c^cZrS#y zs9bo3ns~(<#(Jobw?=JIXWKpmbt=ZAUcW0`3R>xxsF0sRMdXrgzlo)(ze25`(9h=c z@~8>c#EjSyeHe*~Sb}X|Zry4`b*3R2uOAqzrXP#86EHPnDDP&4m= z*>NZ;GPAK2F2gVJ86Lq^x6F6ONY0$v_u@Pp@T>nzuj{<0P>qJhw@ngGM;){67>?Jm zC#Jh&zSj@LO4O6EKK_AKvEpwgCx)T6@-1p&A5akrylWPa4;ATBm{#Y%GKIo4)JJW_ zK+J`cP%B)Cia-)3!|yOTp2Z-%hb@uU%s|QRn|dHd(Owv}#WOJz<2wr} zsDpK=(3%IT;HEy7~B982Iy)E>V??WyOn`G%AZbze9JV|CPX zpc7WdfvA4AJSP51vMV$c$J9^E@hOXGs5e8kcR&r4fSTw$OpA+A9j`&H{A=5O9F8M z)C3=)7VsQ(-&DKy{pe>UbLJG|a;=T!evm z47H$(sHFST)>FMOD=voVXm5z=8Q*C~L3i$f>o9l96U+N|Ovj2-Iw4~uZY9cNEFgNzcy3}1%b{|I#@BsCQ zeTnKg-Al7Y*-$IXgN3m;mc&*V;BlM@sEN&bWv-ixK|22{C}@v&ptd3jb?gpfCOm;& z{0Vj4byUc2qau>wPqP)dP}k+dgIECd`LC$!pJEvder(F1o`71}Czt^@ zVrATG>wltFocxX1fL@&)n5Uqa z6!K@Nj$Wb8?cb<9&;Hh|qyXx=P|Sj5a0fQ9?Qc=nzqh7&XMTRjf=y`eh3jw|hG0AQ zZ!_aDsJ)qqRq!C{hId#8>-}Tielt*8b_q3+tEiRTvi19@EqRLm+~AF*2;@RVwj>tC zQCJ+^H577FIE%XRG3xxL|IdWF6n;v*2I@3i#c<5?-VEFV6~PXe1AC%EI|{SmG|Y;h zp|)x_YGNm_u+IMl3fXCRi<(i!52nK^sJ*U>t+B1Gug9F!lhB73un@k)QkdQG_#@H) zHDC*CPt@KIv5v>ojL*L>di>Aug{V+&@PD9v_4v>6UJRxER}9AV9@AbFi&1ZZI;Nvg z***(3p+&ZRA1W!&peAs``Vjr+|22hLe305}E^LenX(v&{lS`jr+ zT~so4KwTG$8sHNQz{RN0uf{yM88zYWP!qd>8t^e{3$ms(6D^R^H6bcZgH~D-eb^9{ zl>Mv|Q5`NpotDk037kTO_8jW^$EfV~@=F2r7l_)DY^VhkM}@u?YGNH+3fiMSs2hi) zI!r*VYz3;L4XA;4SkKz_$EaiX61B3lsZBixYM^jbWExmop$6_^b^BA$dwdjXhT||V zE=29wF4PvB!XkJB6^UeN%*wJ_i=if5&DI;Da;Ph+pJ-bjjkFNocpS%6-J`k<5AZqptkB`)RwQqe2njW zO+m?X1?%B+RMM17Zw79Nx}mkL_d=~`04g#wQP(d+UAG+-^24_MGU`}9L@nqI>fMnn zkn^vZrlFva=0bH;9o2Db)D}dco@leN4emruAVmiAU@C}OVLQ}7JyDU0MO`-<^Wmqc zE!=~;{%8iyzmn?=4Vv*UsQRC%iKXH%NtR+x%!RvAH=ajbcLO!i=coZcpax8v$<&LX zk~#tv`lhG}Mq>oVXX5;8k2cbvQ?Lh>=oO znu%3HWp6uFhAU z)JzZA_S>k4yh06_CYQ;TLa6qps2u2p3h^k^gco3KT!nn&aehPHACQ~t8Q%$^pb%F@ zg{leafzt`|V-#vaQ&6wZ6{vT?F;vIDqPDPL9`mj!g<41j)E+lN4cHy^dLM;~;COTu zqU98PxE}RjIE9+f52y*;L?zn?)Ih28niXb2ZCy#!gc_h;R=rVg#{sD8<58!@#Zdel zb!^Y)<^1cy+capPhp6NW$Y)lX2X$c`R0nNQp&p5f)EvBq%TXa8?(;Y=aV|#Ur2HNy z5$|DJ99O{OOvH<*2U^>LuE+ml_Nam;R9~Y)cN$CJP1JEpQ^@12!UlKC-LDJ}&KxD<6hx1cUOfT=u81V>Z9RMg}D z2Z!dxJpR9MJcxP}mo09RbQ)?aH=%OmE7VrpKn?Vp{roLz3zC&Ex!{(jpk#|c?Ntrb z1x@S)BT*9?hg#8W)Rvt@ZOvKKK(}oBecS#FwWaS-6Yzza>myM0`bdObrwauQJPqsM z0@Q#%p$5E<+FM_k>7X(ydz)Y~?1zfrKvV=>RDYkK`dNlb-c6|N--XJdJ?Q`a-wzZN zy6dQUoMSi!Z`gW=%I0l0 z2sMFqsBFK6WihCV$({O`Ks_F{(lt4%u796Esy^%9wShZx`29_{a1_g zua$PL?eYJLBn~@JUxkXqTl8UY9rFmSg59aN$3q^D89v0J_009%8kpC$i(1G^)LZo+ z?#64VV>_pz$N!H~cU%fRXc*SW<4nOHaU^zWY$kFUyHO8q;_?4^?o3p~u44mi)YOD{ z9**&{=lBinOv~O?i@&7AW)i&mvmHRyfeM5bO4Yyf#N2%MPeL>xFty&B4t0DW=0MsAu_J)bYKAIt5Qr$(f>yN$%wBgl;D3 zR$xc!2T)JEtliDTi=%R*I{Lr=ccq|}jkO<4MNQxfR0ubsCbk0$;}KMZ9$*3d9d%vi z9_C3IjC#VALq(`D>eO^VO?)zHi)W($-~Ya%P>_bxs0&|MbN4g@H$sKH6Dk)5Vt$;0 z>S#Uc5quFt@Gk1QfJk#{3ZNEP4I5(v)Yi_A7*MF*|DvE!Wi<^>Zq%{LkJ_UUR4#;B%cDB3fto;F)cvh77(1aRJ`{D`Xw<}9 zRDbhO3ti%`bN;`epaHg^9vokxZu|jxqB@sQH(Wstc-ywWKn?T~i{V?;L<+>3>x!Ww zR{^z!%~98PwDs;7!1zuK18%DVh9EdGWV56S06N`paDALHIk&CKEOId%**3goH;ffhnj(gTN6+N&OvQi zqHSM>g{iMcP4oxUgl=IpK0z(G)iBP#-p`$enT|%HKA33hGf>GiA2rc+s0i&w?dg70 zXwRYs_zAUDcTv~9KwbC7wx=F$j&EkvbrHi^raGuegF0x8T2TkoM0%nIjzNWfIO@hR zs6C%*>pQRk_5HS0Qny3q#VOB#mZwn{y)iXHqJaT-3t^n;GY$=WI6b8|-8{1%^NhXvdu@m(p*c5{&^8~}bI1P8ALSAW#$610Cu_EU+*HrTjs_k_1 z#pW#P=ZEAo%vZd;n4fx69IW?$EQMM${DL2|;`}pBNVm^2@9`_BpLAZMB9(Hs>97EP z!u3^A$##aDYv3i+Y05mulmD)Ap#$xR_I%7RuYDKB-chCcES2M7R%r<)P&xkLYhC(4Acr0!ERUrqfrrAp(g&j zZ4X#!ek99@x-J@(j6+fV%s}-!4>jIWX$Jy~=b@2Q^SfRH)-n z9gaio=@irx^i$Nceht?4@H|0X|JQ1>CEhh=oD8Tf%7w*s{)9_eQ7h<&%7Kxn znSX6Pj2iG1HpGiq6mxxUwxllVH9O6^6@An%p(6Pw>Ph=Imeu(W{lYwGdSY4XE{5VR ztc$;*4@1@(>s$MxA~PK|(KV=VyT4*f%(Kq?*d2@N?`PC0NweNWxDvXW(NGEs>1Zs6 z38?H<$xG)DINtH<;IPK~#h)pt86==E3e*7{_9M{2cZAK7n=c8Y(h*Hkz%gvytlXLPcVzZJ&c0cs(lF_M>v+qV=x* z{LLoUtR!Hw={P59rNyi@Py@6>MPei>>2_I9qK?xwRF1qs4g3K$!OUCCz@e!7s#{y3 zCf3`fkdML`)WFNI1@1(3=-Fxvux3VuI5+Cl)WLSx0+rQkQOWtG^#H2>lc>nuM@9Gz zeu{36ZDwXWQ5_yford$MB)Wmxv(W9v)>w=BNYr)vQ1_ic<;(^A9G{}TInCW+2K)?5 zQa^&)+80PfTqo^Lv+}&C6_-OjII5vm(%QCnwe@JMNBbbuz+a)RKZ;t}74+d-)Wmas zX--iMRQqJq18gm3*ZJS#FR*fql1yZRuo!h8 zDybTyCfFG@;aJrDlkhEmj7@Or9y5W9sIB{Z59ePiO1al$X*lYuRx4DL~v=CJEbFC!kik8TG_Fjmnj~sI7d8%8CC_Pr#sKCRaj` zcGsyyLD|?GmDO!gAsd2vrjJ9-JP{S*gQ$?7M4jWysDW>w{|6b=mSsF{_BapfXUrO? zE$fcDZyNgl_dn)P(B7`aa$X)NsFgoH$xJ-;BC(9BPuB5>Q*-{VN?!QY$TaxXwdpx|jU;ZRg) ztD-t?g^FZ9RF)6I=Qt4+p}}X2Gf-Q-2HoBiE>O^l%bztrBDJ+9phC3+wZ}=;pR9jb zv;SZsQUQHj7mK?76Vz9_?Wh4SV;CkoXSO2z9Ou6#4V`IFh?b#VB0H^TFoyaA)Hk7) z=S_!;P>>_S$12|i6QOpf9O{lf?2pR!S*V2`Kt=5O1=rm0k_J7&{;?lqxM)IH z1hvhfp55Gkn$1Aq|Z&WU2y=1aK#9AJGv^T(x7=yL(s7pb|D*0uTToqAS z-x(E=p{SM4L=AKhL-96V!oVNR0`8!`pgcj{SNMu~5>~agLFGVy?2MC84PD(g?6BJc#Yw|}FOv)FZW{>z{iQW+m%TU1h3zhNG5 zEs?s*e;Q#18i0C0jI;H*s8FuP{J7KBFQLx!Z&(ezKbwiyL=D&pm3;B2NGw22d?V^~ z?MCIo1x&B^zxSre@{FhlOLpvwA=nr{#^!hxXJC;u zQ161)sK|^$MKl5Rs9%677~fezL3{H#>cRu4EIok=*>9*DU!w+0dD}!P5cPR}Ygw#M zy&fw2XQQ_EQ&c3jptkHAR3t8-tHN^%x-jh>Gvj=y3+tl>XouR0zNoz(fm+!*)G<11 z+wY-rBg=0l`6{Cx#jQ}!k?yDkMx!DS`y1z99gn0zPqOK#fmdJ++=6~{-Z|wen^M9Czix1cW_H_P3bG|n}GCxkA zM4kV@$L4E!J=9h#!UDJsHIeU7TX+$bgip|iDV~_*D~wukxU~vui|V-){2@YRX?Ik} z`=KH-26f|H)D|s8ZP_;Lgs1K2Ax}*|rBKg{s;FEUgt~8pbrx!ZD^c$pcP|AE^dlci0fCKl3;%a2oE$kmnxfJYGi+5!?KNFDAJ4ck>{d^3o*XUR16)ugq~Qg$<~- zK%KHgWIWedOF`MW8+ER~N6qXJYNjdvG{-L=E}|Zb>LB@RkHZhTP7vm!zUPhEimRxt zxQ$xa->78G@Rx~Pc~oTDp#SfGyHL;;^hWLN5LCz$Y<)KB!L$Ok*E_H!?nNbUz*}Q5 zs-NPhkXJxWvTB!ws0iFbE%+&FoPRv7*MFQ!c)kAb zZVfSv58_avTZKBONvNbbhB}_7Q61gH>?Gk+)UiyS+zgZ-^$0J86R{a8r_Q0SyN1f8 zH!g)D6h5Gmqeu#KVKr0-4NzMUjXiNRY9cpq6#j<#V$v<8*Z&jK5>(EdKn-*i^#py5 zdP4q#nus@**T2PXItsd>Flt7XPy^IP4cr*jK@ZgNj7DYoWK;;}p(1h=6_HD*9Jyob zX;Yi)gRva#MNrA!6&cTU`cf!H!(h~aOHq5c9W~>_sJ%al8t`A#ghSI9YoNBQHR`?z zSP|!+Ai$o`NRu9JR6>X-z0YP%CSNicA!0-~p)6x~LT| zM{V6^)PP4(Z^0X=2bm|G*MI)=pxSGrA{2v#8Q+;lK@-@6_3#dA3yP*UmPUOMsc7qy za2fSks1+3n^!k5w+X?;OdQl7MZ|fsa6P<)wz^RJnI5oC_x zDQrRgchnx$%xqTJ5A_lmgNnc!RB~=ZP5dj=ijSebhX0AxcrKL6Vzy>rR?|-$DoH1! zws@gSLCLioQ{Y-u2shgcj-v)Tg9_y()C4|Q(`GXrWyc?A&xab|IV$VlV@<4|-JFVP zsBw;-Q3t!t4di z01Z$fY>PTx-B4RF1{L~`ZTlwF6Yvx&Y44&Qbibpn3ogj{FGrz5K@-{m)&%TE`=_YW z@CKDM848(WSPIoqd(_)+0%}4BFgu>G-bC&BYt#hOgqR5CLiHOG;+hpyw;y!HYJAWK zb&kJ4AO3_LY0UL(R$R352BLq8Y=tKgqqJYqb6Jowes?)J?(^gZp2|YPO|l{Q2n1o z<;WFGqxb)F3cBzu>R1#HGtYr`))A->FF{T8ux&q!3hfKjL;_3N-) zh`Oi+F2oEv|C=Zj!F{M?x`SHjbJW0ZQ7g<_%4|(7)Uj)dT4{e&XOHeto&w2)x z6Sq(ic!}C7Z)wiII!a4HD=&sVY>Ue7VW{J|2(_o*qh@{&b>Bazj&p{a35KBtZi!lX zFVqCbqayW*ZQq9acKv-g=f5b0+cfA4ML-#IjB24KFcj6nLev(mwV(fFKMyEt9{Tv2vV$JsPXg&=!ZGe`ct?zKxo|3)IS-2(SNt z02Wk&9a4M%4AEP@%qtI;M|M6G>m*e6J{o`KdQ`DRia~i|XJz)W8o>H@rc0 zl&*s5C^u?_HBnjI4wdx-(T7t}9j`|n%kNMt{TX%4e@9LHEh?gJ=89(EdZ-S2qmphc z>ZjH<*ayEwWp&9)=Ef$d=fpTv_D{DiLM7`O)XGnwlI;;HGA~hEk*u;`{`WsA=)xMP z2}I#nI0nNoyo$N82kP^|s4bd~TG>j}{ku?G^b;!i9-#(GQPo7OAnL(W5!KH`^#A<7 zgo3hs4c5S)(1+Qpd7Um;5jEgKR75tTu0M}8F-3K=r}a^%Xb`HOrC0~A<66vH!|VS& z;&F_jp1G#i8Q@YFOF^N1grS(SmN^Y&@hSEGsDV4wHhVZ0l>?J81unr(xDvG`?@*CQ zUdLRQ4a-t5f!g~Xs0EEiSI1!<1trl=d%^eCdpMeQr>@tj1!GXh^&mFD``8pq)-xTC zLFLY3)LtJzCG%OVh&NDMlBK?hRGIpmf6crb4a)u`REJkkNmQ_b>8L)IrrrT{-E{Qf zdaR76Pzy-b(BwpJ)F~*9Iz_Ef_s5{_UxL+ePeabXlI0BznpsdIvjwG5^=_ye#$Xv- zii*Hl+x`ZXOj#S72!vT1Vtv}9uq19mMfw`5zjvtna=1-QLupJ$Lkld7T~Wz22{qt0 z)Qvx3Gkk;ESC&Jk+`0g*qksQCsp0s(<$x1-*1~G&9Lk2sL0O zR5JBKJ%9$HIv9uAn$@U~9=D&rMtvPm(cFA5NQXMUxloa7ggUNcQP<5y7V0{Q6cma> zs3f_9T1kc$<`jgWR@4=hRKqZxhbJ9s>lU=+bL!hs$MsYzud@{kwf6e|h2tX5qh6|w z`8~o>Y)$jRFsI5#u|9}6#nu0pojfGgr4>*VVt4?13f9sXd+4vT_ z(jMEzO!PD=BCoLsrtE6Asw67es@r;Z)Km~YUvX5<)I#N4TzAgD1{hC+zD%CM zns@_s{Brj&xlkFEbS+Ug#-p;^MLp40qfXBOR7Ac*P4qhI3Hclq@;^}%&D_(Ro`OAH zQ;49U9UnA6CC3U}gumk?oEqtM2C+2(y}ZtS>Z7B)&JOC4y}iyi)Pwq%E&34^$tHcx zF`kG^sc*oBSSQA8&1?*(zQv_bi^46OioSkc|DRH?NA2NeJc6fDH_YyDPQ@YAgXJ|U z#DzZcI=8SIs=wR=%x}56V|D5qP)Yp&YhuVibL!ka6cn2Am=#yx4BUl!$yAFqKl!vq zt!$31A4k0fok8aJ1ra!#>S(NisRn!XzftToz`FPp$6)vnumArqz*eMx{`>DZb1aIW z-cof?p&x;@F@3yQX-m|*!9|^pGZ=|ohI*a;cn~?wPSIf|`8J>?Z~_&n{=>cg|6*bZ zDrZuU&;q!lAcbfeI-;I%hfvvj2X$UQ;84splBDzU9KdM&a)uHyqW^ za=ea5FbyCIf)gmr`D)D1(Ch&!WkK90x2m?_cxdafdlq~04f;hX5g zcNl`fpPDCRP1J*|ze^z>h2fYQ7o$SG67>YzggO<6Z2cuFGAWms4ys@%^#-VZhN2d< z5Ov>L)P%30j_*y>lQr{Fvrspjg5FjmusrU-0{8&)W56==didIdx%mO4ICi044YjpPFcYprBI-JOD5Rv}5Gn#EP~QiBz#zPD>+etz$+W_} zd_qu>2t!@h3zf9PP*1!i*a)wp-i`%6GZAZym8rMKxjO&zD8%c6mF7{p7>iTij#=_`n)XaxYa@*c0e7ok*Fu;eAM@X?WpWdLhbc2)PO&t z`uiER)ps^>{?+h=24(3V_Jh}`iM_X`-efusLS=U_s)G`!fy>#?TcP^vgnFLzKuvr& z>b}XS2`)n2zhaYXR0C0;*C}mR zi8BH`1#CHKdw^$C%Jl!&)k&=~c@lGnM#aPpitRT#+G#T?_J7L0HiJj?j*cr5?;9LB zG{!feU;I$th<-z3792g0Kk$EDSF&Wuq`P6BK7mP1s(Fe8{m)>-TX;&QDj5I&?l19p zd(X-R6Siedn$W?sH%-!zNKfBXF~j2f#rE~txt8%o#`g9NjfwWfM-PaO8XDc(%qb=^ z-q$X+U(}%9(Y~md$hb&8ii(UpB=N|!0aXJO%`ec|ORhesp@$9WFd_CzKQp6<<>^lY~$XQreo zCp~`!2KoBLMfM#S9XoVTyf4z%xJ`@ZNuz)C%=0B?dE=RqboPzsXK&KQcb=1(63?gg z7D`H$&igo1(!Y7UY10SQii?WrH=O%epov9N$`al(X}rColPZ++woH@MubQ`Bq3mt? z#YXveFfz{hU**JYqrJH!d{HFFfJoo)$hdxyy#_=F(3xfv7dyz;vQyiJEtn&;h< zHfiGu@5nT68;ue*jFI9ZeZ%6}L54G#TE;g@6N(!)G}7#76mgC09jTcP?H3)(ZraU^ z{{J@E>}tzFalWCEOg_{X9nanVE?7rA`+00 {0}doesn't have the spyder-kernels module or the right version of it installed ({1}). Without this module is not possible for Spyder to create a console for you.

You can install it by activating your environment (if necessary) and then running in a system terminal:

    {2}
or
    {3}
" @@ -4505,11 +4413,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "El diseño {0} se sobrescribirá. ¿Desea continuar?" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"La disposición de componentes será restablecida a los ajustes por defecto. Esto afecta a la posición y tamaño de la ventana y los componentes.\n" +msgstr "La disposición de componentes será restablecida a los ajustes por defecto. Esto afecta a la posición y tamaño de la ventana y los componentes.\n" "¿Desea continuar?" #: spyder/plugins/layout/layouts.py:81 @@ -4613,9 +4519,8 @@ msgid "Enable UMR" msgstr "Activar el RMU" #: spyder/plugins/maininterpreter/confpage.py:122 -#, fuzzy msgid "This option will enable the User Module Reloader (UMR) in Python/IPython consoles. UMR forces Python to reload deeply modules during import when running a Python file using the Spyder's builtin function runfile.

1. UMR may require to restart the console in which it will be called (otherwise only newly imported modules will be reloaded when executing files).

2. If errors occur when re-running a PyQt-based program, please check that the Qt objects are properly destroyed (e.g. you may have to use the attribute Qt.WA_DeleteOnClose on your main window, using the setAttribute method)" -msgstr "Esta opción activará el Recargador de Módulos del Usuario (RMU) en las terminales de Python y IPython. El RMU obliga a Python a recargar profundamente los módulos que fueron importados al ejecutar un archivo de Python, usando la función incorporada de Spyder llamada runfile.

1. El RMU puede requerir que se reinicie la terminal en la cual será utilizado (de otra forma, sólo los módulos que fueron importados en último lugar serán recargados al ejecutar un archivo).

2. Si ocurre algún error al re-ejecutar programas basados en PyQt, por favor verifique que los objetos de Qt sean destruidos apropiadamente (por ejemplo, puede tener que usar el atributo Qt.WA_DeleteOnClose en su ventana principal, utilizando para ello el método setAttribute)." +msgstr "Esta opción activará el Recargador de Módulos del Usuario (RMU) en las terminales de Python y IPython. El RMU obliga a Python a recargar profundamente los módulos que fueron importados al ejecutar un archivo de Python, usando la función incorporada de Spyder llamada runfile.

1. El RMU puede requerir que se reinicie la terminal en la cual será utilizado (de otra forma, sólo los módulos que fueron importados en último lugar serán recargados al ejecutar un archivo).

2. Si ocurre algún error al re-ejecutar programas basados en PyQt, por favor verifique que los objetos de Qt sean destruidos apropiadamente (por ejemplo, puede tener que usar el atributo Qt.WA_DeleteOnClose en su ventana principal, utilizando para ello el método setAttribute)" #: spyder/plugins/maininterpreter/confpage.py:140 msgid "Show reloaded modules list" @@ -4646,11 +4551,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "No es posible importar un módulo con caracteres que no son ascii en Python 2. Por favor introduzca un módulo diferente." #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"Los siguientes módulos no están instalados en su computador:\n" +msgstr "Los siguientes módulos no están instalados en su computador:\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4990,11 +4893,9 @@ msgid "Results" msgstr "Resultados" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" -msgstr "" -"Los resultados del perfilador (es decir la salida de profile o cProfile)\n" +msgstr "Los resultados del perfilador (es decir la salida de profile o cProfile)\n" "son guardados aquí:" #: spyder/plugins/profiler/plugin.py:67 spyder/plugins/profiler/widgets/main_widget.py:203 spyder/plugins/tours/tours.py:187 @@ -5910,13 +5811,9 @@ msgid "Save and Close" msgstr "Guardar y Cerrar" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"Abrir e inspeccionar esta variable puede tomar mucho tiempo\n" -"\n" +msgstr "Abrir e inspeccionar esta variable puede tomar mucho tiempo\n\n" "¿Desea continuar de todas formas?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5956,11 +5853,9 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Spyder no fue capaz de extraer el valor de esta variable de la terminal.

El mensaje de error fue:
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" -msgstr "" -"No es posible mostrar este valor porque ocurrió\n" +msgstr "No es posible mostrar este valor porque ocurrió\n" "un error mientras se intentaba hacerlo" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:621 @@ -6540,8 +6435,7 @@ msgid "Legal" msgstr "Legal" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6551,8 +6445,7 @@ msgid "" " Hint:
\n" " Use two spaces or two tabs to generate a ';'.\n" " " -msgstr "" -"\n" +msgstr "\n" " Ayuda para arreglos de Numpy
\n" " Escriba un arreglo en Matlab : [1 2;3 4]
\n" " o sintaxis simplificada de Spyder : 1 2;3 4\n" @@ -6564,8 +6457,7 @@ msgstr "" " " #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6575,8 +6467,7 @@ msgid "" " Hint:
\n" " Use two tabs at the end of a row to move to the next row.\n" " " -msgstr "" -"\n" +msgstr "\n" " Ayuda para arreglos de Numpy
\n" " Introduzca un arreglo en la tabla.
\n" " Use Tab para moverse entre las celdas.\n" @@ -6661,7 +6552,7 @@ msgstr "¿Desea eliminar todas las variables seleccionadas?" #: spyder/widgets/collectionseditor.py:1042 msgid "You can only rename keys that are strings" -msgstr "" +msgstr "Solo puede renombrar las claves que son cadenas" #: spyder/widgets/collectionseditor.py:1047 msgid "New variable name:" @@ -6781,12 +6672,11 @@ msgstr "Dependencias" #: spyder/widgets/dock.py:113 msgid "Drag and drop pane to a different position" -msgstr "" +msgstr "Arrastre y suelte el panel a una posición diferente" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Acoplar el panel" +msgstr "Bloquear panel" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6810,14 +6700,12 @@ msgstr "Buscar siguiente" #: spyder/widgets/findreplace.py:123 msgid "Case Sensitive" -msgstr "" -"Distinguir mayúsculas\n" +msgstr "Distinguir mayúsculas\n" "de minúsculas" #: spyder/widgets/findreplace.py:129 msgid "Whole words" -msgstr "" -"Solamente palabras\n" +msgstr "Solamente palabras\n" "completas" #: spyder/widgets/findreplace.py:144 @@ -6938,7 +6826,7 @@ msgstr "Expandir sección" #: spyder/widgets/pathmanager.py:79 msgid "The paths listed below will be passed to IPython consoles and the language server as additional locations to search for Python modules.

Any paths in your system PYTHONPATH environment variable can be imported here if you'd like to use them." -msgstr "" +msgstr "Las rutas enumeradas a continuación se pasarán a las consolas IPython y al servidor del lenguaje como ubicaciones adicionales para buscar módulos de Python.

Cualquier ruta en la variable de entorno PYTHONPATH de su sistema puede ser importada aquí si desea usarlas." #: spyder/widgets/pathmanager.py:123 msgid "Move to top" @@ -6965,41 +6853,32 @@ msgid "Remove path" msgstr "Eliminar ruta" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Importar como" +msgstr "Importar" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "" -"Sincronizar la lista de rutas de Spyder con la variable\n" -"de entorno PYTHONPATH" +msgstr "Importar desde el PYTHONPATH la variable de entorno" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" -msgstr "" +msgstr "Exportar" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "" -"Sincronizar la lista de rutas de Spyder con la variable\n" -"de entorno PYTHONPATH" +msgstr "Exportar a la variable de entorno PYTHONPATH" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "Administrador del PYTHONPATH" +msgstr "PYTHONPATH" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." -msgstr "" +msgstr "Su variable de entorno PYTHONPATH está vacía, así que no hay nada que importar." #: spyder/widgets/pathmanager.py:276 -#, fuzzy msgid "This will export Spyder's path list to the PYTHONPATH environment variable for the current user, allowing you to run your Python modules outside Spyder without having to configure sys.path.

Do you want to clear the contents of PYTHONPATH before adding Spyder's path list?" -msgstr "Esta acción sincronizará la lista de rutas de Spyder con la variable de entorno PYTHONPATH para el usuario actual, permitiéndole ejecutar sus módulos de Python por fuera de Spyder sin tener que configurar sys.path.
¿Desea borrar los contenidos del PYTHONPATH antes de añadir la lista de rutas de Spyder?" +msgstr "Esta acción exportara la lista de rutas de Spyder a la variable de entorno PYTHONPATH para el usuario actual, permitiéndole ejecutar sus módulos de Python por fuera de Spyder sin tener que configurar sys.path.

¿Desea borrar los contenidos del PYTHONPATH antes de añadir la lista de rutas de Spyder?" #: spyder/widgets/pathmanager.py:394 msgid "This directory is already included in the list.
Do you want to move it to the top of it?" @@ -7050,9 +6929,8 @@ msgid "Hide all future errors during this session" msgstr "Ocultar todos los errores futuros durante esta sesión" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "Abrir una terminal de Python aquí" +msgstr "Incluir el entorno de la consola IPython" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7144,8 +7022,7 @@ msgstr "Comandos" #: spyder/widgets/tabs.py:269 msgid "Browse tabs" -msgstr "" -"Navegar por\n" +msgstr "Navegar por\n" "las pestañas" #: spyder/widgets/tabs.py:430 @@ -7164,20 +7041,3 @@ msgstr "No fue posible conectarse a Internet.

Por favor asegúrese de qu msgid "Unable to check for updates." msgstr "No fue posible buscar actualizaciones." -#~ msgid "Run Python script" -#~ msgstr "Ejecutar archivo de Python" - -#~ msgid "Python scripts" -#~ msgstr "Archivos de Python" - -#~ msgid "Select Python script" -#~ msgstr "Seleccionar archivo de Python" - -#~ msgid "Synchronize..." -#~ msgstr "Sincronizar..." - -#~ msgid "Synchronize" -#~ msgstr "Sincronizar" - -#~ msgid "You are using Python 2 and the selected path has Unicode characters.
Therefore, this path will not be added." -#~ msgstr "Se encuentra usando Python 2 y la ruta seleccionada contiene caracteres Unicode.
Por tanto, esta ruta no se añadirá." diff --git a/spyder/locale/fa/LC_MESSAGES/spyder.po b/spyder/locale/fa/LC_MESSAGES/spyder.po index 4051737045c..17e84f46756 100644 --- a/spyder/locale/fa/LC_MESSAGES/spyder.po +++ b/spyder/locale/fa/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-01 17:40\n" "Last-Translator: \n" "Language-Team: Persian\n" -"Language: fa_IR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: fa\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: fa\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: fa_IR\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -191,20 +190,17 @@ msgid "Initializing..." msgstr "در حال راه‌اندازی..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." msgstr "" #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." msgstr "" #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." msgstr "" @@ -237,9 +233,7 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" msgstr "" @@ -704,8 +698,7 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" msgstr "" @@ -1058,14 +1051,12 @@ msgid "not reachable" msgstr "" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" msgstr "" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" msgstr "" @@ -1286,8 +1277,7 @@ msgid "Enable Go to definition" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." msgstr "" @@ -1305,8 +1295,7 @@ msgid "Enable hover hints" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." msgstr "" @@ -1512,8 +1501,7 @@ msgid "down" msgstr "" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." msgstr "" @@ -1722,16 +1710,12 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" msgstr "" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 @@ -1915,8 +1899,7 @@ msgid "Tab always indent" msgstr "" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" @@ -1928,8 +1911,7 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." msgstr "" @@ -2327,8 +2309,7 @@ msgid "Run cell" msgstr "" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" msgstr "" @@ -2685,9 +2666,7 @@ msgid "Removal error" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 @@ -2695,9 +2674,7 @@ msgid "Conversion error" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4412 @@ -2705,9 +2682,8 @@ msgid "Clear all ouput" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" -msgstr "مقدمه به IPython" +msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4418 msgid "Go to definition" @@ -2862,9 +2838,7 @@ msgid "Recover from autosave" msgstr "" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." msgstr "" @@ -3161,9 +3135,7 @@ msgid "File/Folder copy error" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1416 @@ -3171,9 +3143,7 @@ msgid "Error pasting file" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1436 @@ -3629,8 +3599,7 @@ msgid "Display initial banner" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." msgstr "" @@ -3643,8 +3612,7 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." msgstr "" @@ -3657,8 +3625,7 @@ msgid "Ask for confirmation before restarting" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." msgstr "" @@ -3695,8 +3662,7 @@ msgid "Buffer: " msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" msgstr "" @@ -3714,8 +3680,7 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." @@ -3794,8 +3759,7 @@ msgid "Use a tight layout for inline plots" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" @@ -4219,15 +4183,11 @@ msgid "Connection error" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 @@ -4399,8 +4359,7 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" msgstr "طرح پنجره به تنظیمات پیش فرض بازگردانی می شود: این بر وضعیت پنجره ، اندازه و داک ویجت تأثیر می گذارد. می خواهید ادامه دهید؟" @@ -4537,8 +4496,7 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "" #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" msgstr "" @@ -4879,8 +4837,7 @@ msgid "Results" msgstr "" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "" @@ -5797,9 +5754,7 @@ msgid "Save and Close" msgstr "" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" msgstr "" @@ -5840,8 +5795,7 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" msgstr "" @@ -6422,8 +6376,7 @@ msgid "Legal" msgstr "" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6436,8 +6389,7 @@ msgid "" msgstr "" #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6826,23 +6778,20 @@ msgid "Import" msgstr "" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "متغیر های محیطی کنونی کاربر..." +msgstr "" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" msgstr "" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "متغیر های محیطی کنونی کاربر..." +msgstr "" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "مدیریت PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." @@ -7011,3 +6960,4 @@ msgstr "" #: spyder/workers/updates.py:136 msgid "Unable to check for updates." msgstr "" + diff --git a/spyder/locale/fr/LC_MESSAGES/spyder.mo b/spyder/locale/fr/LC_MESSAGES/spyder.mo index e6538a9207ae6c6ab21c2cfc3ce835d9c4d30f9c..3829ee3c29828fcd94bf9ee488698691f0b49dbd 100644 GIT binary patch delta 29512 zcma*v2Xs}%`uFjD4n6eV;RGQe354EDfY6&XDN+LoBoLC2LWe_<4$=uAB_JS8x<)_{ z1r?AYN)eQ%h!mw+==gqrXXg69_pbl@uJ^9H)@Np)y{A0$%LUlM5)zMt* zGE_V7VouzIx-T6I(Z6$uLKZGuz>Ij+)^DI5{MFVUp$7KMUU$WthO%HeuII7!nplZ? zn61ZKM_MOZXG;2a7E(}1SD-rh5CiZ47RMVn0G$NKX#tVg4Yy){%s9+(>f<0RgX?f4 zeu>qwQ6kHWDOiCeERp0mcd55ZCjQYBYNVL8Uxfvz@5ey=8a0q7n1=^T4|klV)GLp0 zoRXM=d2k`t!FAXeuV54OFbds206XGFtb)(5Ayybg{OeH|GRkpk;d`i=UPUz!KH6NL zimj+0MBSftjN{bCrpP3maj3{VLdNRU9qTxc8SoBdEY8O9j#Cvqgu5tqMlIP;FNG2m z7NQ#3fgxCK0wJY=j;NWwJ<;5^6*c2ySP(B^etdw@E|y}FS;C={9Ve9Q$*B9^#UR{+ zY#`?fYWKAEzGXh>ipt^%sAQajdT=pjzzvuWH=|~90F}iLurWSGg|^-lGoYcU`zNB> zTZFoQg>^mVrS9EEK^+{z*YOBy`xKaJwo570Oel~57sfJ(~VSOgR8=d-XT^{uES z`w1&x?penA=tn&UGhs67d>M^ucQIDi{$ESsH7;DhFno>ySZ}uJus=4XJ_bE_2(#gJ z)PV1zLhGJGf?{^ej>l2k@;qwb`QJ9rRX`w#DqM_@VJi1qLkDs+Bx zO(>gKhoT~|6!rOOtd3dcnE}!(n}udTmr&3DY`w32AHs#F_CmQu=Eh2> znbpN$Y=yogLUlYE)zCy#&dkEn_!WlYUDWf%78`?5yQnrQ678`9cJ@-JNMQnM=G$!j z82VFxjFqwI5|c!&Q3uul>jW%7eI?e!U8weMV+SmpN@p02L70xpt?O6`y;+u;nbop3 zK+Pl^bK?Ni`#TA>_6tyJ`YvjXcVd407z^Xq)?277cb1u@D~4sM2B3CZ8>D@&6HlQw z7rdyMeuTamU_R=Fmzx0wTPvdmP!sieQ!Iqtta0}G6zft{4sAg#$pO?vPWY~~{+B7_ z;KF@-!wXag+1V?yC@RE(s9dOzip(HX1V&&{oPhLHkQWsGJ`3>{Z zzw?5EZp^j9%&07eQLlw+aD;UNDw}7Za$~-|z8l+7{}`2&ek)Cq7C_aDqdG2!8h9O4 z#9EZ}D!!!6A-atjbn{}P(C?_gp!KlzTN3CsV)PQ=UPQW3k$PB}>IM&wJShu3? zPe(1?=XeQEp_X9NdNY9^k)`%JSvQyw!WRbnytT!>SzbH#Dmxh zv%F_=pd%`o<1sUiLES$Iwf*LyhyI<_6#Thx$lmZHDzpz!5%Ak+22=nQVt-V_RZz*+ zz<%Bnvrvylrrdm zf3r7Q_%^fsnxPtuL*2IowPbryGd+hn@e*nxw=fqzK_zXL?ZjW%Uu3&^AQ+Vcl`uQD zMV*Y@?e#d!Mtv4$z{RMJmZ7%QD%8QU8+G4Ts7Rf&pWj0bFvAX$6Q%WiKQ|WwxS)~N z#EmXqDyW8j+sT&)%(Tm_;diJ8e?krP1*+qsyN#i!H4n!s*c){K&Bc7U4fErts0p6) zQpif-I_jXggSyeR$L#k4*oS%mzKK&%Gropu;Ad2X9-;=8L9-pT@6VxT_PzB_YwmP&JqYz&IC^jhY8T8#?UJpi zNF2BI+gM2ZKg&mqjt@$qmZA&lsEkB)=ta$Z88*T{Pz}}CYqGivDkI=BrL(Oakp zJw)CA5(BWrKGS|U=F|S~LqP|`F!aNbSP&jj}QqLNr#&0?9a{8EwpY$f3AOS{RI`dY+slO=0(*@dnsrDfv6c&K&^Rw z)Qp?i>%CEHHx%`tp40u)P%} z=EiZ>IjALHiC(ScZVGDn5^5m7VjX;p4Y0}?6Y_W*Mg1OrfYIOZV-%M9mP}_i+{HlZ zJI|R&UBvSG{JeSDw7{CwBT)l>=REOODAsd9A>D}@*+JAvcNP`md#HiDz)4v5g1P?y zj-}({sHAOi(Ii7K$a1QFeZK#NTd6D=lD^GGk138Nd)iu=h2bc>VqXv@s zlDS?0l?%mD1Mg=YiVFE?EQFKn^<`L!`Ucdt`y4gVOI`{J`2$o)p4$&HeQ$mmE{>W( zTh!-$Py>v{JU9|PI0K8~W_$gJ^_=yl^)YH-S${AS@|LEc8B|1ttS;(?_Nb1dP#umy zjeHsw#ucc@?7@zB7Rmr z1{T95s3hNs4e|5atbY)NjCV}Zg<>%EeyHs<3$x*Rdwn}9>o1`Oej7Ewd#Hw=p$4Ao zuDMtdy*p|m-u@J_Qy6A%7-yY=+V69)0! zn-0pNo~wl#U=!2?TA-fqg&N2}9E78Mvi{d8bl^g(2WBA4Q4fBAP4PG?%X2?89aKf- zMgvsC-B3vxfjKZ5126?caRvIh*e$4moquHR`yTVrzjKR%8hDOcBlquS`}tvB>Uq$O zWl;B(Lxs8mDk5*9mS7<2zQK3^huY6W{xJ8~!m3=4!b-Rly&BO`3L5!I)XXknZu|r5 zV#YsBJq$JD*HKH*0~MJ;m6i(0Z$&&@zyL(MG6)~leFq!#*e0~LXRsK}1O@^}P; z(fb<(&9ul1^I#3sLDU@;>e09qXQFn)YcI`78I9_AIVyr{u?TKKh4u&*!f&u3-bO9e z->8A*dF9(BUZ(_w!dwVPji@K8!KtVL%*9T)+SVVTlGOdT`Q}ps%TjNEm9Rf*U<*(k zF1Kz$<*OM>^m!r1n5mb(x zM-Awjy`IVC@+D!AkR29>M>QO}RT?l>R4+Aen}sDr1dhF_r~ktKuaAUBq$?vF~Q)~L1a zje34KYR#vhcF#ieg&x)M7F4z$LM`!4tbutmy1b5uLW_(pUs6O{lTjT_Kqb*a)P0*# z9ejm;_zf!Lm$4-Nf{I9{Og7}G4g*l3?}!>`A5?^5GkMKS$8y2Lg||@2xZZjI)!;cS zfxn;zkTJ6fZFbcC0jR8Qh>BcmR8Dn5O<*7@^y5$wTZkIKS}z4XxC^yb2T?P-h-&Bt zs^ed++4!x4t_Pr&AQUyT7Pj66)zMH?WF}kZpgK;qZa}>=ydO}|2=`(s{2H}p_fbob zF{{h>g`zNO#9^qJy^e~^0Mvj}?DYw#99o2GXQi$0LfyX~HSm+B?saZb(9EA;2iBRNaW*PtB96HisI9j}C3AOF$m3B1 zn~pkgmY|mG1Sx_-dN``1W#~&f)WD9RI{F#)+zZr9i{*3q ze#2208&DsHV{tobcU8#m^8Mh^2K!K->7}61-$t$7OH@Po`0ERsu`23qcNrDJ$EX9x zDQN0NP#yWBX4)6EwnI@l;YB?^7uEhI)PxSACgMF#L7}~g+D=bVAK$+s)o_LqW(}L8-UXddk$Drf#<8dYPsEJ40u{kEs3rLXJ$M|w zIuPzs(1;$Q2H=!5$yElGEFq{F)*)!%D8+#O|L*j>S7u8ze0D93nA#dX0}rWOrts&4`TMRE@v?w$7JkR&J64v z)~0?J>tNXclOugl5uJ)ky2YptH=_DGfZCp?y%cogRpgE9+{4k-{|a>Z{-9x4d6(~R zG+sj;!Ciw)l5R$=}zrFnqr$Q5F)XG2}jgUTI$)BwWm_3pMFgNpEYRJ)szCGvD$c2j>;#pMjd4OPuSm9v`5@lX%Lx3M2K#QUg; zgjP3i(Y6>)Jq4q2FDim%YVf@Fe=vm=*aoZOEv$ycYnqU~j@ou(a0xELdKgm6ywwJw zX0#HU;&)gbOV&2Y*9o;WQK)le8jiu$=+%Ydb`O=B{Uw^=;HZU!o#VsJ__^)v*orUN{#w;+t5z0sB9O!omhF-)}NsqR#4shA!XV zRGf=SnvgK_GU|pJ@E)v)C$JknL(Q;LBeNvQSd98&?1MY-AWNCMvCFxIr<$1i)0=wD z>-J((GvcSHmrvnlE@ux`Lv6cTxB%-ncR79WG)}=vEnL38YW)FfAfYW?PH&uwuj37j z!|JVEzQ3USE-FXv;wU#u?``dJKI6i=wqz?K8`RF_EW*V0E@w8^vvhFz{`6`QE~j3m zqgkp07)<>S48_u&Ok~>Qed+^IN!GQq*#&*@3GK#UCa(8?!|Wb!GzIPNVK@LsqSo{W zd%b5@vpxG;lTZ!6h1!0zQQK@WYN^st2h0h~kLOWu#XG1Y_%Uibmgwfo6|WOaLCF@5 zIx5Fv63)f9Fhh59!hLUssscArGe{26M^onGd?5Y$_;G3vf3>jKn3K0z(P zY1FRy3H|Xos-2R(+5b9XTT&>8Z=!CTfZ9GwQ8V6+t?>YAt+VzqYnUH3Fn`p{0#P## zMYUTCwbrd{y&GzYVo*6WvJd;e4222ygLhGDw+rjzF>HaE`)IK9A&;>j6==_C;vc~bDbM{ z4>B_ji#DNcf$F#eYG6H4OE4G}xddCEhS{mlK`p^DR3zR*EzLeuZg@{nP!?Z9J@7Ls z`5xNqudEqjOk}d6?$3?dRs~Q?QUta39%~TlzN)AJ)IvqJ87hKpk%4=iK@@c3P}Im0 zQ4PL@n(1s?PepaG8ugOdhzK;3@~)!|ut{YO-Uen#cYUDQAd4%U6_zY-J_ zx?t4WHAX$q#@0Kc8t#c2*g#YRai{^Mpza@!T9UU=16zpNhD%ZJl#QqeA3;6$HTwSi z|DwHc6E*VtSPoyJ9(-+xxgLh%1J5|j zELjfJK=PnBm_i8(8fgnu2i;KHY9Q*t3#gOuDyqSks7Pf^G&9ME`n))*;X0^@G(#m> z8`N`sQMol36@d|n?0*#|*c)cp8y2CGWDROyAEO#Rfr`*~sD^H$8vY$M&{wveImrw# zFV^IGaa-?%t*H01^$khvf6Zivy|B-E#Ci%fknd0({Ek8R9Q8$|T(bF2*A&(8`#2aM zV}D=E4@#k^48c z!TLm|B~C{*dRo45!=CYkSghwxqM#V5O*820NY zI9&TbXsY@4nuh+|@IC5meu*BeJk7kfJEGR|8uHb^DLmbLy^fz@X1o-2!tF=BUQeOU ziCegW`&~25wp}&L{I+d9=GOkdOQ8`rKF3K|f3|7p6Rbl08`Qy+agND>Le^5&0QB&A zD87lWqwe2~z3^kyk`;a14CoDO9A?q`e;S2sI9C^N8LELbsDorTF2IlM^)_?O`@c8p zEtiCSF%=u(V+_UG^T-)yJ_r-3PoD2`np6J^M^kUGko{kc!bcPu=|m7n>v=hZ@*gRJ&&uv;Q^Y%UtlspHZ*T zEKAJxD1lmne%J{YV6g5(4Jc2lNzPylq@IjAPgbA~r1hvs9>zm>4%PmWrKY_tOUY`j z%_}ZwM0J;$?|RKq--HIDl4?9EQuDDAevaBLe_{iyy4-Fv)IfG&d%T1_G3Xt$Yo?&i zlZ)uVKfDw)^Ma)49jt{Fu*^#HQhFUrP#=goQYWKcJ~L1=orlViWvG*KmHm7hDncKi zj`Y*m9Iv2qt=uY;jNVEV)IdvAgY8fcbVW57g?bp=my~GC7aC_9u7N7>U40Ycc z)PT-nb$oz@u-toQX7w=-_197N^+OG0EC%2TR74J=@8AF3rQqShD=d$tH=4C=iJDC z6;Nx{4mHDm_VW?eX{e4;Q3uOz)PDaF^*!Ma)N9(k#pHxP>fj1SJy&as*PLLTx!~bK z3~C@VQ6pTAYG60&r27V6$6KhZuD#V{dAPM5*5Z0M)BxsUB(6m5D);+lAbC;eO))Qp zG86((p=*Vj>6^F?hoQDvp>1XW!KfKDLM2l>)RN7@rnm>|;#1W2tG?ZI)DSg**0>mZ zpmvG(J_XJ2396yOJ4{E7P#t$g4QMbb2`Ad?Gf)kzv~ECkxE&RNy{HI1L>;*=P!r9& z)8s;R4AT3*Ck2Ic3TjQxp_1t#>Ii;Zw$O*>^;sJAvgwYB$YNBe-$z9v z-PXTAh5Bn$WPV0w?}(P5cE@M?*#DZz*Ie+&OQ;dQK+Pb} zev_1qP&11`T_2Cy1>3CqQ3E-O+E(A827Ck6QO1u=MDwANusC|K;m7QMHPn|2dJD#) zA~6gH;RcMuTnEhScN{7*lTk}H3pIf?_$_Wh4|)%pcS;(rpnedQV?94H5t)qbsh{>z zP=}=snb3E}t<>MbdRXOCm(v4>VjcVvd!p+zbCmYRMCy~VHvWZbuhL=ju871C>Zz!t zJB-TyUr-VA-lL$Y_s54fT8HNpoX=)LMISFz!XI zY1vcejPHTXsINjr<_4<62dIv6oHq3!Yco`CM4%3+<*21Qie9~s?^4hj=Kb1y&=R%A zLr|fdf;#z*p|PjkUMiK(nFDADs-Z=w2&AKO<02}gKcbH2Jm<~V_wsm?`WvVT z)W2Yst|@94%s}10%}YTu`2sbw%Qyg^VGQ>9&b*9{qLTAE_H`2}R0F-fH_4QYI@713 zI+%}&*oUad9zhNG0V)ZfqWbeXKe(I+6iT8R%yrp}H~=-UP*lgwP)XJgb@GixMQj1G zkDaZk0bD>u>PHO2Ojk@h%}}B5h1!Pk$T{G37E;iI>rmTpJ1W~xqDJbzYL+M)DwIWW zAo`;^n2sIrD6Z4z*QmQVa#1tR{i8WAidbu-a;`g8)c&7BK{xJ3olGC2Iyz~upF{1} zE4KasOH+S}%AKM=nPd$@Wp_hVvbD7KLPatGo8vUp`Em+<|NW246cn=GQ5_Y!VYX3u zR7b5)pT}Sj&PLt$A?k=eit6w(*29Nb9xMKAa;GcSq8^W$`Fp6fKZ{;9@Q{K+o%yDj zK~B_?6h=j)3~J4*p>m-W>OhG^&3Gm1y>~>+*}SKkA@LKt*;e9>lkPVgGNV z5PpjeS+hqtiTcai=8xAV+;KUVsb{{+7ZEUkZj8X&s0p>XXOk5* zz_F-^OvM1)jvhRJ&ud2Zm@4=gKTsj;@WezQ3N^qn z=#R5dN%%f00vAx*?>^4NMo-N^zru8u>sGPWp1?b;- zKtZ9*^tb7_Br55uTbrOl)&Z5pgHdPvLR7ANgnH|pL3Q{TbsiMs&qdzAP&|rn;azO! za{G2m9k<)@p5ekf6to|QW^g-wu|h_-@3lG`wdP+~Z=t@T`DJoD)3FX}={`dxrpe>gL>&)!pis;Y5;** z-M;6#q7I%|)HWQ33h_wPlFhY~O2<<^_qZ6oX{{?-KLFI&#+YF!b?_r1W%(Px5!)4?KGya7MtS@48xF8 zX5>RqS)7Q<;wh*Qufrzz5h{YutvO4Zh?K@^Tn|G%pM;9gRMa+Hff|_ieF|FJy{K(> z4E?aQ$AmfQNCUkSK2K6-5Qk}5nNGiQl1;twDf^P*ZDL)b%c?CG3fs z=xFr4|EE)UlM5?RYxV^71*1ZsIoTqxBK7&G&p$zb{2jl-g5^!_T*n5~%LkbO3`B)I z4z=AzqjDhC`ab&p{Qocoy=L#BLgyE3-rxSHj(TBz9FJPt{ir1T9Cg-TM7 zVQAe-A|~2{Em8SxkAl*y$~uHN20dV zMAX^82sO~_sBFKDicpS9WeyENoVE``lQqauyp^n;9NKQJBQ8$jLY#N-7 z8rT9<7N?~Das(C0r>GM#PZcwP%BTo7Lk+Aa>V%9!C8KvTg(?)*qC$BVTVkH7 zX6<^Q9-NEnU>PczcA#FvCs9fHJ1Rnf)y#w{qn4^EYNj1f+jWGk&%j#R|2rrsq_h za1v_GSDV)Ti89nQ5y^u}vSL^PJEE3mJSrDvpq6l}tsg)|_!?>{OVmQG zbua~WSQ|Bf*HIylL^TwL6>&c5wfYI_wS5D%z5cfK8nw;LI-ov}MYT5#m4qv7{Rk>T z7t#0k|DIA%!`bVYwW^G2pqs5HVteY#P&2-T3SsHGrh|G|mwE@(fM%fv{wapx4b%k7 z)-%ai4}B4<$Ntv{$8$kRwGcIs9jGNaht=^u_QA6CP1a9FCDRJj^V_V4aUAvUP;1|{ zfjPL6Q3KkE+6`w>1G>|I{jY83YG^tP#!%`_Q6o>nZnztj1KGpO04kw}dP`J8F{pRO z1pE0GRD_P82d|-ai>r|_5Ouwsmx8iB4wYQvQ4P*Q&3vt`UqD6V9%{R0ZETjRE=E(2 zKn?IXR@Qx}2$gPPB3cEt%etbrcMNLRcz01yHvV95xPwZLm#A%5GTh8K4EIy-j*3u$ zrl!Gg^o1I=gvqFbX+G*5uo?AkxP)4QN2q}oZ05Vq>(rs35p_b1@Ey#G+ps34V-LKC znt99SZr{I%7>!!{2dL|rTA1Xkh039!SRNOkmS#U{NpGV13ux))zfJ1pcQ6!mf@N#v z_Wjpw8sH-8>8P2tZEZ$87&W7}@jE<-YB;rxIe0!pb$lEZ`unJ)b+t7S=w}^?92 zmLduj>2c_7Lt!2THT(lAOCMt|EZM;v9HUY9two*X_fXrYU`KN#hoJAgK!rRJ-@tcJ z6L^R^33GQc1Mr}7q;@Cvzt*lJ7nE$1F&x*UW_%l!rCB?h6R;{OIr~{BSkqAL96}}6 zBYVA27Zb^vsO{JT)qV=<{=HqiW-ab=K@I14!;H8BDw|uQLNy3=1dm3YTpLj@qjRX| zTwTpqFn`qj(Wvb_2DNRcqh`Do72*A;?}XRA6ttagp}q$c?q-rP1ogJ+iwf-!RL4_M zq1=sn*&IP7;b~N)o}oG@+ubB(Yg9*_Q0?_VO(Y7dqjw<%b$k?c;}59N|Aa~wcMo&6 z7elT28(15MphCC~HRJQ>aj|5m8T<8QK-A0Pbn5MUxt-7P0`|p~z1{k!T3+WR1+B^A zK5pNCNa!M}*O)}(*u3kn(VrTxGaZN3HPM$NbsX2l595)4MYjwhg&>@+It z&!akchWh@HKgP_oBq}GWpw5GCsQY5k_x_(kp(+986qLak6UiAEh* zNvO46ib}3ssK^~e4fqUZ!b_;nKsA(OwE0=D6TVA*9yY@2W6aT; zf=j7?g-X)Mv1Tc+pqBOzRHVEG#+jE$OMIUTOK>DM9q)E#-~pVEEr^uX@FL#8C#VDI z@&xk(i{C`k@JwvZ_05=!4>14-PBPEWK^?_gae(&!Q3|cy{IolnuUa@_ib=-P(~R!v zX8+ef?caX*GtR&v7(c`9`$MPiaTxWgN8{+-qoI6IuqxU<+nH+Tdp+Q z?Hm@Oe%<;6eXrS7<~yYabg zR!qj})ED3^ES_eP_dU!+{Wxj>Ut>nRj*7rdRB{KcF-LlR)cxJou>Xm))0+!3F#?0} zSFC}B*SdZG5TPCFEqD<1h2kRWzQ?G8sLZ?Ob=v|p(=Mo=1t*|R!s)01EI{SZddz{R z-X)9cQuv+=I*|&kGm$8V>aZ&|!eOYRcNc1gM^R_}Ma+(Q*PHX9B)&$yE9&Jl8ns&t8F)7@g>qaNi~1h07B!$VsF~(?&%D=zQS~mUpXCx!ukGciI zz6TZRbJ!ZM;#v&e>~{L$NmR%~x0rLI9oQn#8JTtGc|1=Yb_ z9EyKp5Ds|XB<&njB;P|FR41_t{%Y%`wwZPspms%T9IyS~okC+STtJ;{MYp?s|Ni$C zcBX!Ghuim`P%g34?fdt@`|&rf57})(+hdQhKWg_3LFLF4R1z++*H@vIldt4$`=wtlxkd$aU1ob_*4ezfo&eG~Em+5H+KUsL0et4WJonK<(}4U2Q!A z6}cExf8$ZR=dE=6lgv^sXic`E8ajYFv%f&i$xzA28&f zi@VF+`mD$Yf4PD(=N*_BKlGo6Vq@Z>Qj2DBmCX>49G@DJ#nr{vL+X?)t}?z-`uka2 z`CQ%9t!G3`ax_mSC98q>xPdW)QW7J4U3N-|^Zd^(kx>I9Qeu<;w|oAp@6^3nT?=#m z$7s@?DpP1x{i1XbU6PJ__H6Yp3Ibl>}RN_B(B`w@>uy~uK zx_%x{Qo^WE8mizcjEISgj2}_K6B(0Oo)3cj{5r%&MI=ReBI7-A@yQy1Cnni91cERs zJ|&UiMKaR#cS^c$xr_f>n0z5hVyH<`$;nZX{^_GUu6)^2%LcphraupIWytG|jgN?o zi5q0c_MZXt#3v}$Nuiz|@hP6TsDI6y#*_y`JpJ=JP4Csvbu(jgRw^hl;n6G9!m2{pd?RxoF^$_cvSsLF%9jEwagI%l4FKPrH*grDx7*H+?6Z6 zN4RTfMw6i@TDZz(NzdNF<;_+j_TQ7hpT&* z^bQfOxmo>cRjgdaQ>Aj<>XlP($GA#mqh8%pxlY~6>3Igb)~zk0R^p-}Qap##2<$&? zXoM+Iv9VE3*VK_|?uv!GD(g~+7#;oFed*ukaKB&ry}1?K*@{F)C8RJnK8=kT6vukm zCQ{$3;jUA3a7xm!lt9zVe|(bhU>Wr!1Ng58BN9E4hc~f{lmG3h{~WI0zm2z4ZTHEt z|I_^cb>va$cWS%4Wl8VX$bGPo*Y0VL*{5V%)W7zmU#nP8Qeea&_Elt{otJOJa|`3@ zA3rE!P?TRva*UZ7OY%?VCujpE5zYt&K9YoD9imbalk7%UhNZA-DT!V>_MHU2fN)=2 za$26?*Z>cr5|5~hn!p7D~_8i`ne0Y;5@$jaY zINk61ME=^Nj4Kk`fW40NB!pZd*Qm&cpp84w+jI4EkU-W{5dJzoAF9fW$iYPyk8 z!CMl=u`HM-*P_*^tZ(j~{lrSN1}>b=qK z!Rc>|cKc@xAv`a{)*8>lBJi9hB^++v-ZcDw-`&oCedLP>JtxFQFn{g4f1MsQz{^XUKE=20_T`IT|C(Mq&Ax${WaL366ewYn& zU}-Ff{#eu62>UyZ*J(pR53a@HxW(4L#4OZLTF+o^>X%R*-m}+##hldtM0J=o!E}_z zS`5`rIn0HXQ1{ir!u0PnrQpYfUYHpN+IlqV!FXFwMh$d~y*?4u&=jnIb8USWR-=B< z)*oB{u(}e>bJ->RI|V2xq$N=ugkeQ&ge5QlQ% zO5tyq7YmGZoO)Ouo8bU#j;m1j-^AB2h*8zRG1wTljw1dIDBR~l9Sj_8W;zhnz!B8- ztYaLf9X3YYKNaiZ=g8Qdm#D}jk98cz=zNOL8F2M+BrWxz362wrtFRcJK`q(C3B+Gn zUjWrmb*zHxupte6i<)WfiRQjw)QsCI|V=mN#g)kFV#QazpHIqiD}~1rAv4W@2GJSd1Rrj*7@>R8szkMe(WqJjZl)3iV*rl0{)K z&av*p?9_k4EcgtI<6o$D3(e5(V13I{D943f*aXL7Mcj+(@CLTT7wEyJGfjjdPy-%@ z3hg93fHN=$wx4CTWlvN)^U)u-qLOeI7NURWGzG2o9juPeu>uCoHrd_@wF{AebIp*`uSd01;)PVM3Zu|ms;aOaUH&8h;VJ`7klFXwZ*P%kS8#ChpR6|EFJD$M2 zcmXxTd#Dbc*?OjTO)};|-TxY@wGhyzNjULu_n$Z{#Cdz+Fn>^Z`_WW*{7(G zA46Xfp*sEx)sSm}$(bBjhI$7K!C|Q9ms&TWcF}HBBu-&4p7BzsO2JuZW*&m7x5EG& ziPdolDv7>E9auN5&LXorN}+Ng6!m-@cENP)g!ix#)=4+H6@k^Ld#6y)%rdO|Q8PJ$ zdGIEd#$Pcn=3i{qv>a-UYhVFvh()lAH5QfSZ=#lNDVE3esNHrPY2WKSrcjp)uTV3s zyTr}_^HWbp4REvdLsU}jvY&sBg{fb#KC;)dEH%#;MdeTx)RHtpO(fiRo%Mg6LQXCu z+8f59I+$Tyf(5B>K;^i4k%`mZ#( zQf(#cuh6vNg6xgjuW_glzJZ$I1bcmotlI{j-=6|EsHrM-RhNY}w zn2GD1Q4{QjS;?^$^tk z^-xRK1}|e5)HbiO&P*U0S!%B{okBT2n1>q3F4O?dpayi!*8jG3|Mhlzp*jl1w%8Qg z;WShZoIxEtzhG8;iMl`Y2DAP0qlf;T015$o&>VF`G%B>os0hqN4QLT6#P6dT-ihky zp#A(u^rQYWDp#JO+R4AsL?#$BQ?G+s>W0{X{+(C~?eSw&1Fx)pn@op!P}j@gJ`6-< z@h$WvCyu3FaI;Cmxfn|QBW$kwuo?z#;s4kRIkOxudi6o^t>*iAd#p))xvihZvee6N zGk@S{g_Wqkhe3E0)!-AE#^ChaG#E%^3JDh^qvF{GkU<_)Y6Hpy5vwnzL^DnRlUPT>1`F5J^R~-vb zZ-JU%FE52`6rxaT8ISq#EmUL{;Q-u-ebMh@Gvi2914B>|NW0Z3d}&Y z+w&7M;5gI-yb~yNq_7N?wLhWO)PI-Rr!`Ow_Cn3K18*bJHzaf z5L6_>ZG9LPrhjJ|h1z_u3bhpHQAgz+REMtJX67ZZDfJPkhBB}ko<}8R);;DAq190l z9g2!j3hI6@R>T#k_P@aV+W*%mC>ef5t?^%22(x@@LRbRTQDtjiEJA%cYQSsIgFEc? zGpOvoZ~Yr}U#`7oiAtd+9EO4P@5ECe6wU&B!xq#4K1a>uGM2}iSPZjq2o%S%xCR@c zcEL4ljJHu8mfvrZx)y3c%~3hk4O?RjdRtN0YHxUI&2)f6h+R++bMkq?&rB!-(2sgm z)PQTFwplpp2=9r?l`*J@zKQC13Vwr&Q3H!TWClFy5b;;Y$8$m1Iu*4I(@_ntv-Rz$ zkngwG&tU=TS1|;CMKxUZu<0-qJ=9yECJ>Ek?+w(z$D(p(-eKae(Cy=b266~BgA=G- z@g0Vu=Ln5qH%!2E^m8%G&&^D8e___PkTnDYxUY>h4i&i>s0q%u_0?Vq8o(yh3_d`u z`2p07kJ{^3QET@Y)sX8;bH6_-#KlniyD~PwMyLsmw%6ZAMJ(N3-+~3Hdv{S#vK>b? zbRCQ0Q&a;vj+zk{Ms26^SP`qElCT$Q;K^7SCt+#ahU)MHY6-4kR(yzBif70~z0N-r z)Un@JW`;#k^~$Itv;q3za8#tyZ~~4&4e(EE=3^#uc~JKk#d+w#S-2CgVzaN!&x8Vw z^F34he+h+6e30n`UlMQ|Dk57>nou4>&Fm$%#Gq4VM#C`|^)cv=Q&Gt^&$i^he7%rOX4vM!#k(}mpV`U6^g*~CZsj75cQ_0lddN!#7U@uOu$LF50z9+ zF7T%(Iu1uA?Xim{$!?&M?Wl< z1~S!NUxdnq_fP}5VSS7W`QKO=Gha5Jmq6``Ak?;NgPLf6F9n4>85NT8_Jb){o%(yI z8Jx18UqcP>XUvO#p$Bt(Zz2|qy58E_%NlDPjT+c=)P%gNDQE`UQ6bxBZ#a$W_%5o$ zKT#w1|G^wgrBRUy!`HAC?!a{X4BP+6*?>8&nCnsaF7?9f!49|)SyHd_jDnId^qSdb zeXs`gw{Z~e!gg5Xx=G3iY)yR`HpCmKoG5d{EZG{=z&4>Gu^TmkW2i`9LM`9} zgo4(h$W60ftD=tHc9;(%FcYSrw&h6FQ9BN^;T-%M7o(nAc*}IO4OQQbQFt1)#IX$O>i6*#eJvm`cD{$!ycFc z&PPpPG3vQBw!Rq^v5zqa9WpxCqgSSz+F$dM~Ce+gG z#GLpkR>Y$if{!q}i?`b^W?(^2&3#o-1FDaDz9ah4ztf$9wp)MHffI>tOhw%|6070_ zR7AF-mS8vPzJ0hK58BVi|7z}^hM`=~z-st2DpJLtnE{nSuVxlZArH33`qXZ}X~+jC(d7nH5nPz~Kf zb@&@}f`LP44y#c892BX?bLESeI74hl66Mr={mkZjj z>8LgT7&ViF_Qqpakoq_HA>Ojr7d|)lFSD-4j(q+RcEDG-9y|PD{xtm*HQ=IunxzT! zQfR=1zNiNlVGF#0?XcosX3dgNBThrjY^<%ng<6tn=*taM1a_k$dmMwX@C$RGH9<{u zDC#-y6bjn6n^B=Yfs646Y>8=qo1^kmRL2icA$*2<*}O!Bw(v{-kL9rtHb5=aYp8)m zVns~ABDfGM>;1otf*QPt8o+hzioe);%YV%F>y93-Ct!J;gVpe3)WB|`I(%S#iCX*I zuZ$&7{RCkFtbu;o|E=r?9Z@6fiNQD?i{oZ{{R^x_{Q+v57Is{|90@`VC=_+Q7ix{; zPy-lceFwEP^ROAN!`xm9cPVIOzoR1X3Tt2cI_Eo#7nQ3D%=>Tn8b33i|cdJq+%lcr=SMD64l@q)ROE(P2eah^jA;=dxjc7rmW_<0$vJguncNu^-&GALUr8H zI@DgDf?9$(sF`iF^-oY8okB(Cmh};;<3Fr^{M174j>1?Py~Qagp9ow!%lKfz`@kI&O=)zpJfBq9ziLdA0wiQ_us; zP&aNzh4i4keioIz_fRwX9rZ5Al+z5L5u$Ezu_QeIr68@jg^Lx2$gd zMoJIr9#lUgupCax%l_BM)^kA}9Y#HP2{q&A7=}gixqM&0JK;F$Gf~^eKflZOBiA4t zKz#^m3BE){zOMZ%qq{%xUk;(J*vaJg-yuIq6X3c zwRY`L$<`fp-$>NbOv7+ogPPD|tbu+-%)siQlD7vc!jZ`HUgu2;IvD1m_U$(Ff%6pA zVdkP{26?QdQK7GjnrS#{0s~P4T7VkBdelG<*z4C(5&0F>pMNps3j41-1$_{X%7I8! zh)1JFJRh6mD%AIbo2Uk|7dQ7;K!vy=DpDO#2TmW)VrfZo9h_#WzLKZ4Ea-?>IX zYgn#?Is2=jW>N>W#_doY4#3Pf8Wq9`s0b}b4{ku63tyoIbP6?qA5qEn3e``xl4gPh z(W|u!p`a19LA`vUQE$6=)B`D~?c&8?d>^%KPowU;j_T+hYDuz}GBYiOx~~Q5`R=Gl zk3vOib}9D1LcN>|PjMGU;HJ_pCmnN?aXH=beVmAYU_%_^F_FnYMd}1r!7HfUlRd!Y ztirZ<0Dr=TxUsCu_t*Rymoo#|S&sc*mk*9{LEFMz-W(teQ6Y>*%_s%66q8XME<&yYMRFz<)&Ad2VG(|d4KTL0dAqGe&FC`fWGobBPP)FRq@06VvNfm! z=5rj2KiGQjI_Bk-gc`sGRI*>jnpmJN`+pLJHWW0&uTdwRe?6D)=l$WBK>axCoTy*l zM4%f6Qhx(G;1cYMw=oWzHgNfVFu56ZRF`V#^8LlbhNzie#QvDM5&K^wk7(rb{e)vQ z_M*N4HM3Xf!GOl*1Z<86T>KIm?_tTN=DsY=&1*Igm27QLZ^Ib;7$@R93~S+X2I4V% z2P?H?|Bs@uy`||msFllko%);D8Lyxs5YpP^`wPbJp(1q~$GUlUv~f9ysjq5BQZk^Z z_AX}uCUkH)Gr69rqs#Zpr@6S4dg)GPN%kPG5$B1QLS;V4-o@qn;Zbc=M56IAjzc9? z{A(ubQ}GWT^rFuCQQgdLnSk2oQ!ol=;{<$aufNgVY{ya7X{dI+ODSl(twG<3gj%Cr zsDtJP>Kn}i%!y79^JOv@YWszva-%6Kx%!}za~`JPI(!?8^)ySe0yXeY(YOCUqo93$ z8k^ub)HzV7mwCOGM?KI0b$~QOZOc}um)2QSlKq0cF>h~kV8x*ZJ^_^z^RNtlh?>}G z-*xuiWeOU=AE*$%L`LRh?qlBf1yK=dfMu~I>b@bUlP&>uKutwOXa(v>-hvwVMbr{s zLEY!y*SrnOU@-kVEmXiHRLAe3M!pS|3x_ZOFQFQGfjUAf_A~E}x~TgGU~wFQn&G?H z9+#n(_6};venAcF6?!$3Ed9+4bD|n9gt{@%)@!4dqzx)*dZHpRz+Rt-`sOqj^?Kfb zZSWQ<^ue#2=h|Z>>RnMs^SIa9|HCLekv%uce8D(@90g8fw9C29^{p{x=CcQz2rohnU?pmRo4pjY7JE=3JZS6Z zQ8W7iwG{VI5&0dpME*m}%u1k=w;JmHTBsy!YOi;+c11;~m#q&#?Iv#&1+D22RF)@N zN1+;?h#J6TR09i8p=9Sph6vF>$On>YK#>y9Q9nXy*?Y&{&GA^j%>lN z$Uo=JVJ_z!wi|9vv@(gNqlT!Fw?&P-8*0t^+0O@JW$MFF16_pbU_B~%KS4cLG0AMh z5LA2Ns7Uofuht~O-Y^t3!>OoKP#~(E+HbJ`)o^nzXrvuc^&Y4Z4#F@TYU``99rZ1?{+soc)j!!hSHM~dHIP75 z`^~Wuwnx1)Qj>{)EQR@8P{V)YP;8xIzQKHi9jKR2H3vo#&ZC}=EwFN$%ejOJ$Z6`d z9l?FLcBIRh!S&;#%-gcpXp;-$Q0K*B)IiR9DKz7Oe^3KxI@TPm(^1=L8#cp(*cDyl zT)w~8-yOSCUx(fBFVxZ7X}lTm3~WpN0;*ll1oK5_DC+*3*dD!gh(IKTN!S^0V@s_4 zCf{Um4EDu56J5^xI27a9uem3=oDtMVy=}gvKE?p*mESQ(bU1pbdvP|dLM>&r$$Zmd z!h>695e0La@2sM-6D2t_o88iK zG1=aT3lq7}3=g6P;#%VJ{Z)%f*pK=s)V4W`I#7a^nw)8Ynt3$-jFT}K-&kf|N-I!b zQa?eRsNbQI_#$eeSG^RJB==C+{LtR;618TTNqwE^rLi?uMXhZrDj6rBo=-6zi@v***-F{qLbBb{#dad#L-KpaxWK9eb?Lh~!^y?yrv?>K!o%6HsfLjvB}oY=@7r7S`KfmLvha8rf6|8tGhA$LXk! zH=+kWM|FG~_01;dMw5J@s3hxP?Pnc^96ST2X~_ebP2UPenLI(-fZp*z|Pd`pptRQX0OTe zd0bGoFULB#9yNd~7>y55+v>F~W*~!5Ctw_w#WYmp7Ncgm3D@FhsHKS6Y6dVGHGw&( zTw3m>pf$UUEiuzJ^Ez#d8qiy)j%J|-uml(4MpQ%^e_&?V2G!1BR7B>WI$nnw&>qxM zpSIU8qMr9Yu!Y}H9sYxgK(_5BL`_lKtOIJMgHXBf7FNQ|sF0pTElK$gO)fP>9l>o- zOEC}!;3(8YE?_n7|2q`wbD{7@=BRuP^6lW53n}%qo`zjiRCcwPAwVxuNDP`suk+>*atm01vP+;_VdH28J|Zr;CyTbQXUoZ zDyZwtP@#>%x|o1ExHg~$cncNjztQ*m|E!;w3x!ajE`x-|sf8L?G^&G9s1VOaW$$Km zxp=vt_I1WCm+$Y4K0vK?sSL9;6;S6#8tVClsNMDvdh<}YW-mNJ)ywQQOHmDVhKFM~ zF2*W&8?`&~?J)x{g8|elp$6OmHGzSsq@07A*lv6MBx)DD+{6A?A^WGM<07bR4@8Z) zCaR-usE|gWl5i+`a2D$M4^VHxy{JfhhC}c-Ou*Oon%D0MRAjzGMfCDsyZ@hX;S3l4 zLJywWXI?6gaXIx|`%RKE(+BrqJuLj0`99GF zb&_uNQb?xo9V%H`9Wo70z)j33lp^i*RD1nxROFUm zd0c}^>Z7PgdaqN^_R4(3tYrgKNL!&E?2203ey9eJnhu+wI_hWZqpS;1 zx$z8OE!ii+e})X7)$8?%&EP;bSK=p92Lo`Obn7d=?@ zwD~sN2^H$OsD_T9PAZ`uC} z)t_8wjy1nCGa7+PvgN1-x1kP})2Nx;vY$Uk?SgD)%#4d-IQ3A}JH?AZ_$jKLo2X>Y zde-Dd&{_7sLRy0hI-3WgW;7D-;9ArSW}Gu?Hy^bNE~4&#iJD2F^JZqj7)8Au#^E;9 z%c#f&lYG^2pqogcp5NlVWRmF!>P$a}>fjnGWLYko&=x>ta}!h&wnc@qGd{t1RD-X7 zZw8!(8rXQ$u2_KD1=~?4-*Hrqc&}5?HhO^?K*b+SsA^yn>fKQdEkK2S3zowJs3p3A zdhS=$Hv9*51eg5L4D>bB67@z!G8PA864Jicxj>-{7m8dlUzuXDi;E-os+sYCYv#Zh zVx59oq7A5}JBx~p`??uW4pc`aQP<0(wrf>eZ-Qm0x5Yf#|FIO5t)o!cJqwj=>DDc% z2pq)Lcpi1Yl)7O;8H`G<=BSQh(1RmU9WAz>@5V~hzenAd?@sGR75nm~Wlk_<*I(Hp2WpM=VV#i;XSCwle30}49% ze#Z^?0+oF4-!^`PI>Qg5LVFw!;2Hb?=iQ-0maN%b^COt>d*;_`-{6mY-s2~}i12yM zpG}Uub>HO-;`-|Q?El&n+z-qjsp_Kd;6Putq6T;z6_ImT5&uCCR(NOz)*3bAuBhi< zN6k1HHIb>96BnT-@ILys-$VAlLU)7<+74f%vilYe#cYpE2vblEyp39dcTo);KxOxr z*7K+(yMuxF3bodOkIn9>hdOD)u{+N5QdmjhF0REXPh8Fe4F1LCJi}s7`6k1Gk^5Kk zZFj_P<|y8bwfOuID#^tk|1{gl|1T4%{HXmNjA7Uwm6Q`v5nX}We(O-V^8qUPKDF1+A(8Pq zS12g7PwWRTaS-*aFU&xOptj=}R4&X$Ulyab;Z9Vj52I%MGitj!f17#{RBlv7&Acuu za*^o!?|;)NsNv1%3mN)ChT7McQ8RssicE=@CMQBrp>Bc-Wp`A^@u;MG%la-Vw^pK( zcn|7`zk$Br|NH%8-g+KXhpkaFj>8@}9>2mfcpsO)GP`9ezc=}o`p>BCxXeLd>1T?OV&WsE}_%4R9YS zVy96vzGFXsh83u1%IfxQ<4RbadMnfbMxdTsm(^|l`=7mBP||#c3h`0Ynq9^gB;jpT zmRHJV2G9<*B)xGAjzBHpFQ}z>foiXSzuUJRi=*1DiAu`WsOLNTd)>Y@8_tCYF1(FO z#ydCV4y&T}e^YzCD{5fgL`g+u^Ig3i}$EbmN zUr|sFWXokbY=RmxI>*N1<>3&!?b}tV4xtD{7_(QAu_Ml`KzD1IU)wM4&9H-X3-2Mxzd@@mLX8+xl0w z{s6D`5^TD{r@-$`U3I>Y9NPkGaf_DG&R3@U?nQaHlps^Ve5NPYkb6d&VGIu zl}nG%gDbwEei^l-e-&Z>YaiwtlI(KpPdT>}iQ_RE zuVGew|8G^^gectF9hHRrQEM25n&~9e$v7AL;yTolIThT#FBG+~8r3+gic3(RAH@KC zfyc2_MUy)}VI%r?st1|@B%ne*0=3;Hp>klQ^%K-gkE6aF|Bi}W{vh+Zu7v6+8XMwd z)Y2Y7CE-cbQGXrvmMmI{{jUzHQ_#p;qe3?VbpUNeCC^ns;G`T+xke<0G41y z+>Dyxw-|)CQA?RS#N0O<75bH^0d7Jia|Y^Vbr@^m_g)GLZQg4396-&a4JwrVQ4J-d zPRenpq+E(Ma1Sb^_ffkeu)0~oB-C^3QSE<-%BiEM*YPb>a(c7ZFd=G;no%3nn)O4? zbO`UzH|f9C=Pt%a+miGV+9 zrj<~kYl9k44^(bMSjVHz^d+c>?L^J=E9+I%_krJ0xm3KCiAW%ZQLll)TC`XSTAPKa z6L1x3t&iCHSyYIhqXty7wy_zi!_KGy3`T`~ELO#LP$%Y2)UG;Z>zTvMM9N@sB~1eg zYM>`7>4w?*d@MtKBWel0!t!_-wKT6#9aOAi>dmkd^;pylGf)wGgla!~UGuUkgBtii z^zHu<6oR=h6LsSrRI;2xh4?YP)*96xasNSE%HAYHx6wnzb*0+V3?{+o>xm^r^TPXQ3k1 zu9<0g6e{F1P)oQ8b^n*BBmOGt?9bEOEQL3af=1jPbz=f*WK&QhJ&oGWH!%z!U>_{k z!bD&^rcvLH%7F?k&Gq`ItdB>{d<_QSQPdJWL6(-^|F$w6_QNH7uoiVPHE!+p{bMu9 zxPbZt)XXNfF#}$Sn$Z!wh`*v5KGD{kC(ltGzd|Kx`F19G!%z`e=#%}oje>^@U!WSi zgL<%Vdo!a3sI2XR8bAtaN!~?mzYlR19>kTFOyag#MkS z6cp+m*a5#lHJq=b$eehid0HRBi>nX71~N zisUfVww(PM`(GVw=7Ju0gdX&CH4ijHjW`08&0bWbmZJu+9d&U1h?;r!Zsxf#Y)!o< z>i+jo+xkP)_T7(~@P%$(6XGXa$jXJn-OY9?g?bx?qmpqD=D-E0(5^ytychMZxQBX& z{E14!%sot`s-X^?KB$+R7uC-cRC}{^K{Hv3I%vK^b^H=_W4@jy^hHriRR`;1XVeio z9qZyMR0J=fW}Ks!nQ3XPz1_|icpnF%_oF^;-+yYAudmzp@Azy&b@T!? z(*pg>j6$$E^**SBXF2|ez5Ba;Ke!Bg-TWx#d(;Ga4lr-cq1c4_dQ>vrL4BTQpf56B zryK<(S37GgY6eqK16hiVnZXWRME#dRZr^WAr$(6b;RPmhJuT9FF}a6|j3>%OHVm~i z(Wo^~#9TNP!}R`NL?IIwPTCK?MSXd^ikk5w%!W0hO~dt3uh}lBC0dEf^7W_=zD9iy zxQUwSLsSm9V$A*zMBUdAegFP%4+^1N7=p^ur5KAFP%ovtgUvzWL2b7<)C{MgBDWGX z!vm;;>liA_AEA;g+Yl4cf~biEViv59UNsO#As_ZY&3uThkHB`+r(zrY4p(9EShq6= z_hKqb(;<#OR^w+w-Oe|7WSIGZOJI#`Sf@fUa)uV5=&onn≦gec znCdkj#HE^_d~QcY;u-3s%9>_ER|)4)Z-`3DZ&2ItE-Jgrjxg;+Vk7Ds@qN65O>z22 za|9p9#nb~vndJS{OF?T{YqVM0_E?kpQ0#~+a1=hkX&5uc{K(}oYUw79bvw6k6>8@1 zj5B`_*@4QH(DCLZ?24(>mtaNAJ;6NhtxZ8^ad+ee<|H9!g|qiflavMCGPXvYY@<>8 zcrD(>lehtQzU_A2z<%$zegB%?Nz`^7IoTXExu>{&zY$HqzFfbEv-JM2In{*b5Qg(X zk!k!Hj>V3~^VGkb$=i;kdT$n?XJ9|gHV2UFUAOO-Py2BP_r=dM+qKMmw=;tKBT-9q zX@R+3aG_bcUg+WTMD+dr|GA6IwpfDf7-x;G`!6xq^P~282~-Hn;!I3OZLji6&3DD> z);6ej!T{9vO++0yUaW$%P}_A6dN_Za&ne``+o&&%f1vhnrDf)=*8(G{4??{SkKk0i zjx%uRa+9pTV;1Vg-ZKLzgPEyU$L3fY)z2u@(LMvcdSE>TZLh639X~{^b%Pb=JKSJQ zqP`sUV6K&BAVH}6TB8o0H!vqILe2Dj)LZWx)QNZjHGu1=9D25r{jUc~tukv>8Fc`~ zpdyiq+8ygqA>E60@B}LKnOB?rpBKwfZ-)(VIJUw~SPvhg2P>^H18t8Ls3)%Rn)m%e zE+|<(M}_zmR>D&6n|d1zq#lDh(`R81T#XvYb1aOR)|y-?jyh@std&s*QcYA&gxh+5 zF9jVqX{e-_f*SEW)XYy{S-gST9a+}7eZM&DiXQ44P@(+_+v9h*21~7XJA?2uRD=UI znDZe7b%6E5s_0FjaF4=j)JZmdqnW|is0Y7Ab#MiT<2_VldTugF`X(xrOHl{XXQ(6m zs;%eSY}%=g+69epGTg*u|8IMsPw$<(XE7!HRxqbhF@ig4dz76`o z?L6f3i`z|zzx>en4Qkh1MCHm;RPtr|$Xw5X+Qt=7ITnls^!{%^K_l;sY9IlX15;7k zXFVzsTd*MRMm2O2wZ>;r5&F^A@1l0o6MOv`YH9yL4ak3oF(3N={f}Z4)L>-{z#6vR z4YN@1i`q7WP|1^m8t_}F2G^hlvKf^tJ5d8Vk8Zq%%B7pA=l?_v(0`}h|G6kA`wO53 z(g~H#Jx~#eN3Gpt)PNSCX0#L)nRTcE>_81@ul@Xpt)D_g?mX)G`>0*?%T9M^-^j9j zY}TX%s-eoLEUk%}Ne9%(dtfb$K^-_tQ3E@OTGNvs?;JC9N?6vEsN}e$)SX#Y&uue5 z<6eQC7w_#an!C&B)Y!xXkNGSmV}Z+6IcxekKUeAWY}s7R?KA0}vboCIaz<)4SAJLe zO@G&{jJLD9mS)cNKbpuWoX7QHo{UvxT-V$g>&m*a`S**8O-@XR8x!Mn8=dfuSk z8J94`GdeNNlbYyBPD}8NZkw2v?6EN$o*11LACuxqOiN9Pi;l5T@kN45p4f;HdYUJr z5)%f;4M|ImVbCcdidl$f{>1$S8b`&)L?r9uRJx_1!T&vikui)b#uE`8t!Dmr$eU#R z`kHH^f5z5+t{#5gv=rTCM;7XdNQm}LDJ3R8CMq>1+N@M;M2e?JLR?fLqm7DlU{A2JE)Z>Vnj@oCn_dBKE}hu<0JknAwAK7Hct~`e3|-hw*2>I zl9jB-;@l?-r0<{Lj?36K!5xq}BlInI`>g46r@KpKWSQ=MCwKl9F;Q{$oSJSh{#pG;_prK4L)Yfzlj=? zs@X-w#tf&+lp~vzu5pP82}d@0;u50bnO-zoEXtRwM>Zx?wh5dP;t7xOBqXMdh>1x1 iPXh^TG2%}0G8v8I$fgjdr-GE4%m_0gKXB*D{(k_gAgO8q diff --git a/spyder/locale/fr/LC_MESSAGES/spyder.po b/spyder/locale/fr/LC_MESSAGES/spyder.po index 9f538bfe89f..721caba1890 100644 --- a/spyder/locale/fr/LC_MESSAGES/spyder.po +++ b/spyder/locale/fr/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-04 09:04\n" "Last-Translator: \n" "Language-Team: French\n" -"Language: fr_FR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: fr\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: fr_FR\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -59,14 +58,12 @@ msgid "Dock the pane" msgstr "Ancrer le volet" #: spyder/api/widgets/main_widget.py:344 spyder/api/widgets/main_widget.py:448 spyder/plugins/base.py:204 spyder/plugins/base.py:512 -#, fuzzy msgid "Unlock position" -msgstr "Position du curseur" +msgstr "Déverrouiller la position" #: spyder/api/widgets/main_widget.py:345 spyder/api/widgets/main_widget.py:453 spyder/plugins/base.py:205 spyder/plugins/base.py:516 -#, fuzzy msgid "Unlock to move pane to another position" -msgstr "Aller à la position suivante du curseur" +msgstr "Déverrouiller pour déplacer le panneau vers une autre position" #: spyder/api/widgets/main_widget.py:351 spyder/plugins/base.py:212 msgid "Undock" @@ -85,14 +82,12 @@ msgid "Close the pane" msgstr "Fermer le volet" #: spyder/api/widgets/main_widget.py:442 spyder/plugins/base.py:506 -#, fuzzy msgid "Lock position" -msgstr "Position du curseur" +msgstr "Verrouiller la position" #: spyder/api/widgets/main_widget.py:446 spyder/plugins/base.py:510 -#, fuzzy msgid "Lock pane to the current position" -msgstr "Aller à la position suivante du curseur" +msgstr "Verrouiller le panneau à la position actuelle" #: spyder/api/widgets/toolbars.py:123 msgid "More" @@ -195,27 +190,21 @@ msgid "Initializing..." msgstr "Initialisation en cours..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." -msgstr "" -"Il n'a pas été possible de fermer l'instance précédente de Spyder.\n" +msgstr "Il n'a pas été possible de fermer l'instance précédente de Spyder.\n" "Redémarrage interrompu." #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." -msgstr "" -"Spyder n'a pas pu rétablir les paramètres par défaut.\n" +msgstr "Spyder n'a pas pu rétablir les paramètres par défaut.\n" "Redémarrage interrompu." #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." -msgstr "" -"Il n'a pas été possible de redémarrer Spyder.\n" +msgstr "Il n'a pas été possible de redémarrer Spyder.\n" "Opération annulée." #: spyder/app/restart.py:145 @@ -247,15 +236,14 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "Le contexte du raccourci clavier doit correspondre à '_' ou à la `CONF_SECTION` du plugin !" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" -msgstr "" +msgstr "Une erreur s'est produite lors du chargement des options de configuration de Spyder. Vous devez les réinitialiser pour que Spyder puisse se lancer.\n\n" +"Voulez-vous continuer ?" #: spyder/config/manager.py:668 msgid "Spyder configuration files resetted!" -msgstr "" +msgstr "Les fichiers de configuration de Spyder ont été réinitialisés !" #: spyder/config/utils.py:25 spyder/plugins/console/widgets/main_widget.py:512 spyder/plugins/explorer/widgets/explorer.py:1710 spyder/plugins/profiler/widgets/main_widget.py:462 spyder/plugins/pylint/main_widget.py:868 msgid "Python files" @@ -714,8 +702,7 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "Réglez ceci pour les écrans à haute résolution élevés lorsque la mise à l’échelle automatique ne fonctionne pas" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" msgstr "Entrez des valeurs pour différents écrans séparées par des points-virgules ';'. Les valeurs flottantes sont prises en charge." @@ -1068,16 +1055,13 @@ msgid "not reachable" msgstr "pas atteignable" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" -msgstr "" -"L’installation de Kite se poursuivra en arrière-plan.\n" +msgstr "L’installation de Kite se poursuivra en arrière-plan.\n" "Cliquez ici pour afficher à nouveau la fenêtre d’installation" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" msgstr "Cliquez ici pour afficher à nouveau la boîte de dialogue d'installation" @@ -1298,12 +1282,10 @@ msgid "Enable Go to definition" msgstr "Activer Aller à la définition" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." -msgstr "" -"Si cette option est activée, un clic gauche sur le nom d'un objet\n" +msgstr "Si cette option est activée, un clic gauche sur le nom d'un objet\n" "tout en appuyant sur la touche {} permet d'accéder à la\n" "définition de cet objet (si résolue)." @@ -1320,8 +1302,7 @@ msgid "Enable hover hints" msgstr "Activer les indices de survol" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." msgstr "Si cette option est activée, survolez le nom d'un objet avec le pointeur de la souris pour afficher la signature et/ou le doctring de cet objet (si présent)." @@ -1527,8 +1508,7 @@ msgid "down" msgstr "arrêté" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." msgstr "Complétions, linting, pliage de code et statut des symboles." @@ -1737,17 +1717,19 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "Pour utiliser des commandes telles que \"raw_input\" ou \"input\" utiliser Spyder avec l'option (--multithread) depuis un terminal de commande" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" -msgstr "" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" +msgstr "Console interne de Spyder\n\n" +"Il s'agit d'une console de débogage\n" +"utilisée par Spyder pour signaler des erreurs\n" +"internes ou pour inspecter les entrailles de\n" +"l'application avec les commandes ci-dessous :\n" +" spy.app, spy.window, dir(spy)\n\n" +"Ne l'utilisez pas pour exécuter votre propre code.\n\n" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 msgid "&Quit" @@ -1762,7 +1744,6 @@ msgid "&Run..." msgstr "Exécute&r..." #: spyder/plugins/console/widgets/main_widget.py:191 -#, fuzzy msgid "Run a Python file" msgstr "Exécuter un script Python" @@ -1811,9 +1792,8 @@ msgid "Internal console settings" msgstr "Options de la console interne" #: spyder/plugins/console/widgets/main_widget.py:510 -#, fuzzy msgid "Run Python file" -msgstr "Fichiers Python" +msgstr "Exécuter un script Python" #: spyder/plugins/console/widgets/main_widget.py:569 msgid "Buffer" @@ -1932,14 +1912,12 @@ msgid "Tab always indent" msgstr "Toujours indenter avec la touche Tab" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)" -msgstr "" -"Si cette option est activée, presser la touche Tab\n" +msgstr "Si cette option est activée, presser la touche Tab\n" "provoquera toujours l'indentation de la ligne,\n" "quelle que soit la position du curseur (lorsque cette\n" "option est activée, la complétion de code reste \n" @@ -1950,8 +1928,7 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "Supprimer automatiquement les espaces en fin de ligne lors d'un changement de ligne" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." msgstr "Si cette option est activée, les lignes de code modifiées (à l'exclusion des chaînes) verront leurs espaces finaux supprimés en les laissant.Si ce paramètre est désactivé, seuls les espaces ajoutés par Spyder seront supprimés." @@ -2126,7 +2103,7 @@ msgstr "Exécuter du code" #: spyder/plugins/editor/confpage.py:368 msgid "This option is disabled since the Autoformat files on save option is active." -msgstr "" +msgstr "Cette option est désactivée car l'option Autoformat des fichiers lors de la sauvegarde est active." #: spyder/plugins/editor/panels/classfunctiondropdown.py:46 spyder/plugins/editor/panels/classfunctiondropdown.py:47 spyder/plugins/editor/panels/classfunctiondropdown.py:145 spyder/plugins/shortcuts/widgets/table.py:144 msgid "" @@ -2278,8 +2255,7 @@ msgstr "Anvancer dans" #: spyder/plugins/editor/plugin.py:645 msgid "Step into function or method of current line" -msgstr "" -"Avancer dans la fonction, méthode \n" +msgstr "Avancer dans la fonction, méthode \n" "ou classe de la ligne en cours" #: spyder/plugins/editor/plugin.py:651 @@ -2332,30 +2308,28 @@ msgstr "Exécuter la sélection ou le bloc de lignes" #: spyder/plugins/editor/plugin.py:702 msgid "Run &to current line" -msgstr "" +msgstr "Exécuter jusqu'à la ligne actuelle" #: spyder/plugins/editor/plugin.py:703 spyder/plugins/editor/widgets/codeeditor.py:4447 msgid "Run to current line" -msgstr "" +msgstr "Exécuter jusqu'à la ligne actuelle" #: spyder/plugins/editor/plugin.py:709 msgid "Run &from current line" -msgstr "" +msgstr "Exécuter depuis la ligne actuelle" #: spyder/plugins/editor/plugin.py:710 spyder/plugins/editor/widgets/codeeditor.py:4451 msgid "Run from current line" -msgstr "" +msgstr "Exécuter depuis la ligne actuelle" #: spyder/plugins/editor/plugin.py:717 spyder/plugins/editor/widgets/codeeditor.py:4430 msgid "Run cell" msgstr "Exécuter la cellule" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" -msgstr "" -"Exécuter la cellule courante\n" +msgstr "Exécuter la cellule courante\n" "[Utilisez #%% pour créer de nouvelles cellules]" #: spyder/plugins/editor/plugin.py:729 spyder/plugins/editor/widgets/codeeditor.py:4434 @@ -2711,31 +2685,22 @@ msgid "Removal error" msgstr "Erreur de suppression" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" -msgstr "" -"Impossible d'effacer les résultats de ce notebook :\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" +msgstr "Impossible d'effacer les résultats de ce notebook :\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 msgid "Conversion error" msgstr "Erreur de conversion" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" -msgstr "" -"Impossible de convertir ce notebook. Message d'erreur :\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" +msgstr "Impossible de convertir ce notebook. Message d'erreur :\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:4412 msgid "Clear all ouput" msgstr "Effacer tous les résultats" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" msgstr "Convertir en fichier Python" @@ -2892,9 +2857,7 @@ msgid "Recover from autosave" msgstr "Récupérer de la sauvegarde automatique" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." msgstr "Fichiers de sauvegarde automatique trouvés. Que voulez-vous faire ? Cette boîte de dialogue s'affichera de nouveau au prochain démarrage si des fichiers enregistrés automatiquement ne sont pas restaurés, déplacés ou supprimés." @@ -3191,24 +3154,16 @@ msgid "File/Folder copy error" msgstr "Erreur de copie de fichier / dossier" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" -msgstr "" -"Impossible de copier ce type de fichier ou dossier. L'erreur était:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" +msgstr "Impossible de copier ce type de fichier ou dossier. L'erreur était:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1416 msgid "Error pasting file" msgstr "Erreur de collage du fichier" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" -msgstr "" -"Opération de copie non supportée. L'erreur était :\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" +msgstr "Opération de copie non supportée. L'erreur était :\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1436 msgid "Recursive copy" @@ -3663,11 +3618,9 @@ msgid "Display initial banner" msgstr "Afficher le message d'accueil" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." -msgstr "" -"Cette option permet de masquer la message d'accueil\n" +msgstr "Cette option permet de masquer la message d'accueil\n" "qui s'affiche à l'ouverture de la console." #: spyder/plugins/ipythonconsole/confpage.py:35 @@ -3679,11 +3632,9 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "Demander confirmation avant de supprimer les variables définies par l'utilisateur" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." -msgstr "" -"Cette option permet de masquer le message de confirmation\n" +msgstr "Cette option permet de masquer le message de confirmation\n" "lors de la suppression des variables de Spyder." #: spyder/plugins/ipythonconsole/confpage.py:43 spyder/plugins/ipythonconsole/widgets/main_widget.py:475 @@ -3695,11 +3646,9 @@ msgid "Ask for confirmation before restarting" msgstr "Demander confirmation avant de redémarrer" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." -msgstr "" -"Cette option permet de masquer le message d'alerte affiché\n" +msgstr "Cette option permet de masquer le message d'alerte affiché\n" "lors du redémarrage du noyau." #: spyder/plugins/ipythonconsole/confpage.py:59 @@ -3735,8 +3684,7 @@ msgid "Buffer: " msgstr "Tampon : " #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" msgstr "Nombre maximum de lignes de texte affichées dans la console avant troncature (saisir -1 désactive cette dernière, ce qui est fortement déconseillé)." @@ -3754,13 +3702,11 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "Importer automatiquement Pylab et NumPy" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." -msgstr "" -"Cela vous permet de charger le support graphique sans importer\n" +msgstr "Cela vous permet de charger le support graphique sans importer\n" "les commandes pour faire des graphes. Ceci est utile pour travailler\n" "avec des bibliothèques graphiques autres que Matplotlib ou pour développer\n" "des GUI avec Spyder." @@ -3838,14 +3784,12 @@ msgid "Use a tight layout for inline plots" msgstr "Utiliser une disposition ajustée pour les graphes en ligne" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" "that created using savefig." -msgstr "" -"Définir bbox_inches à \"ajusté\" pour\n" +msgstr "Définir bbox_inches à \"ajusté\" pour\n" "les graphes en ligne avec matplotlib.\n" "Lorsqu'activée, cette option peut être source\n" "de différences entre l'image en ligne et\n" @@ -4268,20 +4212,12 @@ msgid "Connection error" msgstr "Erreur de connexion" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" -msgstr "" -"Une erreur s'est produite lors de la tentative de chargement du fichier de connexion au noyau. L'erreur était :\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" +msgstr "Une erreur s'est produite lors de la tentative de chargement du fichier de connexion au noyau. L'erreur était :\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" -msgstr "" -"Impossible d'ouvrir un tunnel ssh. L'erreur rencontrée était :\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" +msgstr "Impossible d'ouvrir un tunnel ssh. L'erreur rencontrée était :\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 msgid "The Python environment or installation whose interpreter is located at
    {0}
doesn't have the spyder-kernels module or the right version of it installed ({1}). Without this module is not possible for Spyder to create a console for you.

You can install it by activating your environment (if necessary) and then running in a system terminal:
    {2}
or
    {3}
" @@ -4452,11 +4388,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "La mise en page {0} sera remplacée. Voulez-vous continuer ?" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"La disposition des fenêtres sera réinitialisée selon les réglages par défaut.\n" +msgstr "La disposition des fenêtres sera réinitialisée selon les réglages par défaut.\n" "Souhaitez-vous continuer ?" #: spyder/plugins/layout/layouts.py:81 @@ -4560,7 +4494,6 @@ msgid "Enable UMR" msgstr "Activer l'UMR" #: spyder/plugins/maininterpreter/confpage.py:122 -#, fuzzy msgid "This option will enable the User Module Reloader (UMR) in Python/IPython consoles. UMR forces Python to reload deeply modules during import when running a Python file using the Spyder's builtin function runfile.

1. UMR may require to restart the console in which it will be called (otherwise only newly imported modules will be reloaded when executing files).

2. If errors occur when re-running a PyQt-based program, please check that the Qt objects are properly destroyed (e.g. you may have to use the attribute Qt.WA_DeleteOnClose on your main window, using the setAttribute method)" msgstr "Cette option active le User Module Deleter (UMR) dans les consoles Python. L'UMR force Python à recharger complètement les modules lors de leur importation, dans le cadre de l'exécution d'un script Python avec la fonction Spyder runfile.

1. UMR peut nécessiter le redémarrage de la console Python dans lequel il va être utilisé (dans le cas contraire, seuls les modules importés après activation de l'UMR seront rechargés complètement lors de l'exécution de scripts).

2. Si des erreurs survenaient lors de la réexécution de programmes utilisant PyQt, veuillez vérifier que les objets Qt sont correctement détruits à la sortie du programme (par exemple, il sera probablement nécessaire d'utiliser l'attribut Qt.WA_DeleteOnClose sur votre objet fenêtre principale grâce à la méthode setAttribute)" @@ -4593,11 +4526,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "Vous travaillez avec Python 2, cela signifie que vous ne pouvez pas importer un module qui contient des caractères non ASCII." #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"Les modules suivants ne sont pas installés sur votre ordinateur :\n" +msgstr "Les modules suivants ne sont pas installés sur votre ordinateur :\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4937,8 +4868,7 @@ msgid "Results" msgstr "Résultats" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "Les résultats du profileur Python sont stockés ici :" @@ -5344,11 +5274,11 @@ msgstr "Gérer la configuration d'exécution." #: spyder/plugins/run/widgets.py:31 msgid "Run file with default configuration" -msgstr "" +msgstr "Exécuter le script avec la configuration par défaut" #: spyder/plugins/run/widgets.py:32 msgid "Run file with custom configuration" -msgstr "" +msgstr "Exécuter un fichier avec une configuration personnalisée" #: spyder/plugins/run/widgets.py:33 msgid "Execute in current console" @@ -5855,13 +5785,9 @@ msgid "Save and Close" msgstr "Enregistrer et fermer" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"Afficher cette variable peut prendre du temps.\n" -"\n" +msgstr "Afficher cette variable peut prendre du temps.\n\n" "Souhaitez-vous néanmoins continuer ?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5901,11 +5827,9 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Spyder n'a pas pu récupérer la valeur de cette variable de la console.

Le message d'erreur était :
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" -msgstr "" -"Il n'est pas possible d'afficher cette valeur car\n" +msgstr "Il n'est pas possible d'afficher cette valeur car\n" "une erreur est survenue en essayant de le faire" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:621 @@ -6322,7 +6246,7 @@ msgstr "Éditeur de texte" #: spyder/plugins/workingdirectory/confpage.py:28 msgid "This is the directory that will be set as the default for the IPython console and Files panes." -msgstr "" +msgstr "C'est le répertoire qui sera défini par défaut pour la console IPython et les panneaux Fichiers." #: spyder/plugins/workingdirectory/confpage.py:37 msgid "At startup, the working directory is:" @@ -6485,8 +6409,7 @@ msgid "Legal" msgstr "Légal" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6496,8 +6419,7 @@ msgid "" " Hint:
\n" " Use two spaces or two tabs to generate a ';'.\n" " " -msgstr "" -"\n" +msgstr "\n" " Constructeur de Tableau/Matrice Numpy
\n" " Tapez un tableau dans Matlab : [1 2; 3 4]
\n" " ou la syntaxe simplifiée de Spyder: 1 2; 3 4 \n" @@ -6509,8 +6431,7 @@ msgstr "" " " #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6520,8 +6441,7 @@ msgid "" " Hint:
\n" " Use two tabs at the end of a row to move to the next row.\n" " " -msgstr "" -"\n" +msgstr "\n" " Constructeur de Tableau/Matrice Numpy
\n" " Entrez un tableau Numpy dans le tableau.
\n" " Appyuyez sur la touche 'Tab' pour vous déplacer entre les cellules.\n" @@ -6728,9 +6648,8 @@ msgid "Drag and drop pane to a different position" msgstr "" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Ancrer le volet" +msgstr "" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6905,37 +6824,32 @@ msgid "Remove path" msgstr "Supprimer" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Importer en tant que" +msgstr "" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "Synchronise la liste des chemins d'accès de Spyder avec celle de la variable d'environnement PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" msgstr "" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "Synchronise la liste des chemins d'accès de Spyder avec celle de la variable d'environnement PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "Gestionnaire de PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." msgstr "" #: spyder/widgets/pathmanager.py:276 -#, fuzzy msgid "This will export Spyder's path list to the PYTHONPATH environment variable for the current user, allowing you to run your Python modules outside Spyder without having to configure sys.path.

Do you want to clear the contents of PYTHONPATH before adding Spyder's path list?" -msgstr "Ceci synchronisera la liste des chemins d'accès de Spyder avec celle de la variable d'environnement PYTHONPATH pour le présent utilisateur, vous permettant ainsi d'exécuter vos modules Python en dehors de Spyder sans avoir besoin de configurer sys.path.
Souhaitez-vous effacer le contenu de PYTHONPATH avant d'y ajouter la liste des chemins d'accès de Spyder ?" +msgstr "" #: spyder/widgets/pathmanager.py:394 msgid "This directory is already included in the list.
Do you want to move it to the top of it?" @@ -6986,9 +6900,8 @@ msgid "Hide all future errors during this session" msgstr "Masquer toutes les futures erreurs pour cette session" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "Ouvrir une console IPython ici" +msgstr "" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7098,20 +7011,3 @@ msgstr "Impossible de se connecter à internet.

Vérifiez que votre acc msgid "Unable to check for updates." msgstr "Impossible de vérifier les mises à jour." -#~ msgid "Run Python script" -#~ msgstr "Exécuter un script Python" - -#~ msgid "Python scripts" -#~ msgstr "Scripts Python" - -#~ msgid "Select Python script" -#~ msgstr "Sélectionner un script Python" - -#~ msgid "Synchronize..." -#~ msgstr "Synchroniser..." - -#~ msgid "Synchronize" -#~ msgstr "Synchroniser" - -#~ msgid "You are using Python 2 and the selected path has Unicode characters.
Therefore, this path will not be added." -#~ msgstr "Vous utilisez Python 2 et le chemin sélectionné inclus des caractères Unicodes. Le nouveau chemin ne sera pas ajouté." diff --git a/spyder/locale/hr/LC_MESSAGES/spyder.po b/spyder/locale/hr/LC_MESSAGES/spyder.po index 85a5ecbbbc1..3a92f41b907 100644 --- a/spyder/locale/hr/LC_MESSAGES/spyder.po +++ b/spyder/locale/hr/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-01 17:40\n" "Last-Translator: \n" "Language-Team: Croatian\n" -"Language: hr_HR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: hr\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: hr\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: hr_HR\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -191,20 +190,17 @@ msgid "Initializing..." msgstr "Pokretanje..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." msgstr "" #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." msgstr "" #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." msgstr "" @@ -237,9 +233,7 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" msgstr "" @@ -704,8 +698,7 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" msgstr "" @@ -1058,14 +1051,12 @@ msgid "not reachable" msgstr "" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" msgstr "" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" msgstr "" @@ -1286,8 +1277,7 @@ msgid "Enable Go to definition" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." msgstr "" @@ -1305,8 +1295,7 @@ msgid "Enable hover hints" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." msgstr "" @@ -1512,8 +1501,7 @@ msgid "down" msgstr "" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." msgstr "" @@ -1722,16 +1710,12 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" msgstr "" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 @@ -1915,8 +1899,7 @@ msgid "Tab always indent" msgstr "" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" @@ -1928,8 +1911,7 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." msgstr "" @@ -2327,8 +2309,7 @@ msgid "Run cell" msgstr "" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" msgstr "" @@ -2685,9 +2666,7 @@ msgid "Removal error" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 @@ -2695,9 +2674,7 @@ msgid "Conversion error" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4412 @@ -2861,9 +2838,7 @@ msgid "Recover from autosave" msgstr "" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." msgstr "" @@ -3160,9 +3135,7 @@ msgid "File/Folder copy error" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1416 @@ -3170,9 +3143,7 @@ msgid "Error pasting file" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1436 @@ -3628,8 +3599,7 @@ msgid "Display initial banner" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." msgstr "" @@ -3642,8 +3612,7 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." msgstr "" @@ -3656,8 +3625,7 @@ msgid "Ask for confirmation before restarting" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." msgstr "" @@ -3694,8 +3662,7 @@ msgid "Buffer: " msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" msgstr "" @@ -3713,8 +3680,7 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." @@ -3793,8 +3759,7 @@ msgid "Use a tight layout for inline plots" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" @@ -4218,15 +4183,11 @@ msgid "Connection error" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 @@ -4398,8 +4359,7 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" msgstr "" @@ -4536,8 +4496,7 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "" #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" msgstr "" @@ -4878,8 +4837,7 @@ msgid "Results" msgstr "" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "" @@ -5796,9 +5754,7 @@ msgid "Save and Close" msgstr "" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" msgstr "" @@ -5839,8 +5795,7 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" msgstr "" @@ -6421,8 +6376,7 @@ msgid "Legal" msgstr "" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6435,8 +6389,7 @@ msgid "" msgstr "" #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -7007,3 +6960,4 @@ msgstr "" #: spyder/workers/updates.py:136 msgid "Unable to check for updates." msgstr "" + diff --git a/spyder/locale/hu/LC_MESSAGES/spyder.mo b/spyder/locale/hu/LC_MESSAGES/spyder.mo index 3f7d5999279d2bc88a6b9eab3d1041611f4bf06c..15e82765aac686df8d081139d1ccac19d31613ef 100644 GIT binary patch delta 9462 zcmYM&33yLe8prV~vXTW6BtjC|L>7reLI|-$L~5JZmspA@gRwN0sa}*KYH10kT54Km zs%l#Cw4zmFMya;Slqsf6CmmZ$Rn-h3)A{~#&ht#4dHOl;x#yncJ?GqiI*%$n&cEm3 zUTNZ4?(k1<5620|u1ysE|DQ22j^j-@7VG0A^u?*Rel~g$FR(sqU5@_Lm!aBkK|g%W zx(9jBbq?4DhtP)xe@0LI)W&B}16;8271V^T+4|e40q$WeKDKci0@Wv4Gpsq*K6Sc| zGmyfQG#r8X_#qBQ&p5}Kh-0uT9>E3p7&Gwcc$2X`*p&DL^2qrbJ7fK}j?)8kuqV3M z8;{{&^ht1>G{$#EQs{zfFbhwhCiDyH#V6aD`rW8@SFjU?B|6SLEW(C(53v5h%6#8L0x*-&DD73*M)C5Yg0q#R}T#5Y8Id1EJLS>*9)lr9JGf-Fjh&T`R zo@a`A-Uvg9!!Q*ykR&^kQ`rByl~Yl-a%6GNFPMq#+MA9ipw8tq>tggI-hi5DIX1%A zPy@efJ&bDi398*`TYnL~iLbUN|0>*~LMyq4N?k4L1uv$n`bO3;B)d)wdf_0{`@`(> zvB=z=5)8!6NHUyV)-N%NxL%s$w7_VWLfy5qjzk@%Qf!24kOS?!g4**cR3>Ur69{0# z6$e}6Py=_i_C=+BIBKhBV=%77#^`RN(1gO<=!-{D8TiD;XHglrgj(spP#t`aLHG+Q zd@WNN_ns1)Z~N1{GpbFc}Pq9@}!t0`!O>rfqS#Y?ywmGY$>O~;2Yn)n-3 zJ5Nq%Uu=Q}I2tv!y)@QCIgrHWQhAprY2IF8MV^g=bNqr0e6`fpU~{I~*|aXv1^0jP|4LOy`=A@D;{x==A}qlXw*Dw;BBxL*J&T&a74*Ps_W4b$ zPy8bu#-Fi0?$0y*RwFmvxt>StP=~M`{tB~gycTth-B&5-P`r=I#HXkXoVQkE8u1;}1fmLzap+5&h+26D zdSgEF1L+K~^~-Jj7WAWjJL>!LMKtJ4QpTCOg?@f%sgUESvE~DB-KVcS}jDEWRITTu9KI%I#8MP&)s6$g`;~l8O zRe}Ckg*pqTQJFf2+LCM70Pmo-rp7)G;DD+B2vjCg(bZlRP|%EB)XLZ4P<#)g(SLyX zjYvg(5AyLDT!F>tG0;qS0_spbjhe`M)K+e_@jLc;C61x~%Yo#-GlkSa=8HECmD07S z75o*ovdgI7hC8SMyat;?7=g+_3rOBa%UQ2HfjP-U?-f2n)o(sg6FaBexusgq9znn!~w67G4A!1p%3kIIaW{9KrYUGQ7<(fyAYYf=-BT2U%$&oZrDthuO_RX2$q%R(9-=9~#o)b*}ffLBTu3H!tdZ7)*W3r8hTE|8jmI$tZ=zBhIL-VwUK%PMkHPpH>b)%(if^O0?*9o2zEqq>&Fm}m z!>cyFgIYnY)o;3K7lI9_k4C+pf_g8@+5`37D70}gHYA>k`sB}3J>xq&DR|>ss0{2! zrSb@B=65k3y?$-}pOR$MSs8|rI2Vn}1@G2_B_b~wL%{5yQgxZ2g)I<|(oQ^)k zU9l1Nw)O5%3Q1HHqb9Nj)9?%?pvOG3!X(s-Y1kNhViO#K`aYDPGB5-6eOQ3X+5byP${fIrP_18xrU7}i?|c2!{4ALxCFJ* zb*Njl4K(Wf+LBV+ig?P2?oDz;mb+-$rGo z;X1yUk>JCU(}gdjcWhOBJv+e;bSV);dN|=cTp4Y{jGT+1eKY#s19>c zTU3bZXe@@}N?X4ZHIWKzheuGi;ycvd-$R|1;AhCc8pc0k21-XAsy-NrPhu7>#|-=s z3-K;?!koos;8JW$ya9EVDp4yvf?CL5Q5pP)eSRC2*?TSpt-J=ca?fW?hb>VZbws_` z1@%IKtsi9LVW^CZMZG^2b!J>l$8{Kn$FM72Mji4n7OwZ)L<&k_CTb$%Q5{Ufmbegg z%F8hvccBJ4hVgh2m5F*w%u2IS6VFGbxIZT21XTTc)E_eC$QHWJ1qw>lcc@g=qF!vU z)SUX(s0?JFp7*p4M7=i#HSt;2g{Xm^Lv8J99F66ue($2rg4Z%-g4qXBP--L41LJI* zVB=JLk@|Fuz~iXYUB!<0BkC~5J!dl32{q6VR3@ildz_D2z#mWxI)HV*|HmlkT78CE z(K*!2uiN^2s23lhQt!LmtT+NSPy%WKIjF4|XzN#DHt{Q{34Dd>*Jp*vP%yfhVJHQy zs4Z$D=~#E@Y<&UNtqA+_d@5=p?_w)FiMoE*Q13aT3OvQy5gL_cd^9xk}53HW6 zwEt8zT4h!gftpYzs=goUx{O4N+2(YhP8@=DYSPhcXRMSZwxQT>LjHsd5< z5^=6;3o}pyy^LDfTd0g2##sCk)A1o{pyV>saTnA?i)=g*_1+@XH+};~;hU)Mz^AAQ zT|+I%{VxSS3JunnpUI}!fH(oQ#~G-O3hnbrSodAPMC!{i0jp3G`yMsnhd34^UNC^EMj77xVkdB;}uq~cN zWvT}CyPvY&*bB9FBQPGH#$et5S1B~4;$76hhcOT@pbp=4^hNIt=5_?3+BZi%k4F7A z^u$b@kA=7obw+AXnGAT*#4(smoFy6GnM^?|+=xo;yQn?<9JPXPQ9nWtP+OJylG(#- z^dbHgDz&3f*UCk2T!HHM1ynzKFcFW~`tQ+gLWSQ(llpMf-e;me_CO6d05#AERQvJR z80VtS#tKZq?Woj#j>_bH)Rr{bWcurjI*ff#TRn9X`PYh5`3 zg~8bDcV>m{P|q{50DGZM`&x{|&rl1vjq30L>I`_lZ2n62N3FCmdSFY`#9E^k;-*m0 z3t6ZX=Aar5LfwKQ^u&o6iY2JCuoTsPH7WzIp!zw48t^FU{ZpucF53EP)P!##{X{$e zp`aE1f*Qboo9Q49b*fXhRUC>bkEtjwXzWo@Uf9&5;-mgSJ{7HoE@{vpD>*eSGA%Vb pBX#2+#2_kUhjqq6`2 delta 9865 zcmY+|30#*|zQ^$=Py_@-MFm9tD=3>NxFPPM2)OU!LW)4hpg=4tsQnX{)H0LAo4MT< zmr8RQ3Zt|cm!@(i8<{fmWvOMi(s8QMtgBAm&zEz0-Em&MdY^Niv;5BQoagy7eNyLf zr{2T;MXM$?4*whN;W%Nqu$5~6|Gy&a^v3sX`x$IX{haj+>lO5){VJ;e z&*+0rSEDa7&g}$IP=`=#hT+%*V{JVin^Ny@>qAfhjk4|IP~)c{|2T7Oy~_Hyb&GYU zb#J3?$2mkHn}Kg)Du#7)oEeyd^YI7{!cLKnvlQoGAN&NBG2bZ13BpLEt1}3bFdK*C zPE5v&I1;-=JI*A`!vxlM>M0DwYnX_UF(#n7sDUR??Ow5_-w^Ciy%Ni?7Mo*KcgN|4 zeXtqkp*I#;mtsrmE71p^Mt2(uJ1KO*TGT>k(H8^a%)}u`lhYN|J{gsPOw>fxsD*am zJGciGP)-l?yck2MS7JPFL6Ysf+k^9O+&K-6JMZZ@Y}T2J{crW=(? z3g{;cM9r>+TUtAy`bDGq^|0+p=tVsxp8TsYmIm!)3MzG(r~$dQz1UibWZPMdP4V}r z@pbn3QDiU9dl-PXkYqSb6O6sFGxaPC##L?#xR@oP#FnE1rTTJ zeNfNS(I01{E>%8i!A+@(L<- zb*O&t*!pSY;yY(?5_)k#WCrSWei@a?4^aU%pfYm_mAN0$tqz`iuC%Z>>XHPZE@ccV z(o|HxNwz*6gQ(BL_E?VE`DP5pXRtNChMMH)Re@A8P5^4kAqK@ugsMoVCA072e8A$%sVKNO$ z^&C`$c{mOi;Rt*iwZnU;iM{wiP)Y+(0feEBrZXz=1Y1u>rFtf6TmkCxm0~ws>!uJx z;UFrIbEq%gm#6{PPz&6!^IiP3&N^_2$xJk=9*^pmj>^b1)CVaSmBBJpV9T(h z-v4zJbeZ;{F2xbl!VMUYpQ8c`7-}XAMxAvODicYl2~+L!si^sKa12%;u{!VKJoFr9 zUe_Gd{c~QWKvtb^Q4w|>ZWic{3S=;9M``GZQ?W6X_IW1i%om_i`Y7rMt5F%)j@rO0 zsBv$g)_F^H)_0Ck(2o9$>i8+@{k@FZVH5rcP`wqZ-WL_v2#mw=sEL=OGO-es@+WP5 zCx%jg8TDGeizLkGw5>|0yVC(Wpoh(S@nl_*!A(YlUI7SECjOMjQ0e2~r}saEf)Dj>s0rdxmuDzO;s{g#1*lA|KxJq% zHpgAom$5tbI^@1OU!mrW8f*IXLd`z}BXAt1=>0FDppI{%GH~418?XcQ3#jkIZBziw zxhT3!olx~Ys0^l|A5KTzk!)1p`KTjWhRVol)KP6hwV?T)IxEfRZNa{3#^chFP{lHqN1a z1@gCoa~`{3&?J-EMAS}}qXK^n_1m!xb*m4dF6jrTOq|95{1~s12M#eK*dcG7`#psb3$|ok~Gn za(6Zb4ah^KrWCdCa@)QVmFkV?kI&#kxEB-9e>%T>I28Hg$k~s5@e1k_8#=@MNKHfi z2(3c?adz>qLcRaDDCpNHH-npxFJcVl%`^d3qXO8D+TmW*Thf4Zb-qNsJp*T%z*A61 zIU3b(lC5WW1u@v(&O=>?!oqY)Jl#Z%1 zYDY1sBa658wGKdSWGHIkakhOb>JrXGw|+JYD5zs0YQp6hja8_D2ki4&R0fWr`kg}s z{sk)VZ&CekpaQ&&>gPGv1n7f0k^pRv9p`fX>NtRgt~dhEJ;07pJ4((nw=@;?WgCN9 zC<7I6Hfq6#Q7K+(pRcy{O{gQ;ZrfkBzJUtlPg!o$;cxcAMe9{mU_YUD_$z7w@A>8k zf>42kVI)S{`ULANYp%7}y3D#3`N!Gp=HF=)ZeltP&NjFDG3-M95=NkBPUD};P890> zO~o+GMP;rEhvI%5gx_K^cFpClAY6bcSdWA89*#oy;D=1ZYSf45I1b0QdFJo_3{0ZF z$<{x>5!Ch42f;|}hcmGWK7~!O2Akn_)SY<&^#OVdJKZ~`Q20mk-zlpjNCvi8PvGs?4XU0E*8ovSczQ2Uw_(zPuudoa2JKhE6{}jfe zQa%gyJy?MnxEVumA9~?2^v3s5ft^7gylCs+VRPy~TRj(=el5_K_714=QRu_^PJ#+J z5cT0mvGokpMDwvb7TWghs6h6hA09w`itABvq)H)}cNu@7eaxuru|W7>sR;OuwGkj{2}7 z`~GLppq*x+A1=ZGthDXxQMbOvw%1|{>c>%;Yd~f00xHGd+xl%(!2ZRiUl8hfN7P-2 zE++q}6vo*OHK@pTV<$X}3g}bRWx8UYdp>M-+8%q;9)}7b3l(UYwHg)R?@<|i3-y+K zgbL(iH-(lIE+WA?KcZf*tP-=}9#lXltru`4^_!^oIjPiKCO7J`u0sXB3tjjQw!n+1 z41bUQcn1T~?NeqZ2t`F2ZtHQV_qQLa;}F|E0lQPrKn1b}6Yvy9<6YDayOo=9v8W9W z!d5sQ^?k@fGT?UdDCh%Gf=b=I2tvut>7`9D~=eKPEn67A{78nm4#9=rYx!Cag#8=IMzV*B8})gl!*f>l0BKnSmPbo=-uSrUHB8It;}lI0!#Q z-SVJE&A_gx6vm?hnS~mkgYB^tbxAj&GP4`C&=J(z@OM-u?jRd=JAGD~$cLj+oQiQc z+qSPq{Ux&rbyN+gOkF``>SxrrCROIv2ct631ND56H61l>Ix6t|M%i5{1*L8U>JF^J z*|-Tc@io+4xQEJw_bQXx5cHt#vh{FVkHO8f$6+V@BP!(=u`galZN#;jjIqAci-Hy! zk4oiy?1>MfcJLf(N3WtHKZ1I#{*2nuX;k2sZTof9xSvp|_gG`bg`n06M+K0GZkK@)pFW^9K_bvKN|MAXi6u_+dz7AQx3>DHqH-HpTX73_%DZM)w(GjA|z=aHxk zCF!>7wvM7f3r)v(T#XU<21em|)B>LCjV(|Cw@2+P1{Kh7+dd7ofgIGha@5`Nx>o1mtZ>9p)%-f zG69ET0M)*zOE?l+u)Z^&LJSQhI0bj2Ci)R|_VG`dl+8r#Y@_vg)J_g#Bz}xKn!Bis z1a3A9hoKfuL|w{J=#9D9Oz(do1r1niJ5*sG>d#?6Jd0z{zuH`$nW&U5wDnaOM}4dH zBx;BMLS;7aX>*j_Q5#6cM4W+co!KS|x&zy=8NP-}Z9VFB`vSf23TonOsEK^Gm?P_m zfz(H%zHqZq{WfDOJcK%mOQb57dGITTKR{a3J-OI1C@fa6Gn^{1;OAnueY@^}o!eTa7xBYK+HS zsK7o#-IXt~4f;Q077j;$>hTzaLr@!=g4%H=2IB(MWnPJ0aG#q(3ks)D5uZn0j+>|n z|BXsz^KItK7Hus+jXQ`+{ZVX*r?CJpp~jDW)&w{Ur&7Vnx}4qC!_`c1eD5S!wM*(^`3SzOT)hTwdtPa^2TfduMcrhyVRee&gCT z8=bwNq^K~zGS`_>QTSWgHKn*BC$}WB)K#2Sw!pP8zqHJ?B%jj6nN!myjGs7sYMLvz zaB)5(7v&a~xfW-Y-xj{0} will be overwritten. Do you want to continue?" msgstr "" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" msgstr "Ablak kirendezetet vissza az alapra: ablak helyzet, nagyság változandó.Folytatni?" @@ -4545,11 +4497,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "YPython 2-val nem lehet modult importálni miben nem-ascii betük vannak." #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"Ezek a modulok nem vannak telepítve:\n" +msgstr "Ezek a modulok nem vannak telepítve:\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4889,8 +4839,7 @@ msgid "Results" msgstr "Eredmények" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "Profilozó (python profile/cProfile kimenetei) eredményeketide tárolni" @@ -5807,13 +5756,9 @@ msgid "Save and Close" msgstr "" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"Ezt a vátozót kinyitni lassú lehet\n" -"\n" +msgstr "Ezt a vátozót kinyitni lassú lehet\n\n" "Tovább?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5853,8 +5798,7 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" msgstr "" @@ -6435,8 +6379,7 @@ msgid "Legal" msgstr "" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6449,8 +6392,7 @@ msgid "" msgstr "" #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6659,9 +6601,8 @@ msgid "Drag and drop pane to a different position" msgstr "" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Panek dokkolása" +msgstr "" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6836,28 +6777,24 @@ msgid "Remove path" msgstr "" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Adat importálása" +msgstr "" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "Összehangolni Spyder directory pálya és PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" msgstr "" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "Összehangolni Spyder directory pálya és PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "PYTHONPATH kezelő" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." @@ -7027,14 +6964,3 @@ msgstr "Internet kapcsolat nem sikerült.

" msgid "Unable to check for updates." msgstr "" -#~ msgid "Run Python script" -#~ msgstr "Python szkript futtatása" - -#~ msgid "Python scripts" -#~ msgstr "Python szkriptek" - -#~ msgid "Select Python script" -#~ msgstr "Python script kiválasztása" - -#~ msgid "Synchronize" -#~ msgstr "Szinkronizálás" diff --git a/spyder/locale/ja/LC_MESSAGES/spyder.mo b/spyder/locale/ja/LC_MESSAGES/spyder.mo index ab0924db55906a5260afe69ef269d5cdf389aef2..0ac758b45d106545e63b7e9aa5d79dc5bf06571b 100644 GIT binary patch delta 29685 zcmZ|X2Yip$|NsB%{T3@$?A^TW9b#{SQhQTdNFs=YBx-w0tk$j-d#k-^Y+AE5t5hj1 zViql0rAqxD&-Xds&-e5H-G0B%t>5qcoO4~*8Lu<0E1})6EyLgYGkAZ_<~PsbfBVxo zP62#UMY;d|-@$>7)5YaDhj19?9^^QAxjZx4aWdcr^v69&70yvig{Lqbp1}|BGN#8r ztnaY9<9MC4G3LQA%*PD_ZGIA_CO_Re2Q!mjh-z@XE#HM1$?roocoNmn1?x3bJt>$8 zAEEAhg}G?oaRxh1TF8xl7-;iFQ4f~2`HH9!*0kjzsEV3kF>GV=pI~|NUYp-$-D~~I z`mI#^-;+>CZ=f3Z3yb4B%#THfI8I-zk4;ND@OoF?0Vj<{Q|7vWD4>1^Pj&z*57&Vgk*CMfrf@=5xHPXOQrh@UP@{`yU|HWGP z(P+o1juVi}}p ztmM7Fkx&E9WXEX@eyHu!0kvIvqDC5pEin7UfdRgFEf@Ggy`U6D)&8rm?!Q zwRJT5liz@;aX0FG*^e3V3Rc$szeA!31-Yj?PCcxJ#c>p>!ByA@4`4BLW|$5ZM@6a$ zs^e`@A&$huI0Q3bg_&l1)<)Gg0`=T%%uD;u5)umWm#8^Eixu!Ds=>^&%&M=93gKW> zNY`7xM@8aad%eo%X5SA&b!Zi4#!Z+B_v2zbjb1I5u-Rs@47W~2g=`u6;Tlv$8&DzL zi2-;BHNx|#2Cm!uBMc$`5_NyIIi}$TsQTJqTI@K7`0Iuq6sV#|OoQ>Lxf+G_aXx0n ztEl@Pp+fi)b^m*tPczq)2cSBf4|A{>i=Y~;KF@ThKI-}A^N7C^9VsY-z3hdl_QrXr zk*z?5d>bk<*HI1MLOuTgwRWCkAO_Dj`@b#f`Ek~nn3Mc6R3vtLNt7kA56j_w)W~xr znS6OHM7{@BKrd<$ZATqk-&pTs4)UoMn6;1xRbOjtgJZB2p2t#HaG_aS-Xo14FD{REVde*1{@O$Iqb_@lDi7AEFkYd#R}}18UCmpduH9ic~$! zO#4oI66!!7)Q$0|5l_K-xD-|4P3wKsYW^LyHeT8CyvxiHT^O|}2cs70NSjYUH9Qs7 z@#W}M$hIkgC+&sC%gtPMvi8BMT#rUIxE3{sr%+S$1l3@^6($lzQ4N*H@z@Y`{S+3& z@30vDzJg}8SaPm3p($sriyC2T)Rc5aMIg+U53>28Sd#Lws3};73h_=b-o_8` z9cqmgSY_5q$5mdl52Gkh$cI=bU|sS_sEU6;Md~&N;%n5Db#)YUH<2bNd|g zplhu$AEqK-3DsUTEP~!YH3qZ*1sg=`io^qWz0w-42!W0)GhLq+B)7R5U@pY{t= zo(*+>LDbZh!RuHVH3eDL`3B&1J|dyHjz;z96I4eQp*oO^>d`eVOsL%QETM~R6T#8A{MaGL_9y1p?$|gqB*v}X1D-VL5lS;s=+^OKGi12 zIY>SOYK~8%Z*k%%@~_c@@te)J;yi4i`>;G_*kV5KYoV9Z*6B+^7yiYz7_^mzg5yy6 z-B=jYY%@Q2l)_Tv6R;$1!V-8Hb>Camlm%`#Bdvv*$k)d#*a|aa&+WuNfW#mQw5rFV z7UL|`8kmO}a3|_yJZ#G^V`=iwF+CR8VODo>)bpiL+p9e4d})NbuRAJY5%#)w2k}== z=Te}>u^rXJWK@q&;d&Q~2UXG7U3|&neDufuyG@1hs18p@HN4S!1U2`UF&KYC9Ymfz zW*gS?lF(7v4mHvUOoy?kIUR-Cud^{LuEn01j9oF;muAF6P|tsYIWZA6(gmpPx&syR zWL$Z%v?w``)7J zE4a_>mO4lzyiPY;FcNcd!vd^=+fY++4|Qa|#Jm`=-;BI8)+av^71EPf9`B(RXU+rW zhthheh>k!-C=qr43@onwzlB6T3NE8U@;hoRxY=WxUT#Fy#6MMblQFC8Ie9x}v zfI1O7d~HG-ifPFYLUnj3YTHdi9qsc`YiB!pkEfH#b(u}m#DKoVltpK#7d3(=s1bf_^8+xL{3z6jx1b`m2gl-m^v4=!jEzwBwMO0lG0xWh z?@eMl1%Kc#m~hto(kS#C=R5gpxB(lUXMy2UR74(MFrjo^G$X5njVO;q?TRg^$m~ZA z=rn3=U9vvH{It)vl)0e*s%OP90|ucgs)-s&6I4a*P$TV*0T_y@F&4Apa8yH6up_QS zJ@?A$x@5jFGoV)uRwJP~s%LGD8euQgTn@#oxCGUatyly1VIBMf74qQkIc9M??!nTR z`DF&)!V~O6Y zp-#SUP$5r2b?iANV)<+4xt%zgjvT=vl-K@=_~#+f`zO=z2-GeZhZ^x*^xzk$`%a-k zeG9c%@1r{Q6cxe0ZF%||=D-R-btn*hiyO5@Dx(H6!fO+gP*X7nHFt|``A*b!IfB}b zKchzc2Wl$P-!u`*fhzw1D_~{R0K)C{VW* zg-|1^jv7H@RK(ib>-|s-k3}^&3)S%zr~_*cX2x^aR{Q@3iJcS#vGcObF9))UnGAiU-F+UzhjqnyK04+F+;^yYAE7$>3bj~M zJvJQ;z*6LMKPLWVNQ6+Jkq<(D9F0YBGU~z2sJGcZ)M~$sb@3spq7qNc;%keg$&W|v zs;#IFp0?$eQ0=@!4IrcUsp(->RK*2RJrB0!^-znfITpZB)Lf56P0LVW<)GM?Dy4^P?~``H7eT=i2Kl ztm{$xemj=M6R60(K#lwzQm@x>KQr4TBbMVr0IK4;s1db9jWovQ6Hy~xgBkG{X2Of8 zDf$_;M*c=M=zea@gc?X5REG;=Chh+q5?VC1Q4MuKJ=hP`(^%9n=Z-PaMl3VC-D3dt1Ii8UW}<03qSOYQY; zf0-%jhZQNGhvo4!sza|)9e;;f{r<1aVl9d_$yc`d7}S8huk8N+i~@xw2{rfoF#x|o zRd^lszyq6qjt$8Fi<;|@*XD=IW~lrCY=&{DNE}Aha}u>yzQ-(h`!(~g8=h032LDEd z#{X~Ak*uf+J*YKN3Ki=du_2zprpjk| zXXdU8sv|v7BMYyzs1D6X)%O*u z1K(mhyza|;StR`pzdiyCn_s)A22A5KCo)|IH|zr>I6EUIJ0 zQkw=Uqw1}JibMlc{mrl>c2Di{`g%N(0?qXTR0TUwbAA-Hea@pV^r(jKV=?>(HOGb0 zxO~4cX@MT{iKsQP+PV!DnFFXbbRIo;FOAnUkcJ=D{VB+V3V9wZh=tJ?a`c59)nF(p z^wUrsU5JX%I@Cy$(St`&i}9BAHLAXVbS~d{P}oaCBdmuCZDZ5}p{NmzKvg&qH6_zg zBUp(F{a#eZ&Z9bT6ZPCvRDFM=29_hesV5K%kS}5NHnJB&QB%+#HL^sTpNVQ{Jt{JX zt*229U$Wjty*r+vI`}7kfEjpWY065Yrl1~{z;;N7z0ODy8rc+7XqKURyxo=`KrNyR zsEU5H`KPG+|3Y;sYMD#~8=>xNh5p+AT}jl$a8yWF zpbnsIs1aVa<@ZsMd5IdaliAF9UJN8#3bl5c;)fWD>fk!m)Ez*rkrOt51-%;4FC-M2 zcc=$4XE8SxMuog0s=NtmwRb~}s6XnR@d>J(q=cSKNo6s=R?)gz}g!- zk{^p|=Q8@%oR@@p=Eq-CP(vQngVj(Y?TA%zDAvKvI2wONZLfYgUA`YgCSgzVCs3g; zn#)XGbyPhqaU%}GyqG7qiJ-S42`!phwxB(#q3)=WE<%NJJ!vgC_wi9*V z71VtXumw8#%z#>AFxhZa$C6Nsb{8tbM=`7R|IZ|JU_3+Z*9`eh#cfax_CbwckaaXF z^ixrbXd`L>hfp1Qfr_YK0n^a}sPcNKh_pqu*AGk3zLRJ#Y(%Yrqo@#HMfLbOHo$*T z9}po0O$Cvt`^Td?xCj-g4XFK|jD_$kREJW~_tr$c0}6e>{#V6yNoWoeP%oS5s2GJ*YcyX*jz8C61S%?bhSEvzQL^YU#nyS~R z?df02+?Nme)^JMUNXmmtyL^8zu^ESw&tJwI!QRhFXgl3Q&1Jf>CUn_RQ&9uePzdUJ zC)5;#qSnF;)MA^1nyRJt`UZRb2UN#@LJjCw)RYx1=bIX@Q-*{Zs*SqP$Xsxmqvo_b zssm$f`5c>Hg9`CJRK@pDQy3Uz=DY@Kkv79r7>SBh6e?0vQ)5@xMU82IPI+U$QVrp9$#zTeZwp+@`{4#FyR&B?bK$CAH~ z(HLIOjNmeAj-R4-Nrw97dL>lDt5Ee{!RF`=aXB-w6?Vm+L)icQNz`cI^8HG5HGV)o zS3{TYuSixwh3q@*j_*(-?%K%Z`(;%ub|$|96_MBI!F(T@6SEffB;N@SGvYn?5aXMe z`+Insn)kERNS34CR)=smUc>e{qnV38t>O=pu@??)?s6vL6&#M;T9}Sp#vbI$wsiUa zI`0%z#I9o#Y}(31_;VcL=KXt-#MfMC)|S;x&oZ@hIdd^jdzUkf@+}=)zCRtS+0lGT zoko3-^zB5XxNi}bCcm$<%lAvFyQm0d|JddHj%84bGk+Jedy3#|o(sa%djFT`X0}~9 z)c&uEeX$N|&R5!UPj|DgOIWL;DsGC}wrx?{vny)K!chm)=g5bKvluhtHY|z<(f9km z+a$Cden+j+bUn-}55YL{9dH7kMomr2p5`srA9aw#pw0^~*24*?^W-?{_53|DQs-CH zLG%o@z5m1l+W+HwnMJn(KPGijeZF*h~wKhU95PP9U=C$RYp*pY*6~WD@j_tzY zcnlSxUr|T>AE^6sg|h#3WELc$2dbgAQ4`e37lR7fMASB#jT*@j)SRC{-S-TOV46PW zzS7ptsCp-$reH2=ZLG&ac(f1uUlrY;FcCG^=TJxS zkEo8_M~&V1p4FLRj57eGx>P#F7Pi>M9-3eAW1LL_SLhN0f?Gq4GsK}|*W zaPwR+mLgvRb>{cQfp{6!K>Y|4nbxS2GY&QKNw^u;dr9b|>e$bW=u^x0O)u9o4`q)Oq3zHqYfnPFm*!)cu7} z?Uhxz_J2(hYN!rsF*QVWWDx4ccvR>TQFFNvb^mIc-+-!k7ph~2QO}=5b?6-G{vS|N zasxG>$LQ7md`3bin={0OxB%+GlBiGz+k9PAhniq9?1*}9lr2v}HLw;hvQ~EEPMjF$ za=yjtL(Lacs$nL=A;Z}JYN$H}>QOjq&IX`H5{>2X6V%AppgO!2yWwF}hw=?KCu2#} z6n%tRD_w0q0<~?2qYk|JsE%$Q&i>b2AE7`MT}OrTA?g77165Im5$3_%s0V{l5vY&4 zKLT~%5bGFQ{+Z3sMLoB|mT$A}_ma>EPuPM#uqOF`ZNA1x(?BC@Yin0)IF{i4!KgW3 zfW>h+YHCj60K9`Ou;nOoKrO;Z^4>I~&08x5>rk)_=i^gshZDxQoWJlYE@TeRkEH^P z9A`c}E+?1>woEi9T_iT+{%IJb=dcm?rx|a)u$oUWujj)^guTwMB>Hlrd!ozt$7vDR zgZz1G*-7RTY6^x>{s>!RnaQSuqfl?n^Ed@dd}`|3i5hXP&s@HLORg{KzLTiu1E;v0 zb=v8Q{D-SX#Yb2>`GB^ik;StnH*@lN| zVPDi-FGuZ;1E>S&3~Ei>MJ?9H*b1{InfrR9S0NcpLOuEhwclT%-s^q~Og|Lc*sMS&{t zEH@Q&Kpj96Q4LNLgr(&v7m4U`bl#a=PFu)S`Qfda%-JGetE~Q`W}jd!RxdhlOyv&0j)2pJk1) zF>1ixF}7d_#!~PQRYAA4rs4&twXha7(gXNAUPXOioch9iyxy?>gF4Fdtuq}BL@nat zHeVBUl!qW|#OpLCp%H|m&hA*${-1zq_zr3@y++-aZM`uNH6>-OAEF}D6E*U1)VVU* zUVn_*9ex{(IefDJijvTrRz`K832I8Z;6xTnNP$Lb)(m2qT z&qv*N%I5E5b@I8lnnl?L^{yC;iqIG6RpK-Wh4NR7z*O5@zQ6ZB02RW6I0rr3&5^nr zn~-zYGhNb^H2j>j#@K6pw`q2RHV!EQJ{7k>}3Bd6k!x-B=M+yx*S!(an!-| z4{9xx+GQe84}EVvR09jJJMKpPxSe&k*`9+@5!r-_*te*TerL;n+U+%S@rD95?r%?B$*nH|Q%?Jyj23FbH2=!bC)HyK#RqqzBy>J%Qfjg)N z1Cq@H9@N=g71dC8)QHET7U3e)_S%NJ?-*(-AE6p@?=_40L)7kRZ|#jW$a@Fb8`fbr z3ijB1)_r!Hq2{bCY6SIA9qefH;TTVTlr4XTIys&FCgka`9oa0X5%)n&ZGzG3>?NVO z`5pD(U#R{5FY0ZV=YYAs0ad|v)ER#P6$$5{=}-aGb7gJ5F)9+BQ1!>42J#8&r2P!5 z>is|4-tY}-WEWA3>lP~H=? zpz2*Hwf_&;f}5z&yhqJV)gz|ogHdm*VW>0v7%CF4thtVwd=;!uc?ZmZ^H3dKi8_!D zVIZDH?XrjHRZlW~Wlpy8s2(>)y{Efi5uAuxJR49W{SwvD3#bUB`Py_W05vs@P-|tV zbt>w<8GE2VzhW_zJZspQ0AgOH>5j zqB@-Olvyj)PzP8q)X1mU{0h{_&seXZ2J{Q6z9(K1T5Nxy8p?m#TquI7u(Y)!s^W3> z`h3*>UWyTT3p-$wZ_M*ctgBHG-DvZNa1Z(8sI}o8`K=kzeB4aIHkPl8;6$$`a?yFDAR8R`~(cRQ-dx-s!yg4apSLyWk(B-0M`oU?LEO3h7+y zm#EPFhiS^Rb{dHp7{Odr2Uc7+4IDxJ?06f8Vc<{Z zYc~lM>a(b?<@7g9{f$r&YI}qIufzabFy6WZ>r%c4wF~}4g+B16i9}0Oq^4VUTCZX! z%HN_^efytH$0JaY7>C*|t5DlK{V(i)ZNF~6n9z<#RlFP3;5F2?Onb}pI1B3jlBkZg zK~+2o6_KT=2p&XD#W%M6n)NkmG3LH)-imF#B-GP2s0Nd z47DhSqn@9E>c~p$kH=6EESF-A@F-M=XIT%Rp7-7-p|kugs)9;)&5a!~i;Iw+ew^`xS%m z4Qgbiel>p$uQsY9QKK~e_1c|}8u1-eg#N*bSn#Q7pbhG|P}DYyM?F6a)zB%_^OtS@DUK!o25V~n zkNC}gz2ZXhS%2q~iVRqT0gI#|it4Rs`s$69z2wIi$sF{bNuOonp&(qV7MB zYWQc=eX0Jm-~R!Bny*w3>Ld(8g}N82;sjg1)Rv#O<T$C5IPN3=9bUqzulNlo_Iz#r zen6?eUCvbUoA5YRd&5soOzB(HR5X2S4zfru2`!fO7>mE4Lf!72Sv29Oxg3j{4{zCy_ zZnu-3kkw1&c1mzyH1%`)A~ht9+xHp19`$j03-x{Am)7m;Xd%?^4}wtxnTZS9n+nTTn$|L2oX1xu}4P;+|-724lWYan$dx9^iGBWlX}S>w^y zLDXuWikgBiQB!ac6@j;?2;|A^_Dw}F`u_VrO-QK0Zm5^gG}QLnk9yz+YGf}_bC@rS z+xHh1DxfOvgBsCT)b-i6d_8K>9Y@u71r?cBm>b@JO3jOlAoo4KgoT$0(o!^9XB&y+0 zaXW58t&Q#lOk^Wa^(La~^L|D`t8xdbq2s72IEz}84^ij9Ynu-(Xd*NOn^T_a12c7f ztP@ac;0x3uJ&5|c{sw&mLf?SQb^iT75`Nt92>tOlbHVu=)$=rgCNcr2itA!M?11X% zT-4&)jt%e<>b{&FGq5sPmwa7R2gac4oq##D|7VcUVpxL;$pzm9Rw*j9nF^VTN}wvJ ziCP;WSQRIuI&c`(@GaEW?q8^>3@&W)%}`T35w)gvDX;yXs)*b7$rOwqk)MsK=qFTz z&r$pMHEQ)2Eow$k12shrQRVGXi!aiadr^yQCVFrys@@Bzskw(BY6|9~B5}Nw*F12Y z0)^@?REKhwHaC>C)m+wg0b>P$++~H)N{n_I;mwPz^3eRkRHi zsWYf4_!U**D^v&ktC@zXptfOcn{SW)-pzh0D-85Vn)sZTwuiFN=3wxkKp0$Rtw6!6QrMx@#!=KRmJBfxhO~@+MGOyci zsJR`7-S7eqz%sSnzCRC`k2(S0pgK^$j4gJOQ}HdTf!Ef$^~@1F0S{9C1?uPbzV*#2--Bw{6=EWm6%~P;s1639PRLqV z2-~2hG&Y3&uN!7ipaW+yssm?H+vg3c$Bi493cKJS^07953w3h-joPl28k&=H0BQgm zQO`d^t&voXj6tYH+{H^mJ&8fxI2G0NWvCG)TTfwK@>fv@POcA4gUwNqNW|KB8C7xC z#^(7Fs6|^1Rc|NM^%zup-iai%=oX-E+<@91r%`j}X=2JtpbnD8sPa#-7|uj>bPpE6 z6Bvw-P}@2GM<(QTQIYM9DxYD>`S1Uc7)rtSsGc=#YNn(Ys)tdi2EC|AO+qcIRj8@^ z7WLd!d;KY@L+P8D=klWF+=Gfh1JpNVH>{!kKaPY}`+ls1>6@Dh>YyraiHcBP)PwP; zkt{@Y;1ue<%czEbwZ1_$?BBwamqkUmHtPOv==<;g3?ZSOC88==j2hu4RD<86M*bRA zL5`NDqm@t{=!aTd@u&uuqqg5ysB`43E&sv#+M2Hw`(F*$CZPs9qC!3fRnaU|13OVu za0InWPNMFAWy=Fwn|yIhOL;3)hdbHwSk#CYS+}E3$dj$v|9TzXq(BY4M4jnxQ9qyO zZDaDCQAcMyYW2@RHE;|SkzZ{7UsOZ++M0+|z|7>kp-#vFr~yty4SYdc_P;{Bg9458 z2h_+@Q2RG^J2SH8s0ZUv4NO9Xdm}xxMLNXH>`IQ1?$qP4Qk-eW$%7 z^x#jZivPqQ^zY#I{r$WesOwu$i|`}{;#E`yuTcBnzoY4RchrE!+Wcx%y(dr&|77!z zQIYeyI+=sSW9^I`xiAA2iMyzV(sVWt(gxcCR9hxp+^1!HIRZI zn}JkC7N^%~LP8hXqK?u5s0!CukD|8a4QoIbx9@L6HbPbWHR|YnidyYOyPA;(p*mg{ zwP@R-+DXK8xEOuE|64;s4emgNmao7^?r?KsPEY(8^8Gj*j=k*$fk9@)=pdNiGak15!L>Okg5 z^G+y=x-ky5CKjMZupV{aMeBX+Oa3jY1HJp3`$t=+p%&i~)MDO)S{skNBs8)N1I!|7 zi(0K=sD|dDLUjoBz(drU@E>T(Lr{^5LWO)P>K74TpgMRQHHFu)6lRMuCu(g}B)nZn zXmw9U^>7(#H7BDg{ucFKe}w8lra`9S^4OUChnO9wp(3;b_1tz;p24vT^Kx z&DClOGT;`hi2G3A15Z#D{ec}YeS(?Wo~T_BiT*gpmM=ryzaI6Gc^EY{w^55XOQM~*WaKXNI`WZ^?0)#Gol)-jOutj)X1Bn7HN0X8j3=7a0Du% zYpjP*Q}6?7+rRMIMD+=7XFmmFaXr?U$nOQ1f;*TKr%Yy%x>*aKx_$piwV$S#pP1H9 zH6u(n%^WOM@dnQiz#iCSx*5n?)Gj!ML(w(EtRe3(5*kV7ndV5%gWr*_j@lLevy302 zLN*Qy;0C;oC$Sr@{M`Kd?H$%7Uw^jS_jkm+*ns>gR7Cyfm=2dk&Izy6mBd;uj6j{~ zfpg8#TMsogGf-bR*Rds*o#%Fn6OobFi~P#@Zf7~VlH9(ZC0F9lV#yu85tW+l%; zvzz9jrodUG?aclwKthWo1T_UQsE^Omi%m!)u@Ctrcnsg;C_J{r{P{q=rN*C7`+C?i z^ULV1*pB?9(k6ZvG$nO<$UbZccVP z$=ug(ubIlgeM}A4hwNkjEA)Hzo9&W<%4a^n+;c;1)OPyxpm82{q3%_vcg5SoX4|@t zm_?iiKcl=bZpF>0=er#>+c46afZDE$j(SaEBL%u~KPr@`Q2YKhYAW8N_Hnka%$g{J zIwyKzAsm5Pq>E8={1j(l`mfC{S%ezc&|{{ft5I*sEnX5t(m9JQ@FJ?gg2zonDxeOW zZm9bra3KyreINLpF!z;3Ey~WA55GsPh2K#7KKn`2(Uz$CV^G()Z8CLb>JAP=XY%W2`XY4&YJu3pc)E9b)+R~P4q>5tWHD?=sfD%FXuV) zTvPl=`@andz0VioAgplSgmf8dj*@Nu1{Nd#5^rMu3+9XFZ`25DTr?ePff`5`JdQn4 z11a*Iskec(8+z5#coM2$KGwz!sPg-$xlVV&_p`(F*Dx@J0%9TmdjHeVYx6|GU*suOA|Vo`HA z8nyVQpkBADQ6oK%iquoo{n@XZdJ9@Bp~{>hobIVjGEH*sO@~f=6|%;AEP?reQgWgqbkgJ!(0eJRh%1DVIfquo~%ATTrAo&LivKgF8H%$oJTEj6ZI@XnI&a|i!ySfaF zuId-jKPr6Jxs{VMmx>MT=LwAt^Mu954E78TjSlz3$9O_LVf`Z_!ehgu<2{38;`+z; zkBQ#ZZu^7`b1odnpLTFeY&>@*eNom`XxGC7&GOGFbG2X6y0R|Mu1Qy?xjj)s!@@mH z+l+{hjEVO2jfswni3<0GM}N{kHYR!yRqo1sy>*tNPK%hn1OL-vd}O$%@6g!)UyDwQ zq~$$bxigs?|EC)RAJxgcYw4?{{>e?v8>#@ssgAht(Ad6_o`{%Oj}5?}n6RNy)xzU~ zYWIn)tN#m$9zmn(PycAoh?t?Vp12Y8YmldQe0<$DT{?W!x>cKy4jq zp1z^c>T3T%3O${TWNJM9BlL8MForeIH)})VAToSVP*RmzuD=R}#>NjF>=_mlJFtIr zKPEKxKSPdO-QdDhMP;%$muB8Dcif>xDg3@}zhx?|w{ooN{ z;jty+e7*JsQ6r_Lq~;x5%L`~sjbhXn&#+Jy4A=Tb5eD6n{BH->{`6G{5mRkv(CHZM z%X8nLm`^kTcF{%0@I>r??n~a&(={M%(yRzqN}-tOs1Y6(N9dq%&%p2zggP=b-V;g` z;^Jc&UR?5key+)>vj4wA806_6=LsJ)IDSM~Pn@ss3SgW^O)5I{#RNCV>B5X84GDG? zP0lgUmBG)yTDc0rp5O{KD_2M=732Cq`Kq4asx>QCN^TqDif*3tO(%E8{nCcKUV+~wxx_qFncg%{Hm11 zy(#0@`a;TBX5U}4gka8Lt}tdy@Wd``UAF(nQ7Sz=8V{W|FXd zCa?)}eE)1p@|JezO8V%vD|b@Xov!T3S!cT&xR~aBDTznS%3u{Gg)Vayn!n8D=cbEC z53-w!&U2T^tom4RT0ZnJsp~w}LY3K6=i21Tk$cm_^;>+CGA|`@T1vuUqTs}a4^CP* z$6Y^p{v3B?uB0?8T)yC^NzS>^Jdfq7fZ;z&uPtCQ&Me^wT=WF9uNlw9F!cV7SG z2WQ=zxC?#PX4=3@bHaU|2 zIp;3q*W~|lK6tqO!3Gx2M&HxxnC<_W1fJe@_Yh%9{O^6DP05#M-sHlU+>=xPe?(B! AS^xk5 delta 28580 zcmajnb$FFW|L^;IErKNkcMa|V0>RyaOVQ#G2nkMby$R6L7ARH-?!}4+2o%>M#Y&Ol z5UfCgSJ zJ7NdNah;wd^xzT9il=P;0VXE@m-Q8x`OVY2?_^K_Vkd=^xP1yCK8uvS2|QwsyJ zKB`_D%t-%E7ZOP+7>0i6+WbV+gHvpNCTgJbZTT`(L#r`AZnpVLSc3d5n@=^s)XQwm zZ7m|{-ziN(A+3z+pfwi2j+hN6VmL0vF!US9=&=!Y!VdI098NH2*+uT^{^Dq$Ex`22;yIfM8HVLDTiUGnYyS35^Q;aQLG#JPN@28 zusq&FCgJ25Z6Y%hnTvB3-!kB4V^~)5^$2qa9>6U43bkZ`<49yDQF@$ds2LU|e-taz zzzft&gU6eCjZia=!VEYP)8TZCr2b0O5(ZA>8BC9=UkeLkOXMIqV^OE)v1>nggWAPG zUzyEV9Q9x*>NwTKwAcVOla8p(Hyvx>JXC0}p#~KAwW*&QwYkfn>Q}bb!8GLECM48B z7i@~%QO9Q+>bUGf&Gc7njCU~)mYig4fiK7p#TFR(4Fe|OU#LSp>viZCymL=Z^i{b<d-sY?BZ;gpL`b#!mm-0T8$d`7F39j<32o( z0XY48b37NK+WQ0b+)KC`TD3$*$P!Z7S-``RC}9In{xY1;;#yODNsYlF$w;TTB~bV9p7LYEH}&4tB(p{ zD^&f?HXmUffSUP8OwVSVfa-AW4`x71QP2PM1Myd4I|W5?zy08et@s)>GiSC5bt+V3 zDxx~Bj(R=}wRf6hE}VhExCQn6ZR>N)K>iad66xJJX4huH;uO?H&3v%UPs7~g_hL!B zh1x`^W6gzB*jg9UQyz|GaX6~IP1p=?U=s|UYc8TOsJ-Q`CQ*XKY1GUbVl+ytYuKoHMDj@?V*9FC7FP!b^d4C4_06bKG<$6 z971()-g*la;=iy6dKa337e_^)3TmeHP@AtCs=eN*H6MjSJx{ynPU^2^QUs)f18w?i%2Nb3~T-dK!57>Bw~en%~Vv%=g5 zNii$gvZ(saT@vaz0@dIM)Ps{zn=c--<58Qxi>miGDumvZX44fyMX)An30t6M7;YVn ziOA1J^*0amqPw1iABl6QjxM4?_7WBPls}ra&4wCKeoTy|QIV;D`LMRlM_C7>>bt0= zn~GO)4r&PouJTR5b=Hy4nw~?A=n85eA5a6xvf2!&04iV8=9}7lZ&XLau^xVn_3lLcSKNegjOYZQqte5CuIkH-2L)tV5mWov7XY8)^X8Q6c^lbxPi&I!e6O ze4ZDRk}rwcE9Ft`v_(ZM8vSq#7Nvh@Jc)+55gXvYs0Qk+Gd4zb*vjU6;2!dQQEOb} zC%ZXu6!~@-gqJV`-(pQHvffC>?^<#%m9<3{scFB02O zJ{b#R0v5u+O{T%Jn1_5P)EN26$>S@zdz&7)>JU{9JYJ+KUD5k@2T@sq% zB20#BQCIR-RK*jh$Xv%x_zc@&9btz^de-1U^M;L_fYeIiM(f=38+(0V4rz; z6-9N}3$>|7Vru*bwWsExTbIO7BT0Yg)c?}V9z%6}8qeZQ)W9|#BK{ijzC$MDhf$$EgBtM-RKrhf{xvG(i4L3c zY^YWiA|HcEF%A`}-8dExpaxj&q_F|2y;i9Dop2^b;8c8%w{X%a{cec$@A)e~ z#ZYhy*JAzCTq^hq6_FQbOem9{H8ZP)wJGn1+Ej6<$Q;1rcm}n(u2`RAHu6c%na}f} z238CM=-(+rLJd_%e{76us4Z%y-7q!wL~Y99s0fWkbu=AY;cC=#AFWBxo9~ioP#spm zR9MH_3SG^x2MMj^C`^OPQ3KhAx=QzB6?~5hdAZ;CqKZ3kJC?k_?}zXK9_2K|{cbL_ z5tmG)mSG`%j@dEQW%KhyvCG6?BMzrPo2oA=5+hIp`xB>4o?4ANgW zpBF$4uq39&>KKI0Q4x!_yp2Nm9bEQ*%LibOZf;ju;-`wn5Ec1Zr0=M$P;hY63@k3CThN23}ZhB^%~SRB8?lo*Ga z&>_@J@7erY)aK6e+$>Fb45WXj4vDOM&;qp=`k^`;W*v{}a4KrVvB-nY3e;ZNg6il5 z>bdKvfj&e{HPGL~S2EPVM!hoi#$j5W|EVO@z*5v&twtTYO_&C^qZfZc z)jNp_`FT`CKBJZ*;2%>j749XU7WMf#RQ>B1g8r|~4>DEI)rk6%NP*F)nYow>=V3)$ zYxDO|GyVs)1fNimN%6+4eF;oWzACD{rl|TIY(4^Ok{^Ux>K$)b|6C;Y+k#uzfczs= zBuc+E4OK#QSPwO@)~L@TP#yL|MaIRH_%*77*_aj=qv~%#4Qv;xy<=~QzbgJtfkJ)* z)ldTJ+&)IFdD3@gCTUUivSWJ8gPXCOEq{cn|IGRx8EcolViKcYgN>mz?`f+bL=;W(DUz)z;*YN!Z?VP1qtZgP0HRVhK#@czh8lhw8AJ zwHa#dyIT8UGWzFNW**-iJ`oklxxNn^XDKp5ekSnvZo+ezmHbCrp4sE^y+o>^j%gHX zw~s;%=qp>k4z(%wpayWtdIf#w|2~NteDDrcv7%QaW4%!k_yQI3Zm6~Y0Tt4DsCt`F z1K5e_@FZ&HH&M?&M-BWlYSU&-WS%d9Zd(fKkWj~qQ5|eRHM|uSk=>{c4r3v_h}u-2 zP-~vX&pclQwe}TIrzi|HkS3^(JEL~}VAN92_w%^E-}xM+Ac%thP@5xrVq+mxM`ci( zDGYdjiaWQ~Kom=OLkrWw996 z#M;;&)zCIn2oIv_okGp@GFHU9sF3DJVa|Oq)C`-W%EM9hqftvW2DRjKFcPhbwEXCB&z;oRJ|prkZ-c(M^MM|3Ti?RQ16ac=xU_z zNhqWNDNRGcsD^8zmY@adMjMSSa2aXh;20_zh|a*PtS@ zB^Bpin`;jR8u3|F{vK*zZ*e{*=Wmo`!A1~4*#?HENRT9&W{Rx zWz+y$VJL>D;rwfj=24(ium-iu*P|Nx-TD$+kx!l0bQFQwe9@?Z%|V@>t*GZtpl19K z%VN@W9^cpYsyLebXw)ft?~q3>L{6d_x`pfTGwSWPE`tf>5!Bu} zZS(iB3i)TKnHI}vB3Ti&HyWXy?|^FG9ZEu>n~a)CEGo2dsAF{u6`||c0iR$6te43Q zbUcG9fH|m17%EhiP!~>p z%#AHj0~&&QeNIKa3*t}>pF=HSx*Xh&IlieNu<6{0C5f-n|! zG3-QzY#(X>r%~tjIjW;~s2Td@G)tEgHK1~+msLyD+pz269^YR;EMCA2 zY&n*vd?!}G7pOf_w4jM-3)BR=p_XQdOF|ufhdQ4NQK4UtNj(e%N0L8W$m9D1hpL4= zzQ5tP0d*DUEn+t5Fw|1cM@8;O)KZ*6b#%dg{s^@MuTXoz4Jv9jTYl79l|WUfgsKpU z8c<)SCsTepJ3J5@FYANJ1SC!&*22)!{Fw z4lkkBHg$2+KoQjLt%Qm2OH>3qq9WKI)t-xLXEJK@&PPpX1!@niLErEH_K{HNPNFu| zMbw%nU`5Ow>~Y3m2ONM`u`Nr}u!P4sL4IXPkF$wMW z-uOLEL0$RTLYaupe>)O-%??LJU>Qc@eNR4^S6X{>mQTpC3eGKk^SNbN)5r)>X`!_D3C;si+FuQ61&0 zY8q~X4arZ$X?PCXVV7zi-yh-Z#%S{StD8H1E-oM+kBU%>8s;VDq9%B~2Is#xiGL_) zgL!J22#mxa@(VF5?nS+xui!q`x?C-f^9bM6HuZ1SHLqz;n3+g!)Jv-xZo~GdNyRsL-Zs#1eUV>EI#q zXPU5?8Q6@b9_I&K*v#Whp}f==9^apS?Zf%x8?`V?={_Wpg^D>_njewMxAORYYZZ

rk6eyB}33AL#Y;2^w!6EUp4S(;xl zvEKiWNa*5thPog=VKq$D!CWA<(Dz$Znhn$2>pt> z%KtzOJV|G>#3@kq%AuQ=L}L=FIMBKn)$u9R$giRH!gI`xNxPVa3Zt&xmY5$SQ1zyu zPR&Zx((T6vcmlPw>ARXG%hi?huaOm}Kr<J`KzdjJV3oO{z477cMlWEk*JA%kBZPzmxMyH8@0yAP@Cp2ROq}B=JOP& zmro|t&G-#!SHH#G*t@64S%U>5&7FS^^#vtGl*i#pahBs9@=tr22%PC{BJExxp#j`N zjr1{UN#3JE`Pt?(_AxWejyi6IP?0H%no)h!UTKdyMg36`8I0PD<81j<>r5n4t`lnu z7Nd^U3e*~{M(u@7)}5$^526Ne3>E6%QK7ts8u%Mjy$`5?dHS06(xE1r&E^ZB@ArSf zBy@3zsDZ3R)%yt*xm~CwJcp`()#h)b+I@^k zb^c$IPy-)PGxqCe9tcD&NjlWP@}Q1oLDb8o94f>uP|tNng*wvahoA;D7W3m2)N@;H z`5AQ8!F3Yn*d$MIEAAQKaZY0YX!A{|;$Rcv;i!&gp$4=NwPedsOZFoc#|@~NUqcP} zKDNi#r~x$}Vs60BLpc9hBbNfrWCkj~2z6C%LfveqQ3HL5TI)BchSCf*q0EK4c#5Ff zsfl{73F^5>R0M{hLca)AZ_QB7zY^Q*2M6p2r%(@Gw&f43FHkf5VDm+WnXgo#sD=kv zM_IqN&af`Tg4A1$TJp0li2@`pq1NUj_QEW~%}>JQLzn3vNktU~?)&cS>m zJ-*+P@5VReQ;zaD^H{>fqj?^ej4|IY0+_HOI1zQhxl2eiAaNLj_24+Z9P&UFj3z%m z#=M4Kqe7f}ym_xzz@Fq6VMk0n!5EJErgRW%V9-SK1*RK*L4GUh?U?v0kMo_*e* z6#R*eu<_R(-@j^`kE-|)n_}xp9%nVK!v5Ig8}p54A1>v*#(ayE=b2&#a2T}*@=P_~ zbSk5+>_HfdtFWHl|5d*ygv@vn>f3P6>Gro?sBbExQENUG6|&u^`nOPfXXs4x?RPxt z2Hb%~@gz>iH>jI((kyc|&qpowB}}FB|B{3*Any-mQ)EN!);!n*>!FTOEGi0D!RUonT|r04fWa##Wom?zL}r~@&^CII1a6Tm>vQ->m&WREKfapHclC#L}uipYvamM9Kx`gK8K=zAfg%5m*ct zV?#WJ)iBdS(?A>4^OI2*&>mEWM^GJ|LWTYwR>Zfc(1$ECKaez8#I9E;XHZZG_hSS; zLhXg7i_MHCq8i?Z3hhzUO?Uxc;C0mbKeNQ+e2ITxSuDNOJl6}gL<3P*^dy`A!6l)P zufyE<(B_jbGY!omkBmokv=DVwuSK2z-KdVU{AlWxK-H^nZH-!zaO-HK zKi8Q}LNi~8x(D{z3VBwUU0=!C(AplgroB-E7>inx>G&0!ak0(kTVr0sA*gRu^--s& z6KW6j^W`~z8%b#G_Mz_l6Ic(gpk|(Xt$DC0Y6g|?3v7v6Sc{H&sC1&{A-47DNw_`t)oy6PR5$J4At;G zTb^i}89)})b783ZZBSQtUsOl4Py^hK+Jxs(r|SW#-oGvht!2=5(@_P~ZXS&~KHphm zQCI6q`}s|5PyVUR*V|$0w?<7U95tY!r~yu~`Gu&vf2%Eb3&xwPvm7eq)lj>;E^5Z} zP;0x>`W#iSz)tgAan!vKiqo+v>hs&E=O3c>K$2Z166H_>YJoiGI^nip3@Q>+Q4Op@ z&13`Wsy%=j$O-$o^RsCvDQa_NLWR5M z6>4`!qArSAsD>|CUt2TnF$1cATABfNPzJ^#x@=YVRbVX8JcOa>@6b2vkQ6tO2@O zo3SLcSvFaZqC#~I)zE#^u6>C*c1aGHfs{cFpfzgXQKW?adF__y1rDw26L1T`2o){+`W$viZD+&9~Po*o=B@Q8(XOR0RIRq*(BXS&9;< z2$V%dt|@A-3`8yQyd$oe`4L-i6*cq3M~$gaGs=R#U5{#@II5#ow!9;%!S2>+sEGb- zKR<`MM=oOoW<6&9KqAH^p@uJ6Z=gbY&*tCacJiN4A>Q_jnbA31PySDvpL5*2v^JqS zdWSWz(g`!LF}Rcb8r0f%J!yUc>CPshUH%%iR-va%h3Pne{1McwW+@w@_E=lYO#jXp5|zFDt_Ri7h4X~a!){02 zRG}A4$m^gQ>V#@wnsu8k{{t0C|KCl$2B`K%VIiE4`oeM;)$T<}|IU398tH4)j51v` zzq2ikT7m^WeDVw9duyjIw-WPR>hG75`n1LF=qRXcJJ*b}EjWeN9gChv}dO>i9IV_Oy<(&cmvFz7@6mU!X#t{i=yX15~8Gvu?8fj;$$w zf0gsE-QMh)8F@G=5@S%uWC`kgC%JAmU0c)=j6*fN1=Zmt)N%B?VFsK6wMp}%2G$tW z?r>B@=A$CG>jvjvYjMneaLM`_wF%STG;hNusE(GQ27DHC;ayaN-dkpu=SFS9P*lB! zsE#{f6plkh>@n)9F6`d6BeV`jHLwD8XYWEa@VEUu?HvZ};m32-3>x1xKbo~eE!A37 zWDcSB(nD*a1dr2&d@j_Fa^p|~ch8bg#b+4BDe%8%@`E3k^E(|Cq170S@u-Skxxy-H~umM{M(xOnYntaV|H^)l_PJTREU2rq zDrzRJP!C3+X3`fmgKtrhnS(8HEo!MgSyTRP>gPn&uZW6hm@V&*t{#|7LYrm*s^S4u z`DqNq8>nOV1QqJ+FHFN#QRVGW<&$ms2FyhM1Zv=SP)q#C`U!O`)4$~WXC+bXrI~3v z)D6`elj2&`&9(&><8IV9rFO5(z#>os9&C-lUF5&V->}|4{2~*xy*7XD{{SbGk9^~C zj^ICUTz*7iO?SOD$05mkb93cKZI&JwjSEqsPW7+Zba_xStAtwPnyAlPqaqSz^Yc*y z++g!RTTi1ReA^|VHU1klQ_p`UQ~{_5bEDQg7_~$VFcSNt8n|M8gw@HvLq)E_2eXH2 zSlgoB4gFEyl)gp1jNI)cv^KwBW4wV8SoEXEIfFBCKX(1(@%{V#+@BdJ5&M7zxi6A< zyuL^UC-VBfZudalY>O~E9z^|q;3jGUmHoWF33Ww|uj`BGb|zOhTJxD=GpBsF|e+Fb|YQ&8!jX!GTx`C!reNg__Yt`}v=?{4;8AWlLe&D~5_p z6V$Qpi@tyVH_BF+fod=gHPW-FjvrxR3=H)8eu^!J?a4R7-*63H#!)G~zP}~cB9+&9 zNPaEq_{~Ud>UT)v^}SVhq9XM&jo0;k@yL|C#vDuSPg$d4fF|WvnI{%_5I*c7*($iYGUKDD$Yg?;CEEJ z*N_Of&V3TT?vQjxsz5!~$Utv>>v-w21 zyuNQhrBFXt%)|^j|JO*U!xyN{@ecL7ntVZKq!mz0Q43Yx0=3B^ZMlouOw%w3H=+i3 z78T$<16Yd(a2FQCk$Fw|YSeS_ z)+5&Qs3o{*ePPS}@|pJ2pzr7ZY$R$?u@P!_Pe(;$8LHtmHoqOqkl%w^`xh93f%(mO zu5Rs(T7o&KNE|`czlw^~8`Quu6yW@;LZJetVpY@tTH1UM)JtR(s^ht~{4{FK6EG2e zuz9_aG~g_#JHG&q$Evs*58`NyDrDXb4+^4E^Ac6>VKJ}o3(GUq%>PA|XDM!uVUSBAF$ION7zQJ6Ri`~F zM4eFI*9W4`?EzE+$5HjpVq!Y{4Hc0q!Cv35?_OXl^0iBFQQ>^lbN5jf)Kkbqcivk5LW2MGe5ewCN}mbqp)pd<)b)(hk+$VAOLHQA-tz z+FL)N2DAqg>HHrfQJ#X6sEUcomZd)IhqU>WxD!-7M6E)>n5;Vh;sXDfkt2(FD{m z9acw0Vi;D&6R1;>q^5Z;J8H8QLp9tG^?7$xha*v&ZYrwYV$`YFi>jBwt!37(D5^pT z>LO`w%V%MJ^7Bz6K7e`g9O^sZKdAFuptcEpeN>2hqsr&m@;x|&{7qEHE$f)2b9<4{ zNQa>|(RZkj%|LCg4X8D~hd;-KZP#JnC)u z2-UvR$Xw}3(bbRJ`D{Tq)D`NYPQg4>D9>2`vZiQkIx32aNDWMheNZ>$DAWw+pk}@n z72!jui9SR{@Snz#Lc|EUuI=%#`zyaK?4d@VIOLmrxx)wfPUI2&VnQ+$W{1k=TmzB`ygy_!`wwrWWRbBB%!IqdxD9nrV#ve332R zgTBp+nt7s@W+K6;iG-mxXIop|4Rstxq1tn|+r)X)ZvV@gyOr1XHyB%?8oq$Kfb+8*%?>H(Vk5Lg!^`&{)O5}N5S>m1av+>V;jf2fXYck}xGidJ{j`+Ezjp^vBzvxJ*`Rn(HU zLq)b1>hrm%0qw=Mct6~J|F7EJyc}AhDy~2^a2)l(71WHK9>$CqPPQ0o0Mk+R*IW0X z*7{e}roNBb8(AXE#44foP)vktcI^)osH3B(Q2m8^AahT%Cn}=K2cjai5Ove-M*ZU9 z5^8||U^(=UG`}&aiTd{32Nj8LQJXs+HNZ123610-s^Nc8@AoWGW&l-C4R^-6I0Vz- z9@LD_p`N>qiri<^G0fM??DAHqrReod@~qfs;6gL?T~#EN(wIkx=wU;Ek-qZ(+1I);&` z87;BpN3ay9;xcL=$@?=C^5sy+YR~|$@1JJf#)*1vpx5^|qw__Z_HLs#U8X^1Km*YC z^Z&ObH1j3c3(w&htTfo``z5je5U=mASf0fhlt&CTOY;uvlg~QL*c}zxji@DijEbN$ z+;o^7RUV2eZ>Vye|DGh$GLvz*m;9^|W(LhidVRmGzJwhp?>5SWem^QwA5a5NHQF>> z-P+pP2X#!xqaU6@-Fz1?0B@ihLgG&nTAS=+Ohb9GCHV@d2fjz`{<-Lnhi&;8)Qqm6 zzHB~2ElrxSCPLLvS94b^g#A$c#9}R6JeKpX4(?K*kv>H=Se|34_jwc4F^oh#_#Nt) z&Oi-h7wY*VsEC|For*iCj$fb#{t2}S3y(9_L@h!4aeScTF^mGa7k|b?F<#$ar{9OG zS(={X85pLT$nN#>2a8{MegDO?E|biUR>i+DGoOmOaCYN$e2yLQ*Kf^4ihO5IMLf!Q%r<*JJ2x@84%`o49I%8w zGiq&H%rd`fiN#*z)Bj*X?xK$M0%QQLbAW_G{tWeHbnR>t!iU(6e6~4W=P-`NQCKt9 z{CU7WYrDDTe80iVd|rH>`3*?o`Ci|Dq^>P$t$#yZ&ChTV7FeJ!xtzZvBy?lFM{T0g z3(d&uVlZoc7#W-MaI26g8t9%lX|8*2LLZZiUx5h^J8lpSIHL`zPE1 zKYE?zgZ?Ju_ufj@aOJ zx-!6DH}ZevgSVK7me}fb;;Hu-^+W9PI3~d7|De{s=yuM(j>nfIR4@@6Qei9V_j@?b`Th#GPz1tYHoAa-ZN>ZRu zRzaQfeyF7wf@C+o%g@L9M}6K4b<@USUuV4eFr=U*Qz zv=vrZ*P%kZ1@&!rA8KH~p*p&bYUrgce}no)<6JZyrLd+$otmuHBB+U0w4b-W$oc1D zb2?I>4!fW_=!@!L0;=OlsAD(-RqqsPEw7-C?Gu|%cFBC64K=YsHeUkOUS(Te9o24_ zYb!KIjjWT+kF*sgq3;_HYGAvpXHlE>K58KTm(7ypK)vOPVh#*PMKH!X8y)|k|NXzX ze+xZcYrrHsq5WqiP_ReRFM-P=0-_P_<*=)q%$o~C;%)^7?&nEKhNMf6c{np=;$EM>q`FqwS3H*Q5j&GjE z6PvwQcw}_{eo-TPI4wr>`=4}Bivc6L_lPbyC}=>}!I43Iq6Q5P8WuG;a@N5;xl{gM z^@4+gq?9-~AV(}NNdKRVrpKgaX^#mu)JLvzv<=7*wJxgYd+n6DK zTpQ1>X4wW{kjJYj_eUMs7Ie3;e&g0H=~Q}Iw+`Bzo_v3 z-FpOuM|O?w%16>3JG|Kgu&UQEW_zCvW_XzMcTTkkH~KD+HAasTf+mc*)R`CH_UA!XvTMtk}-OgVm6!uU-I6V@e6h)tOAP5i9Ap84tG zd!6@GP90k-nKyIn`8%HUu}3?3)5hn!>-m^3wp3E@&e-O~y_w>RC-b&R6u%+B`y_qr zA3@%n@qxL$Z8FC9FX!EoDuW3`!vCxFTw&gylE=EOyqRKeHuDzrrQ)+R_iju6|M9@# zcHSQgCZ0a!!NMi619o|{hcx@LWxb|Nn$>7o@8QhN_m@w&zw+A$vo|Ni{B++}DIsQd zLd=4M@sk~s*Q!U1_oh!5zif`TrN=M2$AIYAn3;_J`CRYxjAj6{HhHIbJwfrgwt5#O z4P5how-o!^F7JfcfxEof;s^ii4GWCVbljWSzfMp{!uXl@_U?c9!2{98B zV)iA(%u9%wk}xjj&zPMLzFX}3Xi36^845$pPj=xS%$an51J6u}O}^BdBQ$1KLd?d5 zm?a70V|XyZWbGrH9_`Hq)$ diff --git a/spyder/locale/ja/LC_MESSAGES/spyder.po b/spyder/locale/ja/LC_MESSAGES/spyder.po index fd71c2b7ac2..2fc6a9656de 100644 --- a/spyder/locale/ja/LC_MESSAGES/spyder.po +++ b/spyder/locale/ja/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-06 16:23\n" "Last-Translator: \n" "Language-Team: Japanese\n" -"Language: ja_JP\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=1; plural=0;\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: ja\n" +"Plural-Forms: nplurals=1; plural=0;\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: ja\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: ja_JP\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -59,14 +58,12 @@ msgid "Dock the pane" msgstr "ペインをDockに入れる" #: spyder/api/widgets/main_widget.py:344 spyder/api/widgets/main_widget.py:448 spyder/plugins/base.py:204 spyder/plugins/base.py:512 -#, fuzzy msgid "Unlock position" -msgstr "カーソル位置" +msgstr "位置の固定を解除" #: spyder/api/widgets/main_widget.py:345 spyder/api/widgets/main_widget.py:453 spyder/plugins/base.py:205 spyder/plugins/base.py:516 -#, fuzzy msgid "Unlock to move pane to another position" -msgstr "次のカーソル位置へ移動" +msgstr "ペインの位置を動かせるようにロックを解除する" #: spyder/api/widgets/main_widget.py:351 spyder/plugins/base.py:212 msgid "Undock" @@ -85,14 +82,12 @@ msgid "Close the pane" msgstr "ペインを閉じる" #: spyder/api/widgets/main_widget.py:442 spyder/plugins/base.py:506 -#, fuzzy msgid "Lock position" -msgstr "カーソル位置" +msgstr "位置を固定する" #: spyder/api/widgets/main_widget.py:446 spyder/plugins/base.py:510 -#, fuzzy msgid "Lock pane to the current position" -msgstr "次のカーソル位置へ移動" +msgstr "ペインを現在の位置に固定する" #: spyder/api/widgets/toolbars.py:123 msgid "More" @@ -195,27 +190,21 @@ msgid "Initializing..." msgstr "初期化中..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." -msgstr "" -"前回実行したSpyderを終了できませんでした。\n" +msgstr "前回実行したSpyderを終了できませんでした。\n" "再起動を終了します。" #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." -msgstr "" -"デフォルト設定を適用できませんでした。\n" +msgstr "デフォルト設定を適用できませんでした。\n" "再起動を終了します。" #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." -msgstr "" -"再起動できませんでした。\n" +msgstr "再起動できませんでした。\n" "オペレーションを終了します。" #: spyder/app/restart.py:145 @@ -247,13 +236,9 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "ショートカットコンテキストは'_'またはプラグイン`CONF_SECTION`と一致する必要があります!" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" -msgstr "" -"Spyderの設定オプションの読み込み中にエラーが発生しました。 Spyderを起動するためにはそれらをリセットする必要があります。\n" -"\n" +msgstr "Spyderの設定オプションの読み込み中にエラーが発生しました。 Spyderを起動するためにはそれらをリセットする必要があります。\n\n" "続行しますか?" #: spyder/config/manager.py:668 @@ -717,11 +702,9 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "自動スケーリングが動作しない場合、高DPIディスプレイ用にこの設定を適用する" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" -msgstr "" -"他のスクリーンの値をセミコロン';'で区切って入力して下さい。\n" +msgstr "他のスクリーンの値をセミコロン';'で区切って入力して下さい。\n" "浮動小数点数も可" #: spyder/plugins/application/confpage.py:238 spyder/plugins/ipythonconsole/confpage.py:29 spyder/plugins/outlineexplorer/widgets.py:48 @@ -1073,19 +1056,15 @@ msgid "not reachable" msgstr "到達できません" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" -msgstr "" -"Kiteインストーラーはバックグラウンドで実行されます。\n" +msgstr "Kiteインストーラーはバックグラウンドで実行されます。\n" "再度インストールダイアログを表示する場合はここをクリックして下さい" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" -msgstr "" -"インストールダイアログを再表示するには\n" +msgstr "インストールダイアログを再表示するには\n" "ここをクリックして下さい" #: spyder/plugins/completion/providers/languageserver/conftabs/advanced.py:28 spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:60 spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:268 @@ -1305,12 +1284,10 @@ msgid "Enable Go to definition" msgstr "定義への移動を有効化" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." -msgstr "" -"このオプションを有効にした場合、オブジェクト名の上で\n" +msgstr "このオプションを有効にした場合、オブジェクト名の上で\n" "{}キーを押すと するとオブジェクトの定義へ\n" "(定義が解決できていれば) 移動する。" @@ -1327,12 +1304,10 @@ msgid "Enable hover hints" msgstr "ホバーヒントを有効化" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." -msgstr "" -"有効化した場合、オブジェクト名の上ににマウスポインタを置くと\n" +msgstr "有効化した場合、オブジェクト名の上ににマウスポインタを置くと\n" "オブジェクトのシグネチャーかdocstring、\n" "あるいはその両方を表示する (存在すれば)。" @@ -1537,11 +1512,9 @@ msgid "down" msgstr "下" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." -msgstr "" -"補完、静的解析、コードの\n" +msgstr "補完、静的解析、コードの\n" "折りたたみ及びシンボルの状態。" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:74 @@ -1749,26 +1722,18 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "\"raw_input\"や\"input\"コマンドを使うには、Spyder実行時に端末でマルチスレッドオプション (--multithread) を付与して下さい" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" -msgstr "" -"Spyder 内部コンソール\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" +msgstr "Spyder 内部コンソール\n\n" "このコンソールは下記コマンド\n" " spy.app, spy.window, dir (spy)\n" "により、アプリケーション内部エラーの報告と\n" -"Spyder内部検査のために使われます。\n" -"\n" -"ユーザーのpythonコードを実行するために使わないでください。\n" -"\n" +"Spyder内部検査のために使われます。\n\n" +"ユーザーのpythonコードを実行するために使わないでください。\n\n" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 msgid "&Quit" @@ -1783,9 +1748,8 @@ msgid "&Run..." msgstr "実行 (&R)" #: spyder/plugins/console/widgets/main_widget.py:191 -#, fuzzy msgid "Run a Python file" -msgstr "Pythonスクリプトを実行" +msgstr "Pythonファイルを実行" #: spyder/plugins/console/widgets/main_widget.py:197 msgid "Environment variables..." @@ -1832,9 +1796,8 @@ msgid "Internal console settings" msgstr "内部コンソール設定" #: spyder/plugins/console/widgets/main_widget.py:510 -#, fuzzy msgid "Run Python file" -msgstr "Pythonファイル" +msgstr "Pythonファイルを実行" #: spyder/plugins/console/widgets/main_widget.py:569 msgid "Buffer" @@ -1953,14 +1916,12 @@ msgid "Tab always indent" msgstr "常にインデントにタブを使う" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)" -msgstr "" -"これを有効にすると、タブキーを押すとカーソルが\n" +msgstr "これを有効にすると、タブキーを押すとカーソルが\n" "行頭になくても常にインデントされる。\n" "(このオプションを有効化した場合、コード補完\n" "のトリガーは別のショートカット\n" @@ -1971,12 +1932,10 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "編集された行の行末空白を自動的に削除する" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." -msgstr "" -"有効化されている場合、変更が加えられた行の\n" +msgstr "有効化されている場合、変更が加えられた行の\n" "行末の空白は終了時に削除されます。\n" "無効化されている場合、Spyderによって追加された空白のみが削除されます。" @@ -2373,11 +2332,9 @@ msgid "Run cell" msgstr " cellを実行" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" -msgstr "" -"現在のcellを実行する\n" +msgstr "現在のcellを実行する\n" "[新規セルの作成は #%% を使う]" #: spyder/plugins/editor/plugin.py:729 spyder/plugins/editor/widgets/codeeditor.py:4434 @@ -2733,33 +2690,24 @@ msgid "Removal error" msgstr "削除エラー" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" -msgstr "" -" notebookからoutputを削除できませんでした。エラー:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" +msgstr " notebookからoutputを削除できませんでした。エラー:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 msgid "Conversion error" msgstr "変換エラー" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" -msgstr "" -" notebookが変換できませんでした。エラー:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" +msgstr " notebookが変換できませんでした。エラー:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:4412 msgid "Clear all ouput" msgstr "全ての出力をクリア" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" -msgstr "Pythonスクリプトへ変換" +msgstr "Pythonファイルへ変換" #: spyder/plugins/editor/widgets/codeeditor.py:4418 msgid "Go to definition" @@ -2914,13 +2862,9 @@ msgid "Recover from autosave" msgstr "自動保存データから復旧" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." -msgstr "" -"自動保存ファイルを発見しました。どうしますか?\n" -"\n" +msgstr "自動保存ファイルを発見しました。どうしますか?\n\n" "自動保存ファイルの移動、削除あるいは復旧を行わなければ、このダイアログは次回スタートアップ時に再度表示されます。" #: spyder/plugins/editor/widgets/recover.py:148 @@ -3216,24 +3160,16 @@ msgid "File/Folder copy error" msgstr "ファイル/フォルダコピーのエラー" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" -msgstr "" -"この種類のファイル/フォルダはコピーできません。エラー:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" +msgstr "この種類のファイル/フォルダはコピーできません。エラー:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1416 msgid "Error pasting file" msgstr "ファイル貼付けエラー" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" -msgstr "" -"サポートされていないコピー操作です。エラー:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" +msgstr "サポートされていないコピー操作です。エラー:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1436 msgid "Recursive copy" @@ -3688,11 +3624,9 @@ msgid "Display initial banner" msgstr "初期バナーの表示" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." -msgstr "" -"このオプションによりコンソールを開いた時に表示される\n" +msgstr "このオプションによりコンソールを開いた時に表示される\n" "一番上のメッセージを隠すことができます。" #: spyder/plugins/ipythonconsole/confpage.py:35 @@ -3704,11 +3638,9 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "全てのユーザー定義変数を削除する前に確認する" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." -msgstr "" -"このオプションによりSpyderから名前空間をリセットするときの\n" +msgstr "このオプションによりSpyderから名前空間をリセットするときの\n" "警告メッセージを隠すことができます。" #: spyder/plugins/ipythonconsole/confpage.py:43 spyder/plugins/ipythonconsole/widgets/main_widget.py:475 @@ -3720,11 +3652,9 @@ msgid "Ask for confirmation before restarting" msgstr "再起動前に確認メッセージを出す" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." -msgstr "" -"このオプションによりカーネル再起動時の\n" +msgstr "このオプションによりカーネル再起動時の\n" "警告メッセージを隠すことができます。" #: spyder/plugins/ipythonconsole/confpage.py:59 @@ -3760,12 +3690,10 @@ msgid "Buffer: " msgstr "バッファー: " #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" -msgstr "" -"コンソール内で表示するテキストの最大行数を設定。\n" +msgstr "コンソール内で表示するテキストの最大行数を設定。\n" "-1 を設定すると無効になります\n" "(非推奨です!)" @@ -3782,13 +3710,11 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "Pylab, NumPyモジュールを自動的にロード" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." -msgstr "" -"プロットコマンドをインポートすることなしに画像サポートを\n" +msgstr "プロットコマンドをインポートすることなしに画像サポートを\n" "有効にします。Matplotlib以外のプロットライブラリを利用\n" "している場合や、SpyderでGUIを開発している場合、\n" "有用です。" @@ -3866,14 +3792,12 @@ msgid "Use a tight layout for inline plots" msgstr "インラインプロットのためにタイトなレイアウトを使う" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" "that created using savefig." -msgstr "" -"matplotlibを使ってインラインプロット\n" +msgstr "matplotlibを使ってインラインプロット\n" "するためにbbox_inchesに\"tight\"を設定します。\n" "これが有効化された場合、インライン表示された画像と\n" "savefigによって作成された画像の間に食い違いが\n" @@ -4296,20 +4220,12 @@ msgid "Connection error" msgstr "接続エラー" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" -msgstr "" -"カーネル接続ファイルをロードする時にエラーが発生しました。エラー:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" +msgstr "カーネル接続ファイルをロードする時にエラーが発生しました。エラー:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" -msgstr "" -"sshトンネルを開けませんでした。エラー:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" +msgstr "sshトンネルを開けませんでした。エラー:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 msgid "The Python environment or installation whose interpreter is located at

    {0}
doesn't have the spyder-kernels module or the right version of it installed ({1}). Without this module is not possible for Spyder to create a console for you.

You can install it by activating your environment (if necessary) and then running in a system terminal:
    {2}
or
    {3}
" @@ -4480,11 +4396,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "レイアウト {0} は上書きされます。続行しますか?" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"ウインドウレイアウトはデフォルト状態にリセットされます:ウインドウ位置、サイズ及びドックウィジェットが影響されます。\n" +msgstr "ウインドウレイアウトはデフォルト状態にリセットされます:ウインドウ位置、サイズ及びドックウィジェットが影響されます。\n" "続行しますか?" #: spyder/plugins/layout/layouts.py:81 @@ -4588,9 +4502,8 @@ msgid "Enable UMR" msgstr "UMRを有効化" #: spyder/plugins/maininterpreter/confpage.py:122 -#, fuzzy msgid "This option will enable the User Module Reloader (UMR) in Python/IPython consoles. UMR forces Python to reload deeply modules during import when running a Python file using the Spyder's builtin function runfile.

1. UMR may require to restart the console in which it will be called (otherwise only newly imported modules will be reloaded when executing files).

2. If errors occur when re-running a PyQt-based program, please check that the Qt objects are properly destroyed (e.g. you may have to use the attribute Qt.WA_DeleteOnClose on your main window, using the setAttribute method)" -msgstr "このオプションはPython/IPython コンソールでのユーザーモジュールリローダー (UMR) を有効化します。UMRはSpyderの組み込み関数runfileを使うPythonスクリプトを実行する場合、インポート時にモジュールの再読み込みを強制します。

1. UMRは実行するコンソールの再起動を必要とする場合が有ります(あるいはスクリプト実行時に新しくインポートされたモジュールのみが再読み込みされます) 。

2. PyQtを使ったプログラムの再実行時にエラーが発生した場合、Qtオブジェクトが適切に破棄されているか確認してください。(例: setAttributeメソッドを使ってアトリビュートQt.WA_DeleteOnClose をメインウインドウに適用するなど) " +msgstr "このオプションはPython/IPython コンソールでのユーザーモジュールリローダー (UMR) を有効化します。UMRはSpyderの組み込み関数runfileを使うPythonファイルを実行する場合、インポート時にモジュールの再読み込みを強制します。

1. UMRは実行するコンソールの再起動を必要とする場合が有ります(あるいはスクリプト実行時に新しくインポートされたモジュールのみが再読み込みされます) 。

2. PyQtを使ったプログラムの再実行時にエラーが発生した場合、Qtオブジェクトが適切に破棄されているか確認してください。(例: setAttributeメソッドを使ってアトリビュートQt.WA_DeleteOnClose をメインウインドウに適用するなど)" #: spyder/plugins/maininterpreter/confpage.py:140 msgid "Show reloaded modules list" @@ -4621,11 +4534,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "Python2で動作しています。この環境では非アスキー文字を含むモジュールをインポートできません。" #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"以下のモジュールがコンピュータにインストールされていません:\n" +msgstr "以下のモジュールがコンピュータにインストールされていません:\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4965,11 +4876,9 @@ msgid "Results" msgstr "結果" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" -msgstr "" -"プロファイラプラグインの結果 (Python profile/cProfile出力)\n" +msgstr "プロファイラプラグインの結果 (Python profile/cProfile出力)\n" "はこちらに保存されます:" #: spyder/plugins/profiler/plugin.py:67 spyder/plugins/profiler/widgets/main_widget.py:203 spyder/plugins/tours/tours.py:187 @@ -5885,13 +5794,9 @@ msgid "Save and Close" msgstr "保存して閉じる" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"この変数を開くには時間がかかる場合があります\n" -"\n" +msgstr "この変数を開くには時間がかかる場合があります\n\n" "続行しますか?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5931,11 +5836,9 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Spyderはコンソールから変数の値を読み取ることができませんでした。

エラーメッセージ:
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" -msgstr "" -"実行中にエラーが発生したため\n" +msgstr "実行中にエラーが発生したため\n" "値を表示することが出来ません" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:621 @@ -6364,7 +6267,7 @@ msgstr "プロジェクト (開いていた場合) またはユーザーのホ #: spyder/plugins/workingdirectory/confpage.py:43 msgid "The startup working dir will be root of the current project if one is open, otherwise the user home directory" -msgstr "" +msgstr "スタートアプ時の作業ディレクトリは、プロジェクトを開いている場合はプロジェクトのルートに、そうでない場合はユーザーのホームディレクトリに設定されます" #: spyder/plugins/workingdirectory/confpage.py:51 msgid "At startup, the current working directory will be the specified path" @@ -6515,8 +6418,7 @@ msgid "Legal" msgstr "法的情報" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6526,8 +6428,7 @@ msgid "" " Hint:
\n" " Use two spaces or two tabs to generate a ';'.\n" " " -msgstr "" -"\n" +msgstr "\n" " Numpy 配列/行列 ヘルパー
\n" " 以下の形式で入力 Matlab形式 : [1 2;3 4]
\n" " あるいはSpyder構文 : 1 2;3 4\n" @@ -6539,8 +6440,7 @@ msgstr "" " " #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6550,8 +6450,7 @@ msgid "" " Hint:
\n" " Use two tabs at the end of a row to move to the next row.\n" " " -msgstr "" -"\n" +msgstr "\n" " Numpy 配列/行列 ヘルパー
\n" " テーブルに配列を入力してください。
\n" " cell間の移動にはタブを使います。\n" @@ -6636,7 +6535,7 @@ msgstr "選択された全てのアイテムを除去しますか?" #: spyder/widgets/collectionseditor.py:1042 msgid "You can only rename keys that are strings" -msgstr "" +msgstr "文字列キーの変更のみが可能です" #: spyder/widgets/collectionseditor.py:1047 msgid "New variable name:" @@ -6756,12 +6655,11 @@ msgstr "依存性" #: spyder/widgets/dock.py:113 msgid "Drag and drop pane to a different position" -msgstr "" +msgstr "ペインを別の場所にドラッグアンドドロップ" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "ペインをDockに入れる" +msgstr "ペインをロック" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6909,7 +6807,7 @@ msgstr "セクションを展開する" #: spyder/widgets/pathmanager.py:79 msgid "The paths listed below will be passed to IPython consoles and the language server as additional locations to search for Python modules.

Any paths in your system PYTHONPATH environment variable can be imported here if you'd like to use them." -msgstr "" +msgstr "以下に示すパスは、Pythonモジュールを検索するための追加の場所として、IPythonコンソールと言語サーバに渡されます。

システムの環境変数 PYTHONPATH 内の任意のパスをここにインポートすることができます。" #: spyder/widgets/pathmanager.py:123 msgid "Move to top" @@ -6936,37 +6834,32 @@ msgid "Remove path" msgstr "パスを削除" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "形式を指定してインポート" +msgstr "インポート" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "SpyderのパスリストをPYTHONPATH環境変数に同期させる" +msgstr "PYTHONPATH 環境変数からインポート" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" -msgstr "" +msgstr "エクスポート" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "SpyderのパスリストをPYTHONPATH環境変数に同期させる" +msgstr "PYTHONPATH 環境変数へエクスポート" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "PYTHONPATHマネージャ" +msgstr "PYTHONPATH" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." -msgstr "" +msgstr "PYTHONPATH が空なので、ここでは何もインポートされません。" #: spyder/widgets/pathmanager.py:276 -#, fuzzy msgid "This will export Spyder's path list to the PYTHONPATH environment variable for the current user, allowing you to run your Python modules outside Spyder without having to configure sys.path.

Do you want to clear the contents of PYTHONPATH before adding Spyder's path list?" -msgstr "Spyderのパスリストをユーザーの PYTHONPATH 環境変数と同期させます。 これによってsys.pathを変更せずにPythonモジュールをSpyder外で実行することができます。
Spyderのパスリストを追加する前にPYTHONPATHの内容をクリアしますか?" +msgstr "Spyderのパスリストをユーザーの PYTHONPATH 環境変数にエクスポートします。 これによってsys.pathを変更せずにPythonモジュールをSpyder外で実行することができます。

Spyderのパスリストを追加する前にPYTHONPATHの内容をクリアしますか?" #: spyder/widgets/pathmanager.py:394 msgid "This directory is already included in the list.
Do you want to move it to the top of it?" @@ -7017,9 +6910,8 @@ msgid "Hide all future errors during this session" msgstr "このセッションでの以後のエラーを隠す" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "IPythonコンソールをここで開く" +msgstr "IPythonコンソール環境を含める" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7129,20 +7021,3 @@ msgstr "インターネットに接続できません。

接続が正し msgid "Unable to check for updates." msgstr "更新をチェックできません。" -#~ msgid "Run Python script" -#~ msgstr "Pythonスクリプトを実行" - -#~ msgid "Python scripts" -#~ msgstr "Pythonスクリプト" - -#~ msgid "Select Python script" -#~ msgstr "Pythonスクリプトを選択" - -#~ msgid "Synchronize..." -#~ msgstr "同期..." - -#~ msgid "Synchronize" -#~ msgstr "同期" - -#~ msgid "You are using Python 2 and the selected path has Unicode characters.
Therefore, this path will not be added." -#~ msgstr "Python 2を使用していますが、選択したパスにUnicode文字が含まれています。
このため、このパスは追加されません。" diff --git a/spyder/locale/pl/LC_MESSAGES/spyder.mo b/spyder/locale/pl/LC_MESSAGES/spyder.mo index 6e5fa4191f5528a97b251c29d7dc7b3f73b2213b..2a454ee05b93a022d98e856726e691644c5fe29b 100644 GIT binary patch delta 16141 zcmYM*37C&n-@x%dW-w!$u}-!bjIj$TG(^@!2?-@dDIqFLl=@Q%38fU3vLq!%jW%1J zcC;!*k5WWWo3&6<@Ao_BT-W=&&-HxHIrmw9=XcJ1&qVJJ^HYADmy-Ope9HU;|M{+1 zB2gWyXIuOK|5QIOkvM~D4J?JNFdaK#al9BAn&^imaUj;jp_qnKqqETd^P^8-Y9f(L zEDMFib6A!RtI-8E#r7>&n))Z`0=vXN$bS<%_@CxjwM!zAk3F#! z&OrNZj`d<)!}EM>P5Z5wG~zWBPO$Jvyp{Ul3&NQX?@pSi--R{sMf8q*h{f?I^iCYc z2KYC6RCyPM)SrdkjeIPR?a|EkK{s;6g`B^UFNz&L!4lN>#*RNnk6|Y5e`7B!+auh8 z!RVO}$0IlaOIhz3?$9`_Og)JP_6SzQWtfGp_ay&06h5P&F&@Qq%)TfLXoxMTx57F& z3OUxq1DJ=Oq7$9K9L%^l1lAl~I6v0=pl{J2Y=#rCF0M#Y@XS6!&n%7YHN^AL`Y1GI zPh(kp0n6Y9d>pr81-$N(kmB1gmHKQ}l|q5*V8kG2=OPXF*cnHU-hiBZ^y2NPm_DZ1lT zXhznfJ3D|b^eejXG4x1IV>Y(y6UJSM&8Uw;FYTh}I`j^Fh39bp62DStLPMRt;THEs z7rGtG;Z)4P2hr3oMR%|UP2qaX#rM(vzoHBOjn3QT(r`3ap!1H7j>9snpSUe{n1NoJ z$D=RD_ASxfn91`)Xl9D^3xQ>z?X|Epo`pW|fCk(vdL^34k>~~zAOHaRAoGAp;1*v%QZ7 zZ~f!wP8UZDke?@s_pmai4h)y9I+~fbSP?HqcX$m}#S!Q{)6jkk(4$xxeLc3nm!x3i zJMj$s2hB*<<>Av=ADyT%`T=T>26_#enc?Wfx1brCf?md1vA!lgUyrV{1G~&<#vS16zX5`#id#*U@=CKm+&!>7PvO zreNg9(VZ0;6utw+(T**m=b{sJjP*X4Mg4O0XvX60cpsjL#ji{x>ft%q8wXOQW<^9os2BQIvMl&`U&A@atfW_$TUWI1l6*Qm^(SCc; z56~%eV@0ke|6Z;P3NBm+v#~Xrx&i2=nS!3}GiV^2u_}IwHSrg8;q)OPkP2vk*=PU_ z(QzFx2QR@Yc=HhQZ)#`84v(P=twa}i2Tj=?bmu>yM{pD!mohY13B7E2XeQ1=?@oI( z6W!3G7>s6MIGWkfLz7{F@iZ9mZD?wyp$k5Y4qO`RtI){T#rEy!2WdAp#lz@Mv#$vO zor#Wbi*BSVx=s%?kjs)3E}}39JK;(+vV-WE{ekYJ?6o1F%IHM7=t2#mZP3hgNBdua zX6PF9h{vG|PmlF^v7TH`!Lwh3p4GeP!2hBF{E7}t9Toz}KvP^3^ROX$RK3xFMqxvo zjNXkE=+V4~ZtQb3BVQwTCYktwf|37*F8nVx$BgU3PC8-=^$YP%?1cvOada1Y`F=om z{u|E2Kk!zZaXqgrCWiAn0*pX6I-JIQ-~T%)7~wkf%-_c}+<{)6J<;RnYguMQc-{bA z@GLYV`RF{|FdZ*N=eY{qzzvv+qtOkI!^*6mxR*jdT!aod7CnuPsFxTS7eZ5mA4KDl<`aXY+rt%<`#l%gC z#AYamJ@8XBBh_yXm-TY&PW^r~>t>e!HxN?4wHHoCDEvEBilw=a5W2cQ8Yuclz6qtLS( zi*}rZrtnTQ)pOAa7h-K(f$n508sH9e+_&f#avz$}Q`iVgjtiM=iMiA}A2^IVN)W-KN> z^V=zSsitC4dLz8k%C^UxhEiyha*`a0}L`}=6h(i1bQU@Ml&Xd)YXbMMf>HWcc>GZseZA36`F~g(Ueb# z&PFzxOgu)xNM1lwvk~3NTWF*^(TTsu3U~|+sKgy%To!s1_0e(dZ~*p27hZ+VyB?iy zGdkZEEYJFh|57le2hjl~_$|ZKRz~kaF1lbNbfLCb7ki^AzBPI`dUSKqBUy@$e*?W6 zo6*c{Lj%}>WqtpDq~L7Y(c#o{RZ+kpg!xfreb1h6c0}ZQpdlGDR4C>7B5bFe2~fCe-l&GZX}&pH2f6dd>=y3kkA1L$r315Itk8DYYD(Kcv+ zJ+K}QiS?Q2hMqzfdIQbed+7Y%pqcm;3;+55FADCw{LJtg)7V&gT4`;B6Y5U>mx_@6bzj z1WjS1IibA^`j%XV?(A~(Js*WGFd56@3@nL{qJb<%pD#xPdm?#rXX?64r^U#UgU^#4$?zk6v>o1MZN1~a#87ty= zca1A=o zXmn$DMDIoyo{4U3J{JD@-!cmB_*FD@AD~CE8%_21v3@Xm49(QZSpOG2+mwazu0%7@ zd8(odW~1YppdYlB3(3C`_KF?*Vc~0q?rcu}02=wPXv&Vn=cmz(6n`xAuYd+pJ=zdWeQUg) zh`XUbJy$;w`fo!6_#)Q#Bq^A>|Dh56iaGc@x^SgMVF$I)02`tGT1MNVJMWBMwhPe! zu0%IB3Jq{9I^R?@fZ1qfk_%#CDLQZ!dIamy0Ux0Y?~3)kXaEPJ$7B0H=z{4_h74s! zYepMITSvPDlZigD;VSIT1f#GIzKSC-?WypWPUG-i>T7WTU%>pu;SxQ(B>c{}8hg;b z6ECG-?y|6vNq8RhS!nxK?19-&^Q!v(kEGC!hBvSqp1{u7emQ?qjrU+j+<{#%>lywW z7YE~DT#n^A>y#C|TGXpN7k*|e!LzBa$NG2#YhcxtA@!}X1nVa{QfPt~qDL?h({Vbc z;ykR33(!yVYBaFT*cvmR51-Tv(f(IqaU6zT)=^jmC*c5m5dE_Lj7i`3zbK@Ty0WX{ zt$rb#adph%c`kYf+M#!&D^|h*=#ECCJG>v$@fCD|&DaV*jn7NI7&6uzThRW%i{#&l z-lm}z?m{ouDLey`$Qs4MQ;xm!W666Rpx7s=lp9@u%T%*AC2&WSRWX@4!z}LWBmy<#m}KTdmY(PVjDVs zPi#Mo22$*GKRCSq=ur&EG}cdyrCDpp5iCX*dIsI; zw`l)En1d(KJCwDa^Tu<~`V7p)XV8poTTlL7@LL+3;3%Gff1#09+zkSgfW)k8Da0zJakXlA=4V?$r`l3b0Z`qtP!1>MnXH1b7g zU`x@!UPSx9hX(cuHpN|NMoYdK%s?}dg$B?FYhtoBg&Gw4<2^VY&A?w+59@CZe>rst zn(AlJGyD?mcM6MPga3pDnqW2R9b$bDdX!_Z65fLz&12Zy_kRfmQ}j8Sfp5{s{y;lc zd@Dp;4XxM5s(2H&awEt=})vre1K{L1=vwi=+jSc^xDNKJm?4%+( zVLi;kcIbqcp*y+~4fGbw!3VJgzKo4=KQ_Yh?}R|lLpL}G&Cpn^!}^K26gF8bpRYgzeenbG?}TsB zkb|G01CF9Q`YU!Uvn6Dx78+oyXgBmM`$xy3sh*27RxVOeM5_2Ks*IM!Vub>ci1M-a;4n0vqDD=#i#< z7LFn_S_>=s{x_$PLx--IiPxh6Ou}<<8vcr#u>roYJ?!i&G?gc!sh@|PS3r-fHhLG% z!LHa7@4<)A3}t;mKh{swqmY65=o$A!Bkqgt_zHBub?DB+hj~?nJ*EKVfMs`eoR0IrNO{ zpySR%7rGKTg~Ui4hHbwJzosw!iu|8R!xuE1k10F(3nc7{X5c<_!Fgz4%g`OZiXPE_ z(987^nu(v$3?4@NpFqc_?+TZ=8rpsz+HaA6C1PryrojaZ&>ySou^fJm4%mkdIF6?9 zbbMao>+sf8K?BP{18Ia+u{oNdo@hn~Vq+W`+ZQA$xRd4R>-Q2Gz&bQ_+p!UThXzvm zzv0*MY|NtG3B8nq(2d-S1~5O?AIECcSEBvjL+9Uv-jU=93hu1#?(iXMjHawN8u8Fr zABP4o4J+V+_#lgDKw>j!P!$Q!u4xhlWIZG$TEu z1ESZ)=QpA6{~cHnpF+p4jc&p-sDFTNWG~jhV`%@(Z^J)d!Q!=qvOs*`?ZVDJH_^{*fPn3ODXigndl|l zfu`yktcizXz1(+U2er|Zw?g}MMt9mDtKo1o<@cb^A4dDHz{_wgy5UmabN&`8Q*g(% z(2mWpFyd$@bfWHP1_q)1#-Zb;qG$a8I^TS3ii^<=eG>f&op%rB;QsH)za5J24XG*{ zt&VnVh-YFm^tN7&rgjcGZa&&?5xVd)^m4w4Uf#FRBmDxsBi~?E{1MGq(S6B~;tKo1 zgBIun7olf21e@a+tc^?13Ex3e{1H0QH|T465DmEM4Z}c!4=s)PV^83TMTy&u(X#dVw7yDoiPQ+YXhz6eAM8Pxt zBzE`_uccn{KM~|X`mHb~AUj^$?Z-Bl%z0p8#!iG2v?Y{;K z|NY-L3k#$cO-brc!OCdl_0ZR9-U_jI({~K zG*4jzT#bc4|94SvhlgR0ymlwxWIxy7SR!U~{k-E<@K_af4e){mkA|Aj7`S)@qeKhb2N^*hm{nTCF_7NHw@ zE&4vXv9GW?ewU=+4o{;yD^@g2R380Qo`ETNKAO6&=mHm_ukBFu^_ztTumD})DfEb* zMdw`?+qYl^>f5m?CJ#_>rI0qFHDFIp}R~g4M7q=HO8D zOz%dIYBBm+K8}Ji`yx;F$>wmrf^PcNEeb!pf8t!$k^*npiIVT>@er!?pY}h7|sFfv= zSdV3rG)06o zK>O9XD%2;S&!5L8cmk6~oX$UfXW`*^8}*wyg)={ZHL0J+vRLk4;f}Px6zcuZJ8=_M z$I<9fEykR<3cVZ8VrDRBDYT)X77oTVoR1DziVg5ttb~V< z<4q*Ggd=N>PBaYDaRwULljy?F$NF~kE!vHB@H?!6#glBuGiiyQ*;K57n_~SCnu#La z!rM_6^HQ&ck7F||jQi0PpT-<`5wl~q?qPf`bYlgv0G2^Bm8=~LO=E`+=)i8V-a9_O z2|e@CSP&<~_IuGCKZHJiJl3CzuEWf?2|e03(Lmk{&y$IdLm_bpYx3Z`STEEg?6?e? zk(%hvu0t0ZfG#`)J(4k49$&zGxCiUtA@swRw`Z^hdIzq?M%=%|01CBfScui}ZFHeC zm>4Y zB08{X^ja)P{YG?$x1xc~i0u!dmv&Wrz7f4EZ$|f^nfwynK+1KTzXJ+VD2nBASfVQy!+BU9SECtv7mMKM=;b_%rSNBTo_zg7zY6G))Q`4F#s^)| z9Sp*fcpsXXC(%#nCUl}L=!fWi^ff(;X5t(=@dfm3Q?Cz~vItsliau|TuG0g}P;v?d zUym7Rs+LFBMqfq;?m&;|V|0gyWBYORuKXP9SqFp-6hH&3fzI0i-B25Jp6*Bh$;1s5 z-0@H}(j>aG*;o_jqmjNAeFvRrSFC@D9^JR-(fp0KW8oVTi7RkER>L=uPfOwiR>!6T zy#t*85DFD(n1e370p0P7=tA4k9e#}7m9Nn=Kacj$eq+dBD!Q|(=-X5;ws%A`F%&(@ zap<@SSR_ed3Wef07wx#(2e<)C;|?_CN6~;zpaK4lW-P}|Ap-@_0IH+6ydj#AE75>@ zqnR3oeu(bDq&u5U!OOJ-U3eXq$G6eceT`n4)Is5>u0R9ngr%?_mcxnY!VA$rmZ1Tz zK?BG@$9;h5_{AXd@6P|C!PFKS96D4&??iocfzD{khNC+lgC4={=(xGjC(z5b0S(}F z^zOWmW@0az;S*>E&J9k6)c#I`3;csdoNY)r^L*%nWzm5(W4$37c)QqsJ^Eo9ikIV5 zbf;_3fL=kzzl*N(5xUN&NeV`C1Uut#Y=`y#9Rj-zJ+tZPP98-AT7lW|1$3d!(d}qv zK1TZ=Lo;+1J>rY#!Ucwgda^VH8!kuBzA1WEUC@F5Mgy3H_M3+WumnxTN z%$x`9KN%m!>3AC!9L^6gd;l-v&sfy={~WE&X~;P;MA#19VK?;52BMc|cr=N=mW$)_ z&FF%!qZ!$O?sPAv;aBK9C(#X@#~k=O7G(XzMG7vEe-!U1Rz(Ng5uJrKsV~6Hh0ql5 zh<=J5*)gn&f1#PIFggTKADdFY5_jVitcD%NkpD6i#!zs<*_az2!92JUo%jXJi|=4d z+=nwU|JZPr52Fh%LGQ}*=ni+p=l?JLRc!x$EctihUt&Ynap6xisb~OQ&=1S? z=;fS-1~ebja5WnE#@N0!*7st5+7F_4=`{L1xe)6`Zwc*HZz2D7XiS5FbVL`v4t<}8 zpgX$_^Wg*d7Cwg8V82^KMpk28>fd4~EIK}<{6IEI(i&{29I7DH8|^^P~MIpzZgf6VF34@&r0=4Z5+{V*LYj-b3hJN`6hj08XKi z{)(R6-)P6I6GP-V(TPi-6IR5ESO?unPc)E$=(u6%7jiV3(R;8aK7?lS4Xo_@zdLp~ zhc$TcPwY^AQkbX-8emKG>^q@5y%F8vIP{LpL^tpxdb`)40q;e3dN9_%K?698xqbgH z#11(phmQHt9Ti7+QV~6pYUse4=)%p=g*&4e8I5LO8an>&=sYx#CFn+0q5U^tao_(p zC^+F~=)jZc!arjX`~#gJFP|~{6-V1^V0&zZj=Kl#Hy<-E9cI49cscDa#QGt0o>Q1K zHGfm^%(G7kmnsdjQZIu}R344IDw>IU@p+@z-W2Q6-T|+`Dd+~)paHyqzJ6QLqx&>^ zYzp}|;$LVmCI6r)&NDS!y3*(bwa|V|W4#@=rQQuq`9gFf%g}kAMi<%~+qa_|`42k( zJ~RVIr;>jc_?ZR=CZ>f<ERI~=B^Pm}4#_Q3AXP^__A6W%KGKN|5Z=mJx*Fg}O|wgMfu5j~Q(&~cw(Up#}( z-=1F{oVO?D^8N2i!3hVUflNSCI}07K0!{IH^iI5vF8B_*(5F}hPob&K&zxEsov#{t zG|kZQ-O)SZpFLCgZ(<1lFn}@Wf=P72dFYOoVLGlu&u$Oe|2USx-!b#D&IrGPl}7tD zLIdf79!-CA{=w+Hw_?%`cTp&XbJ57wpaU|{1zwBw1L)ZuMN|7dddAuA3g$%@D2{$7 zs$w~;fdgJ(MrP3fbtz78F@1#9Aacp3hT9zhv?3vpa+G_XeK zhT36S>>At0#P(^}p7vR>eb-D<=ME0U4&R`W{eaEzPjp8O?hbE5J2ar-X!|s@{{v`B zAHnkYGjSVP>nFxjaG^P9>Xx7jY(z8hCK}Klbmzx0 zC!RwW`V)O?a?cL0aXMzF-W+`kT4Ndx#B>~iIdC>+zW)m-nA&CNm+vKXfsZf+KSvin zjGpyrtcCd=49}aRJ8z3sus3>_Zb$piL62x7x}lxe0zZ9_{2O_}IbkPNu_g5;=zz)S z#BXS#61JbqcOAE48s?i90xF4as1-WT)#&p9eqv44Fd9;EH0H+1 zX!}gehxefqEJi1I0^MN-+J6V;!@cOGJA!7ko(B^JD)c1t%DZPB0mB z;k{@8^WyV`Xv8a!xf5&AfSN7{zgV=v`qU?4A6$d!n0P4Mi3+$a3u#B6UwAl?xB-(r z7lwbOvI(!GAPI#>gHpl5a`y5M|tM@!JmJdG~A7VWnQz2)!3`abk% zzr~7p9^F9EN6EDBe|-veXpX)v|3Yv5STvBk&;?hacV#`g;8wg7-$uu`dMxzof(F_z z)`wto>SK{jC)T2Or}Yx@Z;ConaDndVL<7*Bjf&oiE_@rhv%Ap%A3}HhG@7}W(4+Vd zn(B{Y{ZRC4H1O|Y{re^4-?KeWL*`|PUWy%ZEe#9iMF*C~QdkiUuqC=+J2a4v=+64b z_Q9By`Y?2ZBhd{^itTqVCI3z|iv|~%6FV$KQ}`tM{CRYOP3Quzqy2ZFf$u{5eToKj z5Y5<;`1}l-kzZmx%i|%Cv}7!lKvQ28hY@l^EKPm+^02_GXaL(|eGi(s{b)c3F&)1_ z7tZ!X*g$?Xz>;XciqRTqz{z?PJj*6%0N0>9>yM^t5IW&FG=SUD%*=`|K*udbkKj49 z|7+;N@5TCFG=M|VW1&5nI7`6=|3Fi8DVk?RsF#dZjn0zhf&L zypmw?DeTJ^u=vC*+k+hycP%ILM)Wb%I&A{01Y|Th94U9u_5*6@G?At zWii+DA@x-;C-pj58=Ig(D5C%6)QYbI_E-iO(#FGVxB0uAVy*uEa!UA$%-iAeR4Lb1~u|4r}=vNob zPzN*<*Put)6HEH@e+Y&0G~Ailz-xlVsJ|TjFt#5@kLnUSL7rDay-c(^8epSXzdG6r zy~KlJeKwlmN3js=C!V3;j$TD4*y95{j0SQJJ7ca_L+Yhsc%9vv>#pg z6gvJQmc$%ygn-MV&l^NLqHouL=-4;N{}nXMprJT!M9=npG?fR?g^plFJdeKTMYn~N zmO@iq4c$?Ftc~qs`*?KT>DT}lqer(3y>p)>DR>!vjb?u{)C;45R7B6P9-6|I=#jNU zQ`{}q2cman6q@pxvHd}Gp2yI@*PsEvfCiR)m4XBIp^+WL%kdbR(!y^AOQV^nga*(E z%V9e#i-YkVoPlN_$J^mgyY;a*_3P10Z^Fv>HPSDcNZB5KlWBl1&;(24HL*Sny?oQK zD9%TZW;NEub!dhTqZv4b29|gy^ec}BTs79~V=3y*u&nQYZwem41ayI!SOFhH7ut%Z z`t9gPXbQi=@^~uNQ+I?67DYEw9-XfqmcWkae1p&p4aahF{seg-|ef6yZ-`F>cS8k)JQ(c3;PIvc$kOEDL&Mvr=ZtiSO-`R`A|P8xb* zg%83RO+q8T19Rdsbit?N^UY|Wuc8y~!E`)?_P>a3D93+7zY=JMYM}wPjrK@V@GJ*M zr=uO0Vinwo4%m;L{dee@Uc}~DXjeF@F6e7G0bAfhSPef!1N{wCFl~3Zqy^DHlcgy* zVWnsjG?kq(H_pJ!Gs6d{Ka6Ig%${Hkv|kf6#qIGC?2I?#H#inMeaLZT;r9bH(2qaL z{Gpai{6xXCzH)E4Ox@6wjzEuK3YNjg(6iryskj$i@F1G%Z==7V0j2H>XI~VvQBRNc z%INETMW&p8cMAPzn1BUw54yk+tbwP{R2BI+97UOEEi`j2Fde(2<=)94+^`c>Fj9rR~LV>IOh(16FpdJ+v_0eZPth3Cn{%M{$<2WSTNqXB%4{_r`0 zrs^y@!Qbdlz5Iv6xcca&Y#rX|6F8X{T8u-2F5iCG6xda>I8uTcRqM7>vy$dNv*=UkNUJ7NfFlNO% zXovdfz-DMhI-<|JpzYnU0p5t$;3McIJc?%OB$mSqv0m!yuz}iW#@k}jj$J9Zlfmd& zPe4;XAAP<89k3a%!#B|#7yBky0o`#ev|n?~3^>{aou?O?fnjLB&_I)^--Qlk z(Ftm!6SP7X?1&D$5v$-BOvi;-8DB&<@CACdr{nXiC-_)VuY^9oAIm?Hz z6nw4TMPIXn;X&dLH1f2Q;fPA13#3Pz;APaip$kk!FV$>xBafmfe-2%EcWnP0-Owq_ z{NMlmo>`#rREVq+Izc_GhV9YUXfzt=gIEJsqC44zE_5(@5e+E)bg(WOcw6-C>L1%D zV@2Qp`zRE}wU~x)VFlcUDfm75Mf(MvC~+nPSRz^jJ)a2|7G(X(M-1#C^d2{yv1=+A(SmLgajKhT9Mejj$+6#G)| zjb>(j^v&-%e+TZRAuk@qYw-tkg62PjUq*YNsk;}Q;7QDfo6*d@jULtK=*}*om#^%P zA!9SqjV(np^-Qd9{gM3pt=~z5pWvhDgcs4B7XB$LR10%ZZ;l4m27N92Vk(Y8$4$aq z_#hU*N6|ilWXsSLtwJN+fJV3zy&LyEorcBe2j(Oe!hFAmv#Nj&Y={Qb7TsYF ztcm|dKST?#C~iR}PV7RD@F=>0KhZl>=C=@d6D-L3i9QsH@?ZoyFp0(R5v+vIqYHn8 zIq?VH^Y;?h;=n-v=^<7wk`WbY=?0<&8MK6SI^j6IL`~O)KI@9ntnu$|b z950~@7ym09Ne6UbUmT2Ma0Kqf(b()l*ub-R8THNR(S3<#>?C@}E};SC{yV<^CI1eQ zS4Vf=3_aua=-Y4|x|5-3VAIiw7o#a&g^e)-&BPfr(A*c}pZ(APDo2~6>-4xt{+(a| z4ZgRd(bsGe8sY6|01x0yd<;!}>3_m4uNb`%C)2(R)3NZSaHI|Ji!A(xg}rF6o+V4> zzkZ*NZK>~1QfNh?WY#R1m#ZJT@Ss>9hem!Iy6`+4ii=|X0-BMm*|KDQ$V#CbY7lLY zZmbuU!5h#GPD3}AoI$~f=cAv{#h4wpVK#gRUEn?ROplV!fimckRY50i z9NVwL!qmIs5uI-b`a1uNZlHLInN4yjD5TTS95e4i<^%2m+VSq_g6MKwMEhFws9NU? z+VR+Ug5vm5XvcCJ&&dq88UqQxUnOq4Ndf%I`;pSGrpfYFHgOOwd*uZuUoHK!zQ)sG^|r6ty|aH zJ%&ymF=52Gv9;R|nmDvsdc8XJ>hUbIU(;sw8f3h%v_pEyv9}TXq><@k2c=IKHGb+X z6JsQUGJe=vrAA6-)6|UA=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: pl\n" +"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: pl\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: pl_PL\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -59,14 +58,12 @@ msgid "Dock the pane" msgstr "Dodaj okno do doku" #: spyder/api/widgets/main_widget.py:344 spyder/api/widgets/main_widget.py:448 spyder/plugins/base.py:204 spyder/plugins/base.py:512 -#, fuzzy msgid "Unlock position" -msgstr "Pozycja kursora" +msgstr "" #: spyder/api/widgets/main_widget.py:345 spyder/api/widgets/main_widget.py:453 spyder/plugins/base.py:205 spyder/plugins/base.py:516 -#, fuzzy msgid "Unlock to move pane to another position" -msgstr "Przejdź do kolejnej pozycji kursora" +msgstr "" #: spyder/api/widgets/main_widget.py:351 spyder/plugins/base.py:212 msgid "Undock" @@ -85,14 +82,12 @@ msgid "Close the pane" msgstr "Zamknij okno" #: spyder/api/widgets/main_widget.py:442 spyder/plugins/base.py:506 -#, fuzzy msgid "Lock position" -msgstr "Pozycja kursora" +msgstr "" #: spyder/api/widgets/main_widget.py:446 spyder/plugins/base.py:510 -#, fuzzy msgid "Lock pane to the current position" -msgstr "Przejdź do kolejnej pozycji kursora" +msgstr "" #: spyder/api/widgets/toolbars.py:123 msgid "More" @@ -195,27 +190,21 @@ msgid "Initializing..." msgstr "Inicjowanie..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." -msgstr "" -"Zamknięcie poprzedniej instancji Spyder nie było możliwe.\n" +msgstr "Zamknięcie poprzedniej instancji Spyder nie było możliwe.\n" "Ponowne uruchamianie zostało przerwane." #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." -msgstr "" -"Spyder nie mógł przywrócić ustawień fabrycznych.\n" +msgstr "Spyder nie mógł przywrócić ustawień fabrycznych.\n" "Ponowne uruchamianie zostało przerwane." #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." -msgstr "" -"Nie można ponownie uruchomić Spyder.\n" +msgstr "Nie można ponownie uruchomić Spyder.\n" "Operacja została przerwana." #: spyder/app/restart.py:145 @@ -247,9 +236,7 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" msgstr "" @@ -714,8 +701,7 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" msgstr "" @@ -1068,19 +1054,15 @@ msgid "not reachable" msgstr "niedostępny" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" -msgstr "" -"Instalacja Kite będzie kontynuowana w tle.\n" +msgstr "Instalacja Kite będzie kontynuowana w tle.\n" "Kliknij tutaj, aby ponownie wyświetlić okno dialogowe instalacji" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" -msgstr "" -"Kliknij tutaj, aby ponownie wyświetlić\n" +msgstr "Kliknij tutaj, aby ponownie wyświetlić\n" "okno dialogowe instalacji" #: spyder/plugins/completion/providers/languageserver/conftabs/advanced.py:28 spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:60 spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:268 @@ -1300,8 +1282,7 @@ msgid "Enable Go to definition" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." msgstr "" @@ -1319,8 +1300,7 @@ msgid "Enable hover hints" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." msgstr "" @@ -1526,8 +1506,7 @@ msgid "down" msgstr "w dół" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." msgstr "Uzupełnianie, linting, zwijanie kodu i status symboli." @@ -1736,16 +1715,12 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "Aby użyć poleceń takich jak \"raw_input\" lub \"input\" uruchom Spyder w trybie wielowątkowym (--multithread) w terminalu systemowym" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" msgstr "" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 @@ -1761,9 +1736,8 @@ msgid "&Run..." msgstr "&Wykonaj..." #: spyder/plugins/console/widgets/main_widget.py:191 -#, fuzzy msgid "Run a Python file" -msgstr "Uruchom skrypt Pythona" +msgstr "" #: spyder/plugins/console/widgets/main_widget.py:197 msgid "Environment variables..." @@ -1810,9 +1784,8 @@ msgid "Internal console settings" msgstr "Wewnętrzne ustawienia konsoli" #: spyder/plugins/console/widgets/main_widget.py:510 -#, fuzzy msgid "Run Python file" -msgstr "Pliki Pythona" +msgstr "" #: spyder/plugins/console/widgets/main_widget.py:569 msgid "Buffer" @@ -1931,14 +1904,12 @@ msgid "Tab always indent" msgstr "Tabulator zawsze wcina" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)" -msgstr "" -"Jeśli włączone, naciśnięcie klawisza Tab zawsze będzie wcinać kod,\n" +msgstr "Jeśli włączone, naciśnięcie klawisza Tab zawsze będzie wcinać kod,\n" "nawet gdy kursor nie jest na początku\n" "linii (gdy ta opcja jest włączona,\n" "uzupełnianie kodu może być wywołane za pomocą alternatywnego\n" @@ -1949,8 +1920,7 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." msgstr "" @@ -2348,11 +2318,9 @@ msgid "Run cell" msgstr "Wykonaj komórkę" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" -msgstr "" -"Uruchom bieżącą komórkę \n" +msgstr "Uruchom bieżącą komórkę \n" "[Użyj #%% aby utworzyć komórki]" #: spyder/plugins/editor/plugin.py:729 spyder/plugins/editor/widgets/codeeditor.py:4434 @@ -2708,33 +2676,24 @@ msgid "Removal error" msgstr "Błąd usuwania" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" -msgstr "" -"Nie było możliwe usunięcie wyjść z tego notatnika. Błąd:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" +msgstr "Nie było możliwe usunięcie wyjść z tego notatnika. Błąd:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 msgid "Conversion error" msgstr "Błąd konwersji" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" -msgstr "" -"Konwersja notatnika nie była możliwa. Błąd:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" +msgstr "Konwersja notatnika nie była możliwa. Błąd:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:4412 msgid "Clear all ouput" msgstr "Wyczyść cały wynik" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" -msgstr "Skonwertuj na skrypt Pythona" +msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4418 msgid "Go to definition" @@ -2889,13 +2848,9 @@ msgid "Recover from autosave" msgstr "Odzyskaj z automatycznego zapisu" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." -msgstr "" -"Znaleziono pliki autozapisu. Co chciałbyś zrobić?\n" -"\n" +msgstr "Znaleziono pliki autozapisu. Co chciałbyś zrobić?\n\n" "To okno dialogowe zostanie wyświetlone ponownie przy następnym uruchomieniu, jeśli pliki autozapisu nie zostaną przywrócone, przeniesione lub usunięte." #: spyder/plugins/editor/widgets/recover.py:148 @@ -3191,24 +3146,16 @@ msgid "File/Folder copy error" msgstr "Błąd kopiowania pliku/folderu" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" -msgstr "" -"Nie można skopiować tego typu plików lub folderów. Błąd:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" +msgstr "Nie można skopiować tego typu plików lub folderów. Błąd:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1416 msgid "Error pasting file" msgstr "Błąd podczas wklejania pliku" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" -msgstr "" -"Nieobsługiwana operacja kopiowania. Błąd:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" +msgstr "Nieobsługiwana operacja kopiowania. Błąd:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1436 msgid "Recursive copy" @@ -3663,11 +3610,9 @@ msgid "Display initial banner" msgstr "Wyświetlaj początkowy baner" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." -msgstr "" -"Ta opcja pozwala ukryć wiadomość wyświetlaną na\n" +msgstr "Ta opcja pozwala ukryć wiadomość wyświetlaną na\n" "górze konsoli po jej otwarciu." #: spyder/plugins/ipythonconsole/confpage.py:35 @@ -3679,8 +3624,7 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "Pytaj o potwierdzenie przed usunięciem wszystkich zmiennych zdefiniowanych przez użytkownika" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." msgstr "Ta opcja pozwala ukrywać ostrzeżenie podczas resetowania przestrzeni nazw w Spyderze." @@ -3693,8 +3637,7 @@ msgid "Ask for confirmation before restarting" msgstr "Pytaj o potwierdzenie przed ponownym uruchomieniem" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." msgstr "Ta opcja pozwala ukrywać ostrzeżenie podczas resetowania jądra." @@ -3731,8 +3674,7 @@ msgid "Buffer: " msgstr "Bufor: " #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" msgstr "" @@ -3750,8 +3692,7 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." @@ -3830,8 +3771,7 @@ msgid "Use a tight layout for inline plots" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" @@ -4255,15 +4195,11 @@ msgid "Connection error" msgstr "Błąd połączenia" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 @@ -4435,11 +4371,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"Układ okna zostanie przywrócony do ustawień domyślnych: wpłynie to na pozycję i rozmiar okna, oraz widżety dokujące.\n" +msgstr "Układ okna zostanie przywrócony do ustawień domyślnych: wpłynie to na pozycję i rozmiar okna, oraz widżety dokujące.\n" "Czy chcesz kontynuować?" #: spyder/plugins/layout/layouts.py:81 @@ -4575,8 +4509,7 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "" #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" msgstr "" @@ -4917,8 +4850,7 @@ msgid "Results" msgstr "Wyniki" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "" @@ -5835,13 +5767,9 @@ msgid "Save and Close" msgstr "Zapisz i zamknij" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"Uzyskanie dostępu do tej zmiennej może zająć dużo czasu\n" -"\n" +msgstr "Uzyskanie dostępu do tej zmiennej może zająć dużo czasu\n\n" "Czy chcesz kontynuować?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5881,8 +5809,7 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Spyder nie był w stanie pobrać wartości tej zmiennej z konsoli.

Komunikat błędu:
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" msgstr "" @@ -6463,8 +6390,7 @@ msgid "Legal" msgstr "" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6477,8 +6403,7 @@ msgid "" msgstr "" #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6687,9 +6612,8 @@ msgid "Drag and drop pane to a different position" msgstr "" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Dodaj okno do doku" +msgstr "" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6864,28 +6788,24 @@ msgid "Remove path" msgstr "Usuń ścieżkę" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Importuj jako" +msgstr "" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "Bieżące zmienne środowiskowe użytkownika..." +msgstr "" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" msgstr "" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "Zmienne środowiskowe" +msgstr "" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "Zarządzanie PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." @@ -6944,9 +6864,8 @@ msgid "Hide all future errors during this session" msgstr "" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "Otwórz tutaj konsolę IPython" +msgstr "" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7056,17 +6975,3 @@ msgstr "" msgid "Unable to check for updates." msgstr "" -#~ msgid "Run Python script" -#~ msgstr "Wykonaj skrypt Pythona" - -#~ msgid "Python scripts" -#~ msgstr "Skrypty Pythona" - -#~ msgid "Select Python script" -#~ msgstr "Wybierz skrypt Pythona" - -#~ msgid "Synchronize..." -#~ msgstr "Synchronizuj..." - -#~ msgid "Synchronize" -#~ msgstr "Synchronizuj" diff --git a/spyder/locale/pt_BR/LC_MESSAGES/spyder.mo b/spyder/locale/pt_BR/LC_MESSAGES/spyder.mo index 638107d71162d10d1cce716907dff94b8fb51b3e..de7e4e60899692b264a38c07ea0cebb2cd1ccfe9 100644 GIT binary patch delta 27445 zcmYM-1$30hyT|c&Hz8<(OM(SW2!RkBQXseocP)_OP~3TOidzfC-6>Kip;#$@v=k{& zw73hz5qDCQ=Pk@y4E5iTa5Cj*}G+Vk*3arSK(|$ASYK zrviS9dVVX`!?zfXHJDvl96FHr$57ZsLkWC?T4~fE)4?oM`#G$RL4zIN^G&fN_3x26 zJI7IxsW8NGn4>ch-!NfkC`k)%hdEAR{C2pB^m5de?HNw|mG!r1P)Ev#{Ll}-qJyQV zm0lfTp8JSeaqvjTNskej7Gtp!&o@ME;hs@M0S};_e}VbXf3)LpfSiJ;(=*Sdpchu5 zviKA#8Lyz;cnAIPHKxJ$sFehcG07K;<*8Rkg?18ZLVHlp|Ap%BHY!;kSzlpl>h5O> z8X(nJ$EkzqP{*eO>bUekt#l~X#+jHCuUq}bInIC7^I`+cG@ibQL^td}eb{$I32&md zGH`-fd1d4nyG}jZ&=NE7Kqm}E7Zs5usHFS}GvR*w`eiIm{Ud72icBQCv5j>yGJx|d zCc*<4f`6d;y@N%y-_I%Jq9JUO=97(soebq^{6|Jv8H zPc!Ge32H)zC(YUit1<VYQh9nF`4VZJ5888C%{$kd0vxt9w8milde)d5ZwX%t*kk3V55}^kE z1J%)AsGPZs*)j9?=G@1i-rvPK2z83SLq%c{M&fdpLIDb=P%Hmz>%l(|KI&Dm5Ozi- z(LB_JwbgnG(@}qn$_4Li(_bm9OT8n;;SS7)iRYNyipD7FZW9Vx*?8*|)JkSyO5BRs z@PC*JZ=#av1!@mHKbmApff=cXT1%p`ye?|%x?pbXk2-Dhk^WuhHwqVaCQ z0a{o)qe46Yl?#(mkx4*B;4lW`X;kt(LiP6w)1#L&r^sc(w3rVyP8m$i_)aYfdayNW z#eK0Hjz@KP*m??;%@@ZH*}hO{ zq1mfgYb`9!>rGJuPDAbC7F5!nLk;M^$V4ItHBd0}Ipq{Uy}kvr;x5dC7g4$LWfAdL zXfiG~Aqq#WuoNnU2R68=xNi8Wp-esAQXqTJcuYo*uwVc-HzD{ip{lHv=Za zT-39oE}-(51>GtX6sq>9kh`con~a*kY}D~vfr`vJR1W-N>zAx|P|rU@ZP{nMgo#&} z(BDKYAoEJI#pN-V&VO|Znn`!m1SX;;G{@Gr+WJ0Q{|7bDeXNFWusX)BGJ8J}b>XZ* zZN)a!^Se;T?kI-h1&3t$R@e9`YYm*a)P(OyD zSaqHGZrBMc=sAqSs~Cns>-qe^;;8n&(5*+ovw^Qq*Z@@@k2&!oHo=dW4;%huj@@un zhpSP~okVTfbJR+MHky#9MJ*%;D)iB)q>V)-b-j(mUvFqngL0q~CdaX;8*zqhUyVtr zA3#4mfqLI*)Ui5`x=M&@#Sy?)3L2ET@JJkDzVkoXa^>+|;O75T{;g?|QIWYtESS+UV--Lqp zVhZZ2T!;$g4%Es|V=NZfVLIx8QPiiPl5!80!h4tmbNp%|6oDGJIOfGhsQ!ndA~FX9 zbpF>-&>nBW^tc-p!qcdMZdgitO z9>=MIS+Fv;M^_K5r670W7f!)3OhNtFZzhx%FfsMpsJ(lHI%a!2o<{dY6rC{)CY{!aWA>T)z_#&M{QTiW_Jn2~y4+ddi7QlE_laUH7Ti>Lwb zV<^5yEgp+{g0!BahTXc(xFy* z@POI6KdpB#2koD&p$ARmDxelv!`7RkKlQdQ1+5?+wdeg%D;{p!XQTFR6>1B1*!F#> z6`w+7^9_u_m#EMdI%L{oP!WqmwYS4`*d3K??kEcC=too%u0=h#A2s9Ss2l4t=EZxc zBn&!iCY}!?sFy+=w+^TQN29i24hG-~)Yh&?E%X;;T-Vu4K`T6I8k`##P5n70#@t6t zs3LJV^=Q-tH(PgM66%Lg&!5DZcpj%+VrJ?C?CY~o6I+OimL#RDJhuX?V=&Iwi zr%XumU@7W_uncxVg?tST!mz*CH(ZJY%V~0&6ToT6ea75q56+rMrTN=@PiTT!cy0t1 z$Azc~pG8ID>fgj)p?E+;dVGVr=>pH05Qm{AQVd67AJp@&aVP`(oi|B4;(|%Cxv1n@ zf?C)%48=p33GbmI>RdF>2V5lnnn@rHDKXf-kPm~X7s5a+XWMI|a-kt=B6F>)P$A!n z8E}_vKaDyS*HFjqGisq}FPVtvb15h!MNu!5#X{H+wSuws^*N{sF2z*11w(N^2IDQ; z{>d72**ursS_n0<@~8zhMJ>SnhJr%Y$38F~HSi+TfSXY>--o)G{zgUSG1kM6_%p`+ z!!zi)V!ku(N41As<%0~5V@+(sS=5%UN0QKW?o-e)OLg6x`xtCXy*pON6Ico}-7ueC z_3u=zZ%o){GXtZnTFe_6*{*}1Tvu? z)dV36tn}Ir@>OHYNjz?|rLsVp6q28bP4!ObjPAUqTX%yNnTM?H8K^~NWtkiI}o(EpzK-XDaTV6?TIZLe+X-=ac491GwC)XEc3{qDO*{1vK$ zG-x7MP+55&1MoHG!%vtW!~QiZY=G*x9p=XFsQ1mp5L}E(>YZ2?PoVk1V&Kr ze4qI1_K+)6 zIdGNrh)Y2$c!0|4ln>3yYhhyQ4N%9Wg>9dUBdIUJFwFVLOt1zfrCta1zGk-mHEJPU zF*y#luaC33Qz__t&&EhxkIC=?YUQ_39si4d_#CyDuTdT6er#4$1hvvew%!S~;)xiD zYf)R1fJyK)GLGw9HU;M{YM}p6GyQ;pnD~h~PU%qt6hKWd7B#_|s0GwTy+0oHTuPu^;nYrP`=%GNnFsH4TG0XLu~wiEUGAE*IO zqat$)HIWCX4&PxK^u9LFr$_ad71dt^>ba7r3$8q>pQ^8kzs_qM4N9u6sFn1$501ce z)W_jwoNwD}y)n<%x39wc{pG0lVRgA&Z?mP2_+E|f>xmX>q zp!O{6y_s<&DiVcly)&mzqai|F(38isAGB@l_S?s6S{BPgB*`k8eFIIPK}od0KDZS%z*$U&mr)_Vi&^m*YQjPOHsq)Qqfnu5j+$sk zRD}AW7CII~aSAFKH~PD#aEJzVconnYGt>kE6PeJaL%ksiwSuas4jZ7hqy=gLJyD?_ zhlNJg-%`+C2l8u;FbqYls2Xa9Ep5FcDjA2M-Z;kA=b|FF41M>4 ztsh6N^cv>J_o&>;o5bWy9i%_kX-h#jS!c|JlTj<%jD_(KYUS@xAq+@rav%+=y%1`` zWvum3&&6XT_QFcI2=)9eRK%WPa>jSwQ_xBS`0y@`sZk-Ug*x9YP%9i^+h?MlUy54s z2GkxO#O(MtDpy`%WeiMiCfEQqa7Wbhy;RrvA4x%3JqdN3)}mIl6ZPN;RLHN|_7|w^ zPMpH5C_U;^EhlQC;i!m~M)lJQ)o(A<7K}nIY(2USD4e383FHnm7f}V&3j3l4a#10j zf_iQ)YNZLNoH&nq{yHj`9$G)6CX_nJVbR5BkzP56m51%ET85%maEzavs{{(VVDgJ!lF zHPD}^H{L?6)RWrd`wd18EJM914#nB1jMKuP#(3|eUwR45ywa^f4*r(=KA0Mk(`T8Uc77SvW8N1dkY zs0h8rmY68L$M;jU+m3=}x&VvfX6qf)fFT)7$Rkk`sf^mY2B;)!je2e*YHMa;E!==w z&^wIA)EUjhDx!{I8zjQ6(~W{Un25SC7NE{+f^GkZ8Zao6SwXNh92NQ!sFgNGEub^1 zzeT7CY(`D=h;4s}ipVDn(fLmsY?7rQ>V?Lr9O#A$@o3bH7h(lmhx$hI7&YN^A?Eog zREVpfBGm|W-rJ+TYIR3VXd3EMay4dQeCId?b^HLehXpd5yT24_CFN0j+yFITC-leB zs3iRk6`@raid#_k!D-ZlE}$lG50z_4vzT$xpsN*TqoBPjiTV(#hx$iw-zksg(m^RH0-NP{M_3ZLUX{2I4r z^Eh)beRhx25P!mv=nXYD*ckkZ`dU2ZF*^Eo;H zeJOO$WoC2}OHzM}r7$|T$&Ie4P|iZFY#D06Ur+=7fjXA|pq_h#eBe1Ba4>bhydK}5 z9}LHS)E}d6-u7;o$<74S9$rEv#~subBo8+OrA56SjtYGdR1UO7C0RUbi+bAEhuGKG zpeFPaYC*eETlE^XCGIB*8Yn2Ac_9Ppg{-K(EQnfZUE3aS>-|w7o`mW+0kwtyq28Z7 z!X#%F)RkNs6{)hQNYzCq%;!G^&9ECPJBOfFHU^a>6HqH#jEcZ|RL*Qi-H->cGZ7m|9zsPhs)%`i30y$EDHg_8SOmk1nuxVTowo0Aw$A@j3Ncu+ znE5p8i(1h-)J=E?i(*7^lYDJZThkkLp?r@+aD%OfmoOhzv8V|QKqd7$)I@IKD9l)r z^RE@oqo51uA`ZtarA)TYN6j=@X%m557)HH3*2K;@4S&JrSUJYy`=ga5*qeH?ugn!a z4Chl{fXbPYWz2_AhccXht>AYW3gAU-f{DwT6}G`p>LW1(m*HOaa1Z{ALu1Wz@fFO+ z>v+^wtwwzc9>i^U183swiXNv8=Bwm!#^QuZod1CoQdTwte}}E8U&lIFwu*_wRIEz< z6Dnert9qRMI0ln3k*d`^&i7cqy2qJJ`@I?--yc5pt!cjbcx#z0S%?w5eijSj7ngz} zP_VYgd5Tp~$y7YfWP4eBOUKnwcY1}o=9E-Jo#T4g9-E@JZntfZsb`L11#1ITza3Dg ztQ+byx&0_;Pe!0Fk`0&^x1&Dw{=nRL9(C+KqAw@vo21HxX=rbWeXu8v!CR;;=+wYW zd<^Q`PedK>d00;8e=!A}|7)m^-6yD(CTeIdj^wE0m=g8jvNv*TdY!sglb6{rd9N8g|SAEuz0oxr?!6&0dHjm^~@gbH;u>V_+cx|r&tBGd_W zCHF&3d>Lww*Pxy|gZk9Fk9sb!iLo^Le*W)BK_MT2%7sao1DB&ZI*7VDpJ5*KHZ{+M zqfSLEYK1MZ2F9cIb~9?rcA+MA6t$3(s0E%w-=F_oqo4=>Lk;u=wI!*WnWPEDoYccn z?KM$b)g1Nl+Y5C%HljlR8kKD6ntOb|Imv>$lB-}>OhEOMs|Dv@p()(LBvE73$~)r* zoP@f;O13mB>Way!_eZT{6b{1)s16giGLg)HicAD55|vR~TNjlp15gp0+=}zB2WHdI z1DB$*I8|$pvkS-I8jNjYuIT&t1NA&@Jq}0R*@ZW#Cu(O_d=C}cr>K74q9*3o-fTe{ zROB+*dX!5+D=LQCg7T>2R0p*c?NGVV7nQZ&p`M?DO1`hsQx>n z7ki+d?}d!#Iz#OX6HxWu?Yp&n`LQ&AI|gL!Zz>V3y-`#sbEukao@ z;{T1u>5q>(dYtLlzO%WQUUp#|#&^>Wf~C+^h$>RhceWb#g%+p*;%&V*Ds)3pkr{=W$W+XM zb8USWs^deb_n$&d?23K;j%|O2%85_iIREO%+ud{&h^j|nMJ#RW!>tpn-&+@3*P*WF zUr-A=iFxrHYHL1WHw^A!Zpd-infhNnIR7fd^faGlD>01vRUCi;-+Fw%Y#xq>sc%HS zb2$Bbku#Xk+kEvp+{fII75kbHe}e^iekkV0^;nYU&to;}sYnP#u9-_g=Y0rP#;I5r z&sy^g@c91hw?8(f{UDadECbDt*{x9>uE!eq5_Kb%8)SaK*n?H5rygv+`835i>N~I& zx`~IF?`jRP9u0?aItC9VyE(Aya0K;J!#%!#zf+HhDby1%A1=pGJdR`V36{b>Blvu0 z#Xq60_{SrSsYjVyD~2Si>r|njJ&MP1ykR7250i{BTaprWoT{Nh+647sG7fbN-(X2h zG}avBvZ$nsL%pvtDkG|?x2B=*@Bg2n(42-lSQ=xQg>JqfsH=B2s)Kc?-n7(PKQ0WoDdzl#U~cN6s1;U54b%jqa4M?5y{O~oooc?Q_3 zL2Xe*)P2$kHL;DTd*%Qtvgc6~aBosjcHc+M_yuZjKO*10oS@m}4PEA#nfF6IKg!l8 zqq2D}D#`Yv1~`XW@eNd{pP~BC_oJ~e(hvXslR`~isEqk>23Es8s3Z!QYuXE-a-|t+ zrF}6HhobIN^n z^{5&DiAuWLs2RV*R2aC#BxN>KG8MFzM_t7YQ4?y1dS74bcbJv>0xW_%QR6&W!ui)5 z0+yNyWI`oLDb$K9piV&@)CAg~uF`K&@0)^(++x%Kzu5X2R8HMT^;c|}v8=T!D#CS_ zxn`i=G&H2aMXls4DpWU7*?b>E@g-_P>6e>Nvpo0%_3EgITtjW)b5vwgtS}$5c~HsM z8#V9`wmre6paJ|>nwu>pD$6tDR4k4Mc-_S%aFuChK8T1h1hYSagj! zEfr9o5j`*zSE5eaA!O@Z=Pm`E-;Y=f^RG48`ZcP(BPwLGQG30^dIB}!+Zc^+P!Y(t z&V;@!DuB|4Q&6D8E_-2 z!yBm2`}?SzNwm?N>rm897-j2mw%*FthoUAn30-wOpMs9dUDVze-eg0E8mI#XVK>wp zhhP{^LPcz=ZNG#H@dH#+XV`2)p92+{C{(V+qH>_tX3oF%s2dGh@gP)|k3y|rCF%_a z&>v5sa^!F9ipjT_Z?ywZAwO@uf!eZ%sEK=i_Bcl{73!Y2h>G}|pE>_i^}<#&;9}IC z?!`LzAC|=O+sx~OaV+(@7=_t?F~2vggB7T+#yEU{ibSdH=Jj|~lJ3E9e2RKKlbc|Y ztrBWx@u-!KM%{4Vp^|f+t#3qqG1-SYe&%_OsN+=~Uwb%isN)s4%j3MluBcr2 zdbfFhHw>jd71?Un*+fBaIE_lCr&tg(>@oGqsDZno_G~I@PgkP`-j15!0aVUhLrv^6 z4#F&Z&5wAqQIU0iGYd?FzW@G5Y6`lu!%+`ZK!v_4Zo_7%WD446LYf;jKuOeIH^zLp z5{u$V)Q#u=yNOf`hElI!ZI9ZjF{*R_I4dY9=?uJ4OCgem zC{!q0qRwSJYQS!IAII8y&lBdk?=TJRGps96TasY?)3)EhP}*OjLZ1GliA-)RLESA& zK`ZT#n%NXocK?KW!!gvvE}=qr3k#uh%3M6rs4Xjjns`-gjB%)luEbh+14m)Ozj)5W z79*c6uCwE`Ik)>zdw31CNAFR4m;H>HKxI@&<7~YR>gMZ&nm`ZRJ{*-R<5ABqw{Al{ ze-QQjG4!4P%eLV;mgNQSS+iH=QCDv*RD{~1-Z%giq4Cz0sJ;Im>Udp5J?H#wCXmXS z1J!?F)N{QsMCboU`@&YtLH)U2z;x%#9!H@Sn!)ir}*=oPP}%bk&47BWfiDQOQ^lb!^&O zd!V*rkaarh;#rNluzo>BWIt-_PNNof7j=xEq54g7&HUyx{2J$99ZaP`9V|d)_gYlw zE}=TSX?>2mSiING7nLli3711X*AQc|16IP7I2<3~4D5Wv<6OoMcmmJ6H$4s?c+RR@ zX5jXB%omC8QK#TMDi=PYj#0+DX2Km%$FnzT0wYl$-?LE@+=+VMS?q*QaSS%SXBK(| z=P<7OfP&8TsQYH`7o#qoHK>k%M|FG-_1t?@WHLT5&u2$PF5FtowpTz!q$cX3YJqxR z53Gj$k;wDk|Dd3i97iSNKd3kSi|XJ5mdE4|&4g;B_Ocx+HwL5LHx~6FH3K7YH!9ig zqjKPbttWb9`pt+r8Q%%>75MG9eW5$*;uwaCz*N+L%TdX<3pL@>)*H6{87jFFKQ@sH zLrtI(YD*fU-v2GCzwua|@twsK_Tqi~8`nHBUp|LFB{!&_z)d*mnfcpmG5?t_r87`B zp4&Wz@yf6m@~MMnyIr)ldIdoPU3^a4Ze#XyR*=Osi4# zZ8#YJLhWI#HztIQQOVRDHIaU(D|nG@--fzicH8z#*oyiy)I{RmnqTiXdh41y`8W;Q zs}%3d0NGG`R1CF(+SUfBNHs$xX**Po^g~T(BI@~>s0jXO>l-i|^`B8I{|iI$x=TR= zd)}MSq(xnc z74CEjVHB34-gwm3FQR7b`Dl(`DC$_X!=gA1l}ravTae_F*}7;{y)0^B9Z?e*gc@)Z zYO7|My6dd94LecC;}mK__fVf^DLdGyM%8eLXuVU*B zP&Z<0+ujBBSug;V)HCht>(JE-wo}k?`wg{c|DY!F1czZFKjUZ|K;7f-b#me$RPrsr zo_GkABgGSWeS6#-z(TqBQ7%v`M|b zzsV4ax;i_dvVA>jpzEmfdlwbbSE!sxmCWnJVh;4(3#b+LL*>k9)Tihi)IxTlE~sNJ z1wD8lHKRB70Z($T?}y9OsFk+GJlNURr(qlFKic|R)I>ZfjA>9?nG1E?ilO$tCMq)R z?dxtA3R?M4)CDrZKClS2(v_$gZ$pLt0BS;iTK~o<>Q_-62L&3_qav0CHG!PC3`?T= zxr~hKI=3h&0?)7xe#W{O7v%N*9gxLXkb13@W<^7=1obJXoH>Y%@e5YL#;HtwA!?#8 zQ3HOmCQ5B47>K!a{?k#=1WKZ2S_Ru-ODv4Pp>p6kY74^BnB!X8+84FQ^Km6^LVese zO6&Fg^g9Ff`VrKG{z09#JLvoGe|giHp1uM^)6s;xQ8Ypx(Fy!*LJlSlvY> z-GA5?{nML;#A6QXLr~8z!~%E-i{nf5{rjK%8O#KVSZkp6{A+73)XYbs&ih1ENVlOb zn%!6$kD!j5Uq-W#QmDu^LT%MVR8k&5P5fL&&c9}Mg9c@9vP|ZI%%}+zK(&`aC1)H~ z!}h2NZ9s+g7t}yU(H}pfBIp-v7M26GbtO@sA+4>WgE{}2(Ha`Ua3|^_xrsWjo)E9^ z>vJB|M5dxvz8JMNt5L`H7t{oQL*>j_)RrX9Z0?B=)bqtq<5fiU)5xWuuiLGx{ZV^8 z4b{cDo1LdR@@i!;waP>tVK=eBoaB- z`GVvuo*P>Q%0X4u2)XM#Hnb%8UDD_TQ z1}C5vav1geMbtPC(D&zmDRP^v&x4v-B~%33qArTQ7>nys$@U1fhu%D9OH!h;Jv-LG z!lRKImlTN00&_-NEbriZyEBui*WM8g);KnbWUK86b2E8Ctp+=MPO zenop3Y=wi-w8TD1|A!?=G{AMdMp>iibYR?;@wl*HM_ajjYb(d2pPay%-!8=s8r;Ie` zJQHdUtDtVi9@rJ9pgMks>M&UWb74iI>TOW(8;P38T8zQdsE_HOf?oX#Y1e5)K{NY^ zahNH}>-+mX9Z~HUP#4Zi)Q3pULSAP*wnR-RNwkSX7%H?SP#0B0R5G?e<;*J7R{m@~ zfQ5Aa&rwjw6BjlS$bss(1ZqzkqbAY|_1qNfhpSPM%UZ-FZ5ymceH?1wlc@KH6g3ej zhB_6kQQ1EL3+VihrJw<~qO$ljHo?~ziE+iezCYg|f=arBsL*?hnw z_C*c&E9#V-M(z0x)caqds{sN^nB!3d%Tlk0TIqCDhwD%`*iqDqo>-HXG(Yj=M(uGU z)bqnp$88~o;x1Hf{euE4$K_%xa)W>he7|wqo3fW`Kp43G}q%|sOCZUpM4r&5x zQK#b!Dy09|_J^oXx%6L|E%^%dTsu@Uc17)ZZ&aj4V18WdQqa};H|oKEQ5VBg)VWVz z#(eGNNC0t!OE#KgGK2xwW^^87q5zzp(ro zqo{90Me-_YOP(P)<~oV0n1<}ANEAg)q$-wVC2esw^|@8OzQ65SsG4y$wxRtaDx@u| zn|oj^Y72j`_5G-3~JzJs2TT0CDAg}%-3K&+=<$%6gAD& zn--N!5vcaUs1=q%bA(}yic zS;y=9Z@iDL>vh&q?^n<3?BMm#243I4p1X*}smC=mD;|s5vhAp2S-X*$$d9PVCZMkR zdyP2%lPMH$Y-apF)WvZW=js7emX2&VZdC9=$Eh$||9*-XW;u*@0T&6O6^NEzN+3 zZ~*myR$iw&4o4;RBg}zcP!Y({+C-q9OW}JOmS7t!*2cW?2kb-LudPYS0l1U;bks#u z^J}l~FQ@fGUDZdhFy6#(F?~C;!g1)kxKO!u25+;4PjP^kq&(ZfT&1--nv16|=Hb-G}^&R*ZY>E4a=sW<83_5IW87ub$^^{(c}_zl>R`X_9R@!ibl z|8D$4=ije8S0OVxf&3Yv^S+1qrgQ9DGhn}7Uf(~($k5yC3-xI%PDg3`m@gDHP%9gU zfxLg2q`tYYaW58OrGKJMg_fF*a60O^j)*gUu$HS^2p`}{_K z>aWmuZ=jN_*bw_^h1PZbm(K1$EIq zbt&j#2^?jPMGe$e%s>se1!FJ)2jdHDk3B}4fls3%@(>k?jAP8oBXJk?I#>ylk2M3= zwYEcTq1%^&l5LoMA^A9yWLZ%;5RE!sEwCsKKqc!2RD=$pviuD8#Tz&c8;|$;{DzuqU6OKSVR|U02tx+G-15nB6qVMnjPo$uZ7oa*^ZtH8Un^8%bfC||z)Se!& z^%JO$&!dw0ntlBl>T~}+s{f2rP5;?Yxse-vzyB*iL3>vnH9%|B03A^e_Cif;404e< z6H&)&3M%x=ZTl9~bK6i!y%T*AL-q3rwRK-GB?eF9{Of^y6f|HV)HyGWnn)FE9s7Dy z)bs679dt$wI0&_J7j?X*qqc4ns-NAc2^>WAe*v{sx2Jit`;zQ64O&s?bhD!3s6DSV zee2*Z1B!1=wzO5-g#I4S#(+7+lXx;D@ZXGC9*~eJrRSH-36c3dC$lCDsOhPdDB(&y z&)HN7Ioo@tB}rDIK%wZ+=t8B77MioGt0$YSm!O_-tE(rrd_u*aJltr8Q%D${|Bx!%B%nY delta 30093 zcma*v2YggjyYKNmLkWc5I}Du=2tD-Pdy`^8NEt{Z$%IKlhfPsw3epjfCQ4Nh5EKMy zDk3U2cojh`2#SEBNXPs8@3nl7@44r5?meG#&Uddhd$01WXRSR6;Ad+hzuX<^o-3Sv zvBUqooYQg2Vvj~D{n!6Ye8O=a_BqZZ9D}DvI!-A*?w#s5c`z9xF$-zJnS$AH2Ij&! zSRNN)Zro|zkB>Qy>%7N>9;}k)IHfTfRqlj2u&1>@=BNBHs>38(pMiNPk3)6%G^(R{ z*5^^}yomX56Y9P_Se*WygIq*VaT>GZH@5r(>cL-Z`8sM~cWk{cWEzUVs?-;>Ji- z7T?2$*e1;KViwk73Cm_U&SlD7Gl_o^7mc#a+ONW*l=oqEJc$~}EiA}`6-GNw2g-HF zI8Hgt!h*OEo8o$Gi|4Q%1{j6zPr$yo5$oX{Y=yPP5&z~~j2!1Ujd25Nrr)3%Xg}W6 zKaE`}A3)uoYl7o6!4AkIoJpw2Tt~+0G@Ix+HyQADWGv3c$&OPW1BAO2_D3yQs>?-L zE*7F1+K$m!bqXP+fxf7j&Yf!R+lrd;`&bOW!Xo%9Ciz&3X=Vvir#nsz^_i&qU&JWf zg=`?_9BTJ;bDuOH3`Aw|6jU-ki+XS|X2X}UFm6W8WIrm4f5o zmh5}1g$3pqTVN#RWXyq?sPkn!s@=udQ2T!!7nP|vjji!6R>S7cm=5Ey1LX-Az=N0@ zzeNrBGAgwGXGu`ZgL&{cYFnN{4ZO%)^IR=d62@XN`gi(pp|u{4b#V+<#f{h;KSG5r za-IogJ8LQ`0?SaJe~b+=;yE**7MP!MN6d#0;R=jLCG7)rl_Z7dn`Ehq3RM%#j;&BL zYmW+L4=jj-P%}(Ib&zSx(^1Jd4|V@1sE)rzwf8eB8L!#TZ_X$FYRJF9Y@7V3H7bT} zur3zDQK*1yvDj2HQ-%XlqB4b>hRNrWhel?fry(u*6b2!z7HtJ*eFJ7VDrJvCPb@ zv9%>?Chf5RCZOKm8K|{ifLhZRQEU7f7Qwf$1fH~BLS?!0yji-^ScPIW)NbpJwC_40 zE}Br`qGtLAdNaVnluIl(1FUJSiyA;I>hlg*90ys`Z2eQ#WvCq5f?AUOsEM5L>RJD@ zT;!$Vn!Vv3s)Ic26`D18R8mI1V3M>bs$2%uaaGj7 zo1!At74-sihS?9!qqft$MnRZ#c8kY+-xD)l@JE)}l4a?$Rwp{i_b6-tV2wR|%E*>@WiKw-G z7B#~cth+HA<&RMveukCtM^psd$n~b90;rJHLWRByYHbIg1~e3P0;ZxOlYv!mvMs-8 zeHC^8o2aFG55K~bs3q9)l9|8{$Wpsb@PvgMah9qqs_ zco4f{?hPgf`lFJ06z0T9sQaHpZNCK=pnqpA7Zs^EY;X7h724~l2;|*p22>ms;>xIo z8=#V{wf%euMo@kNl`COXI}1<|+ltw74{E93!tV6%T;ie|Hr!+yaIG^?9nQ1m)%X_W z4X8Cv+-#E*Cs1C50W7%1d`GN@?Q|d3!3|g)Pa|iX^B21MVEI<_{rosKpj`G9Q|^P6 zD6hjo_zp&4xmV5h>x^nJ9d+L_)ROH(&Ga>^j`5E)$9aPfh-bVbD{iU~=2Wp{m zpdRMIUZ|7t5nG>*xhX%3*>EYUqZO!awFY&typFo>LsX)}w;0W=>A<8~~9 zM^Fs%fT^fm zFc-B;UPVRXge_mj;@bbY-{1#OJ_w+eVj$|M9FFR6Dr)8{unqo+YN+v>CaVXclJaS6 zirY~U{TUUZ-%OX3Tt3Gc@0+W)_BK`5M}Y;N5UjT%4))Jz`1Dwu$!a3+?< zb+``qqjF-{e)IAQqB>lUO6pyx0Ubu|w$HE=UO=}a7qt$U8^YG<_!qli5$Y&jbkKxy zEk;m&1vTK;QQPbU>ZNiDl`D5p5p@okb|dg(iX~A4yM!9>twY3LA-_+BLY?cd8F2|z z!&Pm$4l1-QZT$c&LU}00UMe5(P{)CELo_Eazi=fICT`n|$ zDAWvMP;1@_HRH~H?ng!Twyn?po{3lqRDCq+9B^Z~ zP_lJLH57+R!Z7N_IjAIBg4#~&u^MhiCE;n*z^`LX^dB?Ztu|_jdZ3nI80N%O)KX+( zF75xxT&Uw2s2MJ^<;|!gbRS0G6;z~d;AFgm8sND1jZb1O%JWe7FT(}62A{OuW_5*%5p#5L!1iuudVmjW(+8>&TM1N#L*%mdkN!S55qGogj6`4Dj8*_bZlB=*a z3d>O62KD)4sDTYfMKTTZYX6VtLNl3xx^V$&rYljAS&KPv2P#5uptATl4#00w&ow@2 zY>Uy9yQ4ZBg9XsF&OzeWw@DmKO2*b*CjVnRL&$5H+jU&kjtW$$C) zGrk+J8?In=$~(U>kvfAl^!X|CvgwMkl!v1R{K6^XuTX5DLLuFW8rebANp}hr;$Kk% zd4SWf#c6Z@+c=SqPoR>v>z5|U;!(+$jGEXa4B$M}ecMqHeg8}1udMu-3Jv5GDpcR0 z>aSydyp0-2uCGjeaa1mpLk&FM8bXD9A{NIdZT$)?PkAG1+r5XH=+`b63i)+ZNbcJY zBF>nfhRdO5&Ix zLmyPfBTyZVLydeEmcUi0$n3_x_zv#C5@%UHJdT5K&N);6JO`!j{h!W-)?z7^!B<8oZ!sJGj@p(tQAh1P)PYmr2d=Rs>bYVUOh>g*t$jhjt+<_X<+ZcuKVl@7WO1`o`nTDgW3guYT zbB|(aOhP5~bZmu7F$zzia?JUe^;go>|JiJxA()%;7*zdKR7YD;1Ko!j;6YTwCr~py zZ|kq3a_J71#e$d3lGa8oMN8CD_rZ!7a_z-@)C_i_vie`BnHT%TEJ0b+30MVHACFTp z8LQ)E)BuZIF%u|>5tJ+0a!u4k8e$%7Z$Ec?+lxV{{XGBS8%@oX~_x}nm)X{qDHdIG@Q6oKydhjGFN6w)- z_!IS9?rUa%MNtzdg?c^)bzc)4f!(b;u@B`kziJ??e+n0Ra0Yh3m8h)#7S%!a-%M^4 zKs6kNT7m|c7n@-N$E=R(`|b zW_Ze$3*9g?u83NKXjEjHVt#xK3*tyrgA-8^nq|uiu^r_#$XYw!V+H)hmh=8e{JT+6 z>`#-e!%z(+p*qY$4Q#sod?BjC6{yH;MGa&ps)NH=7(YN||2b59Kcd?E9d)1Yripmu zP2#VH3R0o{S^~A^4N)^`ZEx&~MJe~j?U-omi`_E!m$6pEo_t;pyQ7OQ;|Ew3!?(?V zmtlR%8(c1$bMY1Gf#P?}4;t~^rQ6SFr>Zd*BVob!u~=1_z@C5QqJ6lr0~{Qj|Z(0A9r^Sl};{)eTV*Nkny+ zVx5Fq`?=N?sD3tL5qu3JwEy3+AAE=!;VGL&FlvAwpsNnP;zA97g9_y(R0qFe4fJO-BacR{c`H-{ zgHUTf9JPxwQ3IKP>i8K{)~`h+-+R~yuVVmfWcQix|6Q~Dyd)Wb%HB9sGG(G}oP+9M zD{6c0M1}qUmcwJH0sn{^*ez6t5jo5f)JF}pH7deAQ4<}K!!;cQsZdrvZC!iIROf$y;850Htw&RH&^`QR2R*{b9= zNz@b7;Nz&1Y6Mos*{GSmiuLg*DiSwQA2H{L?cG8j)SNNLbmLpW;6@6y*8j`^akp_6R41XZR>wSWqXc%Wi%=6 z-1-SM;CuPm|Eefdz>KU4E~gxeW$+W!gI7@Z-9qoA<8PR#!?LIjYuIupR95#xg+3WI zz-g$1=Q-5Uy^GofUle5jE6cy6LJj!}87trb$}LeHEkq^XYSh4vpgQ^n_1vGR85b?= z^M1q85L;3ni4$=XYL`_j;`4qG>4=Y0e$wSaOYl7^BsWnF<>jw0Y{pusx8G@0D1SpG z&mCJXT+DP-1~t=%QENN`l^f$w&(A`&{~~HaZ=fdP9_2!zJ&)Q}zoSBwySPt(KhG(N zO)(2K&~4Zdk63S^I;>s7guFFsAcIj$Hyo90qfqxPLM_e9*hBmOZ7wvUyd_O?RY8qx zAZj~?Q6Y9w4ZMIlF}I<%?QvUQpp@ybB5DG)tj$rO?}nOaGHL=7v5@xvYg}jmM^Ga@ z<9)!_ZB#@GmNp$$L*+_aRDCjfM<^=9OHc#ej_q(i>idLK#ynpgb$>h503XJD^zS5b zp?yCZE8-N?fYza2pL6aDfYr=rU>mcTmYzww&pxDr$!HP;1v6HK0V)%W5j>?KlfHu;);_WhK_agQ#tL z1$Ey8R6jY&+x=g?yqRe;)Q!VX4U9sCdNC@gwx9;`CSJ$SFb?0X;B%H@jeyU22oK^^ zELPDRWJ|FH<+o4~x{Zoh{z~lsXf6Vk%r@$QYbejg0~lS|=PbsbF%zd$F$21RO(^H7 zYIa8lRBlX0Me=1-vb~P#@EEGYv#4!(4RzmNE*HFEo&42&&Uh+{SNC~;z%U=PC_6RG z5j+}|oX62S5mC8u3$+C0qfAHDP@gwPg}xIi2QpAOHWsx+)9mN&TzkVl)QAqE_WQ@E zwaQ)7EJ;CBM-@@^QMSG=YAxHMW}0B@$J+8NRD@TdIzEo=@f6aZ>y(Z*9Y&$nHUX6b zlTcYZ7qj73R0y}DLU71WIXMCHz1)P03&naGqwv%^07vjq;&7K4&N8QT2SzaI9M299+*}fbs#Hi{D@?Olx2! z@(R|V`~fNge_)dKf0KqLv}-Xyc@wV0<5(X@H1c_WEWaET%8yXn@D47)BC$U2XUNUi zi1Lr936*VZPRK#nkn&1YQhtIJ@dtEu(By35^Zo)sc~p5h#^Mgt0DeMcd)cODAOmn3 zAe9g^3C!&^eF>06W#O`>eIs1Pe7u8$%yg%dJgUOT&xAb|x z*_?>wDPO}Cn7@^Yz-D}m@+s7S8?^R$zwzjfgD8(f&F~Wp;P0ptv1l8%wU3`0@e*Eb z>zW%+w>Pid+o+M2>R?_%jd2&{{gF`6y>+JLXHtlv) zB%Cfj?=L8aP!ZdM6Z|Bp%f(?T!rjSEM%1W>&sl_RditDasQ;pu&->G>_}=E5(XXhb znbU{e!hMIaCgrdD`n+F0Mf5WfiN$Nw_d;#kR{c%(cfwmd*B5p4yWIwuZPN#}uOG$) z9EMuUqqe^NK(j5oSsz6;oPyeRnW%$j5-Le&q7Iy0sP7F2P;bXiu?l{JY(swkJIEwS zVN{Yuqp~y+GjI$(iI-8|3(^Ohkw1ev2Nt0A`#Nlm8&K!LMbzu|Pt;8FJ#5aABBb|B}ivFE8TBb z7Gg!*f@}1 z?z?Tv5s#ZCDTT_J8mO09-N)JgTC4t4XszN=@9A;a8F!;X{{Z!1*`elFFjY`TbT1r< zZ=>3Y9%drb43$HVqh>w|x8g$7fz~F@OeiDHH7|$BRA?r%aT3l)HCQ0tgt9y;GWAiB z=!sg}!KhquQ4w2YKVO9!;3iDN(g`M+XW>4|_i-b>=_Z=9J3eT>Xsp7n?9Yb7ea^Ss zFnNTT`LHAt;w01nMxh2c5w#R^P!XJO%NsEdK#OEwSW zL?vNiRDA_&6;y4;2@U7_fgM%hn%F&Mb!O2qdL4{>$5#!B9s%OsLzWUNG#^l{%^^J zLf0L&hViJ7rr2^A)$l~rz-FKtn1>qB64d>xQA_d?YGAui+wo1*!Sz0BNiLwC`whK6 z|G#4^B1W2#7sRUEP#*PQ2U|Z3)j=9wBu6IUXbh(MoagZCG;=_urkjqQwLXttL{Urj zimiVQU7cwAxzG&HqSpQr2Jr^!!AD1#6EXoc@@c60IkvnIbug_&MQ{fyLT{nI${n%w zpQGA8XUmsIvHumi8&oJXcToe02%CLd7*%eFYPdD3fv%_l478s=ZtIg#p&xDQCtyX& zPuucFY)^ToE#C~Y|J8v%!B0EmSiU8 z*Zx1mg+h7~b!PvCIzZBAn;R#i_VFH6(!GcI@ncj{oKjC|JofDwlQ z%Cphof488PVC!7&r~CkiP%iTv`(Llaaa?rJ4XADS8)`uJZ8_I`6Uw3(OMP9`HXMPP zNhaz@o{5UYT2v$sVQtK@z`S-FV1V*)R6jEo*!{ndiYin*kDB2DR7WRK+azM4X|OSB z`%OT7vss6|aW4iilCRD>prTO`Y-r29@f_vBsEIXRY<5Mv#bm8UJd+BIXd~*I$u2C8 zr%^Mzij6U1iMhWuwx&E3m6WTo5q^o2Te;@gt~EC!;!8XWfEoXBT$I0~n2w ztIUBGi^?GvqqYBE=0eHxDJl|Iuom7xoePy#o3-kTDyL%&T#d@|W2l_TyT)XB9aPfB zqjGBjHp3mL9J+%2uFZs5gXt>RD`af zB9rY!6Vbw`C8~frSsS5ptvA-hQRpgZR&o)An^0@~8EUO=VONY?Z?gFz)C?A&?mLc3 z%4?`KzkwRSJyi1LdCBK(%POb=cSI%K5Y&LfFR}l1W&tdNAh(6S|V9 zA3$Pkxd$q#;!zF0YTaW!hzjxhsE#h20%v*3guYzasst$&Y<2E5wDttTcO$+ipqW$wQUz;BRq;q+B;so_TM%W zx;m%_`dUYzMm`Da;d7`+97Zj{Y1A73iW<;;)PVAAH@Vap6~Tv4NgG5RJmWBcYtZ}q ze}}kG$H!4?e;zf$?5~*vB@#7*mZ%0(P@$iM%9$CcrCDmrtFb!eZMJ+GHQi#*{U+)w)-hYYXv=@va@~kwtdDglzk!4B61KxO`^?wu>8MD2 ziu(KkDoF>vWnNA*QJ=qt0X&Nu*aOr=%kH-)WJNBNoegY7XVm-uVbu0ZMdi@@&LB{B(h;=O>1)aMw$GuC^kr7HKHsc(te){ooreCrN$ zwZA{(LLs?kEq}~B&=cEGKLvHr975gyiS-JGDCd6Pd?y@-3h~=m7k|XoSmL;OCk#U6 z&SF%KtUAvA*MYEwikf&56{@?ald9MU=7E~1rD%ZK=WVV1P?1Q$mbe@>z<;5Z@DEhv zvYjwFP#ZOoZm4!1Ibq-bBdO4X6R{~SL$cHP6m@joMdd)z4^6oaYM@=PD#oLdY$j?# zFX0J1j^5Gyk@+>>YSgYdgUXF-E*IKnIX^ZHmq#U2V|)zPqHcVE%8_CxSrR`#;h<)a z@~KItNvIQX9%}8Eqjxf5Ey~AGp}&F3nFpwexj8=b@i*0Y2cRl)eQs{7h`>eMopl=7iM79P|4j1x!-jXxzNZmP$8U%N}lIY2hVZT znw> zA-!nJw@?G{eQ5>|fx52*Dpvxi`&(FhqV9hjHQ+?l%rk9y4z{Aa0<~0MVG;UwE^wh6 zZ=)V8_>~D!z}gD6_K%^qR~U8Q^QZxAvhGE7@B!++$TMcf^-%RaPzT){TmK4r|NW0+ zTqv|>P&51vbK_-HM>kMwpW|yY^U~Oeax5ylQ&2O{#3eW$bpjSTYb=LqFACLu1Kfix z&a(eEa`6WhH}QpY=C@vpzcGKTR_MIX`PPd(bklJ@W&cH=a|RdTN0@%e=e$MaTK{A^zW1~F9#Q+U*#&Mo7upt!u?KEPjrcxl zKj-qoZ!3@UOzpbn}(P|rpDYJScyfSSl))I^4(k}?Z*|5PlX z{lAEdwp6S~h5AR-THZzFM3LXjgXOU%<(gOv2cX{L(@;6E$d*^32DTlwga@tP+xnc> z&9*I$MYaE{bD<7fptAHK)QCq~$J_c@sN`CMiqt{W0M4S8=b`#aX15`T&?(&t4BrK0=r}90MOKni)p7+@QF?bbq^bUVumg*%``+HDJ{*lXtX7Ho+7gVUOqh@#) zm6ZAaG96b&-5-q#VLe;!h~+7FN6q{R4B$9ajx0w-W-BU*_uI02i3?@iOX^ZC6Fqp&*VW~k@lZ8?k#*mah3q3!o3 zYFpjKhFHVz_mXJ{Di_wG&Vl2$d>S<~Cz}~aVbl!Epq8qZEw{7f{;1F=qXsku%V_^^ z#IdNz96%i?@1nB(Q(Ip+hu{0lDy6YD^}8`U{(u_b zPpC-TQJMao$eey}t;?W77lXQ?9V*2AP+vG6#kx2Kb+Tj|2|v(0M+qHTmJy{ zeIS2?i9jQarrZO)fB!p{3pKDBm6h92p?(jQ8=u?qH@5r>YUVd>eYRYF?_E$3^?Wqy z^Y*9-^hRyB!Kfw6LM_FNTz=R4L#0(zOroM}Zoju}mZOf&!jUHV8exd?P*je5j9TM+ zsHG{K#|*R?YWsCWMW_e1z(=qtE>)TH;1g8j^5=8Sh>GMh5vYn<+eWC7w?u8XPN?nG z1N}G>)lnKM>$6Z1+k%SN8>sW-2%f_a?dMzb`@O%|@Fwcy^tlC0ws$~vG!C_YRj=3U zTvXC*!Zx@Uy>kII!~6wJ&Xh&HMeCxL@*&g#m591;8frk#+0U0_FUsy_E;Q5ISQY(+ zOt}UQrCiUJ=c5L)+`0v|mT#e!;v>}B|A30jJ^OjK!X`4sQ0GS_)aQ+niMmcJE;Qnv zsL&5V4Jc?$!#b45pdQ>{eH9h4*HHu5hwJbYR6ChPOve*Z5txO&aVhq}i?~($ze!QQ z_tWnM)QpN2^LszJR6`}r<2VqPVJEz1%dy4HK%Yf*xWu{&HPDw)OY#b80H2@+`VGe8 z4Xm&IKe&X+fjOupIEdBoM{B;4W{n%-OVoEk{p52Mb@bLOWj>EXMJ@}q-6o?Zv;wu= zUO}BBM^U+P9$k&>0T;h15w%RqLOYl4#O8vGkJg&v1l1{e=OFfJQOvs zXHk(kiW-VT7zf*?&uZzgCX5^(&FPqA!2YaFpk^$HZVp$?Fj zQK8&|n&JDXB|MK+FlPn(4nZwNAFPh?sPkmD%Y~9;6DHw@r~$hLlulJ`+xyK_`DmPakQ+n5VAl!$r_FU30eB`QR@E15`? zLaliX)Pt>110RXGa31OeT!vb*m#`APhDyfIQ3Lu7>tVLa-b7ue5f|#H6KZAyQQK!E zDw)ElwO)e?^;@We<||aLTu05gY!$O>YND2)FKR$zP?38Qy%QA`iA@;L{y)rxLjFB! zgnyt0l(VXtVR6)qnxPu%gvxroLnh|zcsm2{ESO?^dFN3Bre{H?(G9($J>A|g0hK&+P;2uNR=@+O?RE|~;h$Iw*VHu~ ze2B`Kv#1&Wfm)KtdM1a;p=R0%wZwx^6Ujg(>^h6NXiLQ=R0Ee$yTDi9?EgsAS~fxD z$}k*>E~??ns0Qz$4zltMOt~-WxeTmKx z!>Ib>sDtQ7)Jvvltl!y!y-)+XgId#4jZKK7PzP8iR8sat<<2tHlCHPz#=3g{ALBxy zzm1AOktU|$DAd|^K@B7Ub)Sn__&h3d1)7@V?TcL~k41HS2=#p4W+npFQA^tgwF{Eb z)y&3lp_#md%I2du2rr@5x=nMx_lty7RPyaXh5ipzGUaGt1{jTMr!#iI;iwL`pmxhq zRFZ#&dj4Vy_P;u~MTNFW)s}wm$L02@nNCABxDs`e?L*Dzy!9^DrChv~nPF$t{pqOo z=3xL|K}Gf>Y==Lf+N<80{jUalv^EcnK#lk*)OJ~g+D`jW+wvG{S6oLWSK&71zGkS9 z4?@js9co+eMDM&nwfhk&SubJ)=62hf85BaTNjp?T`k<0#5-MqCp$4!5wL9KH?*Ov( zXHf@OL_4!2F{t|R}>L4KTHOW*K zRbK@)!)Vm*XoE`TQP#Pr=eD4$q&m!nlI<7lgSSx0)wQ4b+C3O`04+o{ybS|*6!qK% z)UNmql{1CM_Znb&d~OsAZIn&1z}{rvrpWRqO=kUuSPdg2&A|I}inIbt`b zngi)mtj2@4P-l0AG{5)H@;cxe%K1Zn=Ly_~%dvF2`8C|@IDm5BQRc_)4X7lK4Ey=- z0=fKJjf;U)9KlX{FoUl~*_a_7r<^jHqmqF&W>hCJVXXNk)Ns7%_#E!zK6io%dGm=3 zockuCmT3JX^Tp!~Y8RE5%!$e84J7?LZAn5Mp}nyIGarIVy7^PhHe8B&3B8P3yPY^6 zD^4@pZzpP7zHR;7`jhnmYMU0BZkD(LYCHBu@8ACo=0XEVN3G3d)c#+K3iWnWWWGSH zY1t?J&NPh0XYozceVv~&15ZKi`!Ht5r%~BH2bFa1q7I}lpJM-OrhigF#GQNi96C>% zk zoJT!>+m_ut&zLo-g1WIWDwJ(eH>RP!N=-o>G^yWn_y5)<$* zRL2Q(O++T5BC!#fxa;iaLfhv&cEFYMOvm3^e@Crhj^|9WCowKS2-%s>jF_uv07&xJ-_7j7zEWwMEulhd z_d2Sf!>9p#fa>5JYUV$qlINh197;dsaw1^M4GXK}AUW{r2?tq^C0;zH10^#7OtmH6*4{@218K+gEX**m^WK2?WLQ)`E zu}Mh@#PeBPN=h&>P$R^R;W5b>!9XaDsiXzR@c+#W`1tRc{ryxpm=cOh;57U6+mYkLt z8dK{Z>&259!OXU5si2zjRw6aXhDeOsGrhB~UyfEu$r&cj8UG)7N_+p6s%p$jS2r*` z6gG>P6%Gf}G8sWITr0q;g&5NaZ;D!OHkCJ8o1uR%SSTwqBRTQ!?DBR5RRIFxk+p$PVCjr0{+i_jY4rT}A^6`8N6y611b^imnW4l`#?tu{{FU6eP{5oJ1cq3| zg*l~0hct9EcWoQ32v&FF*Vc_y{4w8glYvwhZ2r#HsNG5dST4UbiD|LiCdgGPDB6OaZ0c< zvf=_y#H9sO;yB@1;Nr)Q4;tLLuuUoza9{+mp*aFSAI|34F7>5Bg}sL_et#S1aqV?9ZqAl wM}O&|jEt;gP8)Ly)1f_?V* {0}doesn't have the spyder-kernels module or the right version of it installed ({1}). Without this module is not possible for Spyder to create a console for you.

You can install it by activating your environment (if necessary) and then running in a system terminal:
    {2}
or
    {3}
" @@ -4471,11 +4391,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "O layout {0} será sobrescrito. Você deseja continuar?" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"O esquema das janelas retornará para as configurações padrões: isso afeta a posição, tamanho e componentes da janela.\n" +msgstr "O esquema das janelas retornará para as configurações padrões: isso afeta a posição, tamanho e componentes da janela.\n" "Deseja continuar?" #: spyder/plugins/layout/layouts.py:81 @@ -4579,9 +4497,8 @@ msgid "Enable UMR" msgstr "Ativar RMU" #: spyder/plugins/maininterpreter/confpage.py:122 -#, fuzzy msgid "This option will enable the User Module Reloader (UMR) in Python/IPython consoles. UMR forces Python to reload deeply modules during import when running a Python file using the Spyder's builtin function runfile.

1. UMR may require to restart the console in which it will be called (otherwise only newly imported modules will be reloaded when executing files).

2. If errors occur when re-running a PyQt-based program, please check that the Qt objects are properly destroyed (e.g. you may have to use the attribute Qt.WA_DeleteOnClose on your main window, using the setAttribute method)" -msgstr "Esta opção ativará o Recarregador de Módulos do Usuário (RMU) nos consoles Python/IPython. O RMU força o Python a recarregar todos os módulos que foram importados ao executar um arquivo do Python, usando a função incorporada do Spyder chamada runfile.

1. O RMU pode necessitar que se reinicie o console em o qual será utilizado (de outra forma, só os módulos que foram importados em último lugar serão recarregados ao executar um arquivo).

2. Se ocorrer algum erro ao re-executar um programa baseado no PyQt, por favor verifique se os objetos do Qt foram destruídos apropriadamente (por exemplo, você pode ter que utilizar o atributo Qt.WA_DeleteOnClose em sua janela principal, utilizando o método setAttribute)" +msgstr "" #: spyder/plugins/maininterpreter/confpage.py:140 msgid "Show reloaded modules list" @@ -4612,11 +4529,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "Você está utilizando Python 2, não é possível importar um módulo que contenha caracteres que não são ascii." #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"Os seguintes módulos não estão instalados no seu computador:\n" +msgstr "Os seguintes módulos não estão instalados no seu computador:\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4956,11 +4871,9 @@ msgid "Results" msgstr "Resultados" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" -msgstr "" -"Os resultados do plugin Profiler (a saída do profile/cProfile do python)\n" +msgstr "Os resultados do plugin Profiler (a saída do profile/cProfile do python)\n" "são armazenados aqui:" #: spyder/plugins/profiler/plugin.py:67 spyder/plugins/profiler/widgets/main_widget.py:203 spyder/plugins/tours/tours.py:187 @@ -5876,13 +5789,9 @@ msgid "Save and Close" msgstr "Salvar e Fechar" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"Abrir essa variável pode ser um processo lento\n" -"\n" +msgstr "Abrir essa variável pode ser um processo lento\n\n" "Deseja continuar mesmo assim?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5922,11 +5831,9 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Não foi possível recuperar o valor desta variável do console.

A mensagem de erro foi:
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" -msgstr "" -"Não é possível exibir esse valor porque ocorreu\n" +msgstr "Não é possível exibir esse valor porque ocorreu\n" "um erro ao tentar fazer isso" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:621 @@ -6506,8 +6413,7 @@ msgid "Legal" msgstr "Legal" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6517,8 +6423,7 @@ msgid "" " Hint:
\n" " Use two spaces or two tabs to generate a ';'.\n" " " -msgstr "" -"\n" +msgstr "\n" " Ajuda no Numpy Array/Matriz
\n" " Definir uma matriz no Matlab : [1 2;3 4]
\n" " ou na sintaxe simplificada do Spyder : 1 2;3 4\n" @@ -6530,8 +6435,7 @@ msgstr "" " " #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6541,8 +6445,7 @@ msgid "" " Hint:
\n" " Use two tabs at the end of a row to move to the next row.\n" " " -msgstr "" -"\n" +msgstr "\n" " Ajuda no Numpy Array/Matriz
\n" " Digite um array na tabela.
\n" " Use Tab para se mover entre as células.\n" @@ -6750,9 +6653,8 @@ msgid "Drag and drop pane to a different position" msgstr "" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Encaixar o painel" +msgstr "" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6776,8 +6678,7 @@ msgstr "Localizar o próximo" #: spyder/widgets/findreplace.py:123 msgid "Case Sensitive" -msgstr "" -"Diferenciar maiúsculas\n" +msgstr "Diferenciar maiúsculas\n" "de minúsculas" #: spyder/widgets/findreplace.py:129 @@ -6929,37 +6830,32 @@ msgid "Remove path" msgstr "Remover caminho" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Importar como" +msgstr "" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "Sincronizar a lista de caminhos do Spyder com a variável de ambiente PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" msgstr "" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "Sincronizar a lista de caminhos do Spyder com a variável de ambiente PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "Gerenciar PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." msgstr "" #: spyder/widgets/pathmanager.py:276 -#, fuzzy msgid "This will export Spyder's path list to the PYTHONPATH environment variable for the current user, allowing you to run your Python modules outside Spyder without having to configure sys.path.

Do you want to clear the contents of PYTHONPATH before adding Spyder's path list?" -msgstr "Isto irá sincronizar a lista de caminhos do Spyder com o PYTHONPATH para o usuário atual, permitindo que você execute seus módulos Python fora do Spyder sem ter que configuraro sys.path.
Você quer limpar o conteúdo do PYTHONPATH antes de adicionar a lista de caminhos do Spyder?" +msgstr "" #: spyder/widgets/pathmanager.py:394 msgid "This directory is already included in the list.
Do you want to move it to the top of it?" @@ -7010,9 +6906,8 @@ msgid "Hide all future errors during this session" msgstr "Ocultar todos os erros futuros durante esta sessão" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "Abrir console IPython aqui" +msgstr "" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7122,20 +7017,3 @@ msgstr "Não foi possível conectar à internet.

Certifique-se que conex msgid "Unable to check for updates." msgstr "Não foi possível procurar por atualizações." -#~ msgid "Run Python script" -#~ msgstr "Executar script Python" - -#~ msgid "Python scripts" -#~ msgstr "Scripts Python" - -#~ msgid "Select Python script" -#~ msgstr "Selecionar script Python" - -#~ msgid "Synchronize..." -#~ msgstr "Sincronizar..." - -#~ msgid "Synchronize" -#~ msgstr "Sincronizar" - -#~ msgid "You are using Python 2 and the selected path has Unicode characters.
Therefore, this path will not be added." -#~ msgstr "Você está usando Python 2 e o caminho selecionado possui caracteres Unicode. O novo caminho não será adicionado." diff --git a/spyder/locale/ru/LC_MESSAGES/spyder.mo b/spyder/locale/ru/LC_MESSAGES/spyder.mo index 4a6a5b71686d29c592aaf15baac9ea80d98e10c2..64477e06f13d90345e56bb481b3ad3fbcb219e46 100644 GIT binary patch delta 37514 zcmb{42YeM(yT|*Pp(fPOJ3DkFgd#LFA zVF#s%6~U4yU_nK&0Tx756cz68KWh>|-}jt*?&qHKIeur&p0d`no>lgao@0*}`SGVB z{?AM0f7IfC;tE<;6^yxEr5peA+!)ImYFpOxI0Xxhv#g4I?2ogoBDe-a@oD4{)=tca zK`e|fVKsaWL-0$_ACT+)))fva@xe87!m2plvI z>xq_C1E=5vxE7mX=>*Gq2D@VfU3U?`qg*A)vc_TINjyt^K8b@exD@N+CR9a^VM%WM z8SkW=FU7K|Vs|WwW3UxY!A`gfJL6^4^(|5@>mHnrP4FFTkJqscwofDeEjgH$W?H%% z^?-7dX)O-L9=HN^!zsK2%TKW^o?-PvMdo$9LPd+wd=9Mw(=4kg&O{~aPE=PNMK$;* z)cwV#6aNMr%$-gsdB6@-3%{FTE)1DzT3QFoa=s;&#y+Sni^518hl)r5>*H3W*R1!k z2)3VP&i6zmZ7LSRIerdw<090sS&OCcNmL7Vp_1tl#k*7+&_nk6S0zWnKLMd z&$F!CZ~?Z&U8t6y$58y$D_akm;a3!W8vivpP)P2?GI$SGz%k|m%a0M1pYnVM^}wsB zVOn6mnLmo4o>K>#;q6!x$6*Itf=cSQQP01OcWV3>UEtQlx(5}KWGsg>P@zo6gZKoh zMZ+I5!)`3hL3QmFY>b5$nj~z6O6F16lJ{GWbD+?D;CT&OP_Dno zgz|n=i&kS{d2S9^c<>% zf1qAaV6iEeK_#CLb^U16izlKUI2{Y&Z13}hScGyq7R2>f37Kut{fR+@^H z^Q?lZKn*OV@!yyO4Yw|y!^{WPB+myijL!pD9JgUf%<|5^g+(c!@;*O@dchUX0_i5Q zrBOLh4^{C27^?9f&4F5)fJ(N7s0Xe<_4Sje7w*B*_&Ta0AEPRE4t3pCEQKXknOv%g zdS0|=JZhArp>krT&hvh2JqLYoD=MphLuIG+s3{l1(iFo`HLs0|Saa0pMMRAe$d zUqiL<1S*1`p(1d>JAc_L|B3ZDpMN#=*NYpjHV?W3t5WWPm2oU8d*@*}+=v=>`%s}i z=$V7+eD(TMBAC&+*DnqON-m6|sX@5x+u3>>8>oOZb_%D{xTV^A5~M`5sgY2cf%) zp<2EK^`aG68F!#UeH7JIAEGLB1~o5SL`CFR)bO>QFy-2w{>B{WhBm0a>VcnNf4l=5 zt}`tdgX-HQs0yvZDwu_;zzM93XT9<@uUzCw^Lbs=dzxW)?2J8>zl%Ajst2I5_cE#r zuA^=!xZVu6GU%gR6P3+(qCOvk3T-MX0!vX9T8#?v)2QcWqTX}Z`}{Pz`~UAbsLlt! zpdM7_DHEbdRLEPQ`no;#!tvM>_o5gx%pP%gx>xXL@f9ToaFF(2lj?mK}RW}jer zyokE4&@(17Vb9S2y5KfWsD>R-eLoykvC*hLPR6aaWz9xCsQy-3g`H5@U1pm%icl53 z1NGcNp5swn>&GUz2CLz*ZNy*0>;fk=^Zku#Vc2#v8mglDt`6$DcBsho!;v@|hv8vV zi>o|q?hi*rs39r`x?lkufr|8KB)_dmeh&2DlFykM*F?7k*oX6jP|3Lo)u*pwZTt@P zz_8~{%gTE;@$BUpi@MK`KHP$O-VxMj@So#Ap(wb+oT!azaTjck!%$tZ94p~ds8GIy zYWWH5h>c$`&zXdcC@;tQ_%gP_3s@Ly?KBZ;h`iTtwc?--9}Gdgz>kW^S}cgqV<_%H zh4c^>#}lX*edSqZm&uJgu>|Kwpbz7{^N(Oj%1?Xl#S*;VdXoe7(TAuO|AY!zo!ur9 zccVU!MO9!HswJzi7Oq1@N`xp(@lS!z6PrR7Hkj81J{nbI=WEU{`$A zyC7tbxv&xz<$MDyf_Gpcyc5+`y)gu%u_jJL<;DtB1OuoSK8dQx3#bYnLU;fF8V3sL zdsqxVM?LU+ulyTo*yYbO=c{08%5^XjTcaKvhkDUe^x?y(ia(2bUKXmtub^_{<4oeO zn&jJS`nWKv=B2R@R>Y<_7Q5hDEW|wUIjUvf1Wnic;8`%stPRyYJE0;KjcV8guRIm? z-Z}ajteP+2g!=R`R7=;R&Id6J-$Xs=WAFSKR7)>mDRlOk29!mWeW(cC?w!92%TVr* zWpOO({+WIbDsZqAmE~JdHQkNs>zA<(zKu$*%czPKf6?q_tD%N(57Z0Cp+Y_b6`@6_ z&@V?da4qV^n@|n&@9|E&flWB^A*!VzFPRX9p(<9+GaU7x2-J1W@eyo?r|}s6jnD2k zE!}p&eA_*Qw{iYYR0ZoCbQ|ioZs$NX?1bg8m*;4#OnIvJ`J<>8u17^?3+h1`s0zM} zdd@La-=09V^b=H0eTQoCCDeQJ9b$IX_^-r)ZcOr=h7Bk`h6Zx-=5@24 z4@IpBbFl@kM^*SFDiU9yBJn+{Vt-&oEb)fPrTVCfw8XiXhPwWD^v~eM1>Q7CIQNK2 zn#WPev=P;^z39UusAN5l1<-lRTwfSfkrJp#Rrby|K+P*nQAyp&JKq=8zu1qWZo8hF}Ztd`E0d`Ce259`Zh4gR0;rRBmJ( zCH_7RUgLy9_O@kB9`WTz6kZ=r%*54jjH%7sQKkX zRAestIk<;|Ysflb^?RF4#(eLX-+Es|ov-q)*?hi->dVUSndBRaw^5#rP4HQ~A3wt$ z*go4N<3j94`4w!7Avq=&{M|Sx$ceX5HOfYX<}{YTpD+})-zow{unv|-C0l#cFdc|$ zSrUffLd=J2QN!^`EQOn}Fz&^_$Uo~42fA_p2j)e`QRUMZjaN{8JN&qLa4hNpQ&2fE z8&%QusOw%py=Wh*i{8e&@e@=->z^=5+!%{%{P*BMHw-~#^#oLnC!vyYGOFdXJRe3q z=rPpw>rmHiLf!W)DuO#v6*`RCh95&!=ts}LbY9~>^rZQqAu7~uupxFswKNs=;F%bX zb5Rl6jC$aHR0WTslJO*}f?r~Nynqd`%!j6nJEGpx7yY$37{-Ba^kXGlf~9c_w#S!H z54ws?u+~Q=>F!01nmMSJKJJ}=8r@t%Rs1xnf?uJY`xC0-`9CK9`k>UuCYh?CX1Zpm zKD`IkHBqR(PDMo^-7^!_fDchw{THg`tv)eb(h)UUx}(l7z&ZE`*2NO1h`(ys@|0;o zTU1|m^U8OlS~3Jx(Xrm=DW21@4Cm)z1g=Jf_I1>Avr&;bff@~`QC<5b>bYV5Pfd$_ zsFrs2%7aiXo`MS1V^|3{qjKU9>P1I9KR~_cb5upoq3-(y%cJv|d446-eGO3+^xwgO zTF?&lfc~h8jKHy&==mY`quk+hQ<3GU`<}%+@nuw&7dUNRP!B6oz8&@4KBz7jjLNl< zScmsp6F7+E#7Ydc87`=rz5Ru`Fb7qk&ruJ!gzBQ-QNzzVW9Ef|=uoVHx~?)R)HP5M z>4)lq5vc3##{(Mw(cT5&XUz=_u_+e}$40mmRiT5Zn!k!_SvD$3FJNoD>Xn;+Xb@nY z7p_57Y>W4K5cR@CsK{iaD)J%v^}ug9P>3(09_V~+9vF&xV0qMawXpz3q8`)?t6&>c zQVl^hBpM6gL{yHX;7(lVop1GxxxT|U#9ue|es_&MUQP=3padf4>5nc0S;TDAan<0cHp zH&CHHhpX{-yc1VkFf-#x)Qby$XChV-D^RY8ieytPhaIsj4)$}PzL|(ca29I3K8&~F zMl6i)pk90u`{OrWxz+b38GE3Q^U+ufAH+tu4OO9!QSUk9`7;)!?9cauIVg#GQB~9& zT?chzYwvt#tV6jMMqn~(LR#;ge-X9rpTQ8kjH<}r-uW6onyzeydT|${-|EMK`r_)ZtCDfvG4D;jPsL(kV&2<$}FAhgl{B~4J@4^x|1Xa;-=uXwB`&Z)-jsG1S z6z4>tpUex&p(;=r6@gl)7ert^Y>%q&SXAFmL*1W_#c(4Q#GP0YgQyoDK_&HPsIJX- ziEmFD|B)Q{Fb0d@e9slA7p+4j%}(^;o2VC@!%)143i(y6iur#w6|RA*STocMyP!g! zfU4+p^q1gZ5eI7N8uZ}?R1&`8`3Z(m{uwnY@?SO;2uFps9_soos0Iv2J#Q?Q#za&D z=AlBr7FDsGmx;eBaEKGS@jcW7PoY}&8|p#!FXqJ|o^?>?yP&$D7pi43UU?GgMGvDQ z^OWay)Qk6czWNLC&&P@PIiVV!#A^5hmcgRGnl1>(x|ExtDm(&9V>~J{vr!dZ>78GP z%AsAT=e+2Z-$Pyh5vt-B{N9OtznPYo#Rh!P7?o@zP)Rf&^}xqa6ViIDiLYQ;{05ui zU#Lhlx?&>Q7L^m-QRl~?DxTu$e~1Hx{&9@JEvOmoIO>Lqznc)&L0#7v)zY@u8t+1d zbS`SVuRyhMuXp|k>iQ3{0-i-h@K3Bx{#ix-Fv-&ZyYfL7R1FuQ`feTShAmz>h-%Sc zRAf%0uKx*jUH+>k!Y&09jZaSP;0~x4Ab}@$$>(egt{Re_24b2F33W)>xFvJSk!&9P%V4{Ti||dhnH~%Ho0y_)edY=`5hdIMJ?O!hJ1o$ zyTinfdeAD|j(e~oji z`OS5a*qicQeh$>4RoDcdL)GkaRJLA4g}6`w^MKk|iE>NSFzxT151?MS4c!^e^Hr?I z`S(#db_vyh`~}VP{H-`p1$v=s8tr|s02Ptds2A=)<;q*$`AeuAD8%ov>Y{3>3g3a9 zu`6o7un=|s3#jXlqAGX>iIm^^i35%EYZ#7&3!4hvhFUH=V-*~SdhmQyA09!i0mo6H z{S?*5KcZfkFT{5D3)N5&tci+HC-h-Ði}kptBz1yzB$sByX(^`hrdE!>Cd;}1|3 z`W{t*5Wcl&ISxZzUja2W*TM+wjvBUUsOuJ>-t!2$UR8?awk-UDi^gaR%EL=Mq#~TwiUpKaUj+xZd-G33TguT1KVP?5++i^(XY@YanJzg zqK4BB)Qi8zJJ8`@^lF7|P&tr>ir6|-Qay(X{UOxzj-!U(H>m4=LweUL5N2D`C>JYb zTM4+Z6yraQgDafS)H<=WNy5XZzWNFknTx1Umn~ynR2B7kQ&foCVSXHs%B4h9S4{Oj zpYMIX8&#noszGm*Vf?F4tg@L^OJ z{fX+*vgJ$?)QV7iXZ7^&3{1d z?pDLJd=x6nXJRjW8kgZEd>rT0G;2fETBc&tup#GHqAL6{`tU5e6H#s3I!6~&Lw#PT zu36;jV^!X7_258r{#YE0^YA`Ag?+JAJu_Sup<41gzJe|5lLI{XDz3r;4Qy)(=hsHq z?w@4pHniP;k02AZEcc7F-R}VpVI<|<=vQdZaBva-K=tkUMkcu~;qN^7FVvRl*Cr+@ z|3ra-jx5I%+=LHe`DP}PPoOHCg&JK4QKRTx?0_GjMp^0RezPjoXl`z3 zg&Ln7P@|y>YOa4DwTxcGAy~YHnFq$ADn0`>YL;Vl+<|J?yWaVaP!;$M6{$Z_6?6P8 z%`#aE6{1#H13RNGj73c(@u(XXppx@3)O>Id6|v)}neYs%<;CwXeO(51T?f>fG6;3u zRMhkQ&v2j?>_z3mQB;G9yLQTb;P}hw`Ww#&I(zVzVH>3LaB5Go~imFiI z)}}$lQ4KAN%z%EY8V96`#YSsEK7tJJX`=ScGyWs%sA8Ogw^m zV7K-rl7moldmJi4(@_z54C~+q)EaUGLp1(B^Dg)n6Da?T%Ic^NX39K)k5UfnXj?rP zj$2TXxzx#o@(r~K9mF1I3AS?vryT(5_LlW zm1LW|^E*8=QIUAjD<4LUo;OexJc{b;_dQRcp8FN50_Qt3{`G(>oKUE)p=w^Si@C5m zs$#WJ4{U*IX?w5S1NDM?Q1inu)C*G3!3R*+Pe;9Qfp>lt>OFxjjDID`I!>q}Z+aKL ziwfPRsO-Lsy5VoHobOKa;9{tXRY2YELsh6Q>iWj0+-QNSSXb1r>xr83hxs{Bh$o|N zoQn$eVz0a!Rr3v48(%=(c)~k>3H5^OIGrXJ?@Gn-;k#_>8**g>38V48u$PHYCMx;f zMZMSmB?qeMMbt#`D{8$j(%Xc-FDfUZP&H4(dN|)J??6@XASzOCqbhh36~UiT_l5K^ z^F~Ef&NV^S3cqzH2fAPosz$M>DR&a8LQAnDZblz|je23hyUh#Apeon^b$k8zGP;&}j4Aqj-RFr)b++ zj@R*HCW_@TjQ>|TF@BtRQDmH1?H|W{1-r z!?`XEt78B)YJ#Xy@)?f8?@>9?JAv`9MdZE&+xi^WqK4trM3Z!LQOobEs2e`R2Kb{_ zu9Rdpn+;G?_bAl$@8T194#(pBN#?pAurB5BWHW#C@N=N79f+C%@5e?s9hGFy;)A#w zH50Z>F{7dfYH=EcYj7p1%OX-umiuuS5ju?XIbUTm8yMz=jktpH;RnoK(m!CTZ4K1} zP~-A@)UdmbnkdRnGc)9EIFRx{)cI|w(QpubcnZ~3f1$3cJ>7Qyq%#dQJddCzwokDI zo=3XMZ~11J3UtOkeDDM=!3#J5r_40p3yxtbWowpcVH&DWgLpf>foi~2tWL{|(Y2~* z!`UXOJ7Y)6dr^_Tf~__FOU*G~h5De@0zZz#ZFmnBo@{J=GBLqm|nn!cnsAAf1oN-a1rBQ7esI%yQ5whgUv7z_2A8@ ztbQ9+k#A7f{o<9cdlq}x0k(deUm(DO8^<<=wSIgxlT#Wom+D<2_AG|~LUi3!+x zsqOyPYtLXW%AezHc-u1bd)**xLwO_W{O33xLzbKGgsHfSat?OEF)K_`Z^to|e@8WR z)JoHk@qP~UqUoqEcoXl!FHyPBAl=MleNl6GBC17?UP+s79 z9P3c7@u-Px4~(Mhe}aSV99+Xlyz4PD15QTul^<2HrKr)d9h=~Btbqjs=E3z*5$T7j z*mTtATRo4VzCrznTAquocJ*7eInadD&T|0j#yISYOHkSRDQY5d)|l~L4wVZ%Q4buB z`fiwliqvzciX25H;T6;bReY_P!ds&I@Bc(|u#^vyQCVs~Zdx9O8oxeN7qvsZXc21I zZt%+CPnZd%6>7p6hz&3f)#69774AW;F+ZXr`p!Bkst29tKrbrvq?tf!p^|a~szs+U z1}m&L6`P5w=n~X}p1?OS3pE4!pE5bI61!1;7Zt&>8%%DrK_BJ&(62t8#etIT5!3^A zp+fr_szoR8KD>acX!ngK7h+K@TZYZ?E$oEXQAyfqles?;n^Jxd)u15G$DcMa{uQdp zo6QFsQCWNn8)4Wq#q5G( zQSX_x#cx`4kQ2)4Q>eZ!w$&uhP*fi-Kqb}FsOw(!{1p|cy4y@{+<|Q=_d`uwk6=e! zi>kmW)N_2>&2G89p9A$_cT`uzc;yt-gO;H?^`esR8}IXLs25jymi63bp1`q`4?Jgn zps4-4xqcS5;rwHs@1veqe21Ch{q;D|iThA}G{q}#M=g`D;b1(C3VF*HOjoQxRp~ndbMu4R|l*c6&`ER^b|ZAXDD+x?HxH)9IttL(Gge-Uvx_NIIq zyJ-AJyl57OBvhY&h@G*_OJ?{C#&F8rD)Nt943i)5CE@*JjydW7h?B<~!{2Xp` z*r;GD%1MW9YavOx9W|fa{;H`+_g5MJ_vl7W=)x1IMWn=QrUI=onsO4hz~iW{DDt|g zSPxVSQ&1DvgQ#&IKy~FoRE00%dsyuacn^6sT z1J&ilj=Np#w?=VLofD6uvOg15^Pka&wNIESvm5HcF{saH;{wdUG1&T~Y0(DM+VLGW z!O9<+3iLtkh!U|A9>BU9|JOMv%!#@m+1CBo81;Y^sG1-4{L-_)$F}tX=j-D0_#rB1 z=6_;xVk>GCoWyeY6BfZjr%Y~?#k=DCUJ{{4Ri2O97DP{ZOPhGF5)O`lgn zirUyb?70IqW4@2N|H@a4 zf3>*a*XHYRb<}!45;bwmMCHPBs2T24yc-LAW3C&B>Y@~MlNiTQ&O{C4YTugBN8xzN z>8O3cHPqiXwUIq{O` zNmK(bp}I8m0=Z(d7~zZPZ~C2W{m8)&-#h6^#`7qgwt#B-Eq z{K{`TL@4$*W=cGM#kQ`K)y@7eKeILa%dGEyypQWIqt<|X{x*NGT#VtA3tclaWqovK zNNlC?Kam3szb)PepW%a)|G|0g7;!7 zDk+blM$vbuT=^Zf3>VDrSbp_MWe#+r6{@Cpqe7H~8b*t7Cay)zY!wO^o1*%&HR zz4I%v3+3lf75E7?Y)ck&++p1UeUy6?bol@O!ohe>+=WX~<&)lpS5Xx#R>+)hfO>FW zR0Psdy-*E`M?G&oM&PExe#hN#WOG6j!=I?t%opOg+1?%X z1!X#Fyl%locnWQa6u7s{%BFh>PUHDF~{BeJzCsxe{wBWg7e%LhkbB64#MC4 z9Gu|b?vjqX4A%;C+^P0KOyvA&9E^QSIo1W-g6hkarAm7%IzO zL`_6FUb$Qq6S;<{F6xC!vQ4Ndct3W>57Eu3s^)p!QMnY4?tlOL6bITM?8Xsz5PM@~ zey~*q#-UoW1Y6-|)Hwef2jW%iihZj)?k}W|qRxl<9C!CT3WrmE6+2+{aMPes=dWD%7A`?W?gZX}zhWYh=_7a&*J68|RL5~w$(^XA3Zm|R8C8K}p5LL8 zw@6)cz9Fj5`=BZ|7KdU+UB{gX+^Ys8H`gMdm8%ffehU zK8;1aXbm>P3{+&l^v+*Fb!FIXW+trf=Ro7PEjGmds0YqQCC%HY2mFGnP~iq9^rcZP zz7w@;j=@8?!80ntakpgQ4NWfXLru{IBOP~w;1>n~6j{DrDegGOffbwaiL zUQ{F|;UL_H2W^I7W5@l==DSVI^&g;;_8h9hMVp%BE{_E$*F+%Jn>ki8cECj3jmq*G%^i1NFbjQ@A3-JS4%Bl`qc*A6 zu%*U-QVY{3TTvl>89U<_sPSC0rI|Y0qF$VcK3t62$vlTs@F1$fE$%R*;x5$8xf~zH z=TQ;4qm{{#E|T|K_i>=4n1ZEn8S2FwQ8&DV>Vh}C@)=Y`zW2(7TbmpyjatU*Vi9bM zx~@Cwc`>L_G!qqx{pi=!c!~qn{5&cGS5d25r8Xv%ZQ7a|_wN z4ZEkkSxq;h#`!zA27MhI_m565;sca>cQjr8dPl~8aZbF?33&#!_xlN(<5g5d8h0{D z)djaw9*dgcN_I9cip5Tpv#=*#_3YZk48!FZMMYo6!F*n$t7DDEXW(zh#Hn*J&koy5o?D%us7-jkDSflB znRqwlAS%NCg1t@4+oD2vk5^7dwPY(Q%U?oG(eI#I_7yJ2{C!M?A4QG(jTnP(ppv)2 z-KJtKP!rlXY>CUUwZ{Ju4q9*`Utja!)~Ff|Lxp@Ba-nqsV13+!s=(*io%dURaZm%h4l*B%LoG53P!E0^ z)xvYw46mbdq}jcW)e$>+&P8?MtDXf0n~L0pdf{T!=-7suf`383lB(VilLPHhEgyhQ zaTa#KU8ugjgo;qvp(doQQAso!HF{Q~CYt9_4R{0Z!J@;==Oa-`nu2QB)5945>f^(l zP-uSeEI-_2e$RA27EUib~F!c9k*=ft9h?K0F%_bx`@52)u=y3bUg z-F=MzR-72h3H4n%-hn$&>-c%BhxtaDc_IQ;;Ss0`EW}iN83$wYQDzl=1l7X*sNDGo z6^YyLHxZbR(@zcw++>mZ=oV^8Pydf#+nA)jmm+U$aQ|}1rF}y#K))?RvTxMZ8)k+{1}Q)pdzvb z)$+GcUEqr|FB*cHQd3dcy&pBYe!|w6G~UdF&!XmuKhXXDA3DKIsWng)xC7OqG*q%Z zhvjh}Dw%RnIq?%JM=Hjf9J(EK-9%KE&PPS^S=4pMP^0B*RM%9VNK&eXtvFCi#-KiU z5@+Ck)QznY%v3x8$Lcz4h3}%qd*MXK+J-$*S^f+5!|){2Wyzj5Vs>VlLM$Ne`O-os}p4^B1nL%uY}{i9MOD!a#HWt(J0ZDP+%HWe;0 z#Wbu2s_TYfJDh>~4*B8~#=pkx8BVB=$~<5~)E5+*n{#bsD>5g`HisyK8#7I8TL=qYFNiV+YGzAQOT8p8s{rL59585 ze?s-`-E+(frlTtMI_mo3bB*n=7v*@=XvjhhKWm=(yaQ_ZC7>$ie~JUOBnJm!_XkZE zJc$|&$5GiHHs6G-HL3+^sEx%_s2BZ+L$JjHZ|X(OjPIg`ahHe8FuotlQJ#Qwf!~_T zK{rkuKn=J23r$jmq53u+l{Cvx^T0c(#pokcOa8)=7`e#2r~@i@MqnFUg?D2%hGF%G z&Gk*t9siv<(40RE$74Eb{9eO?SZlH4CSLd`LEirvQ1T_yV#x;zJ zO{fUATk2T+WN3{+4cGgZlLP$zY=y3H+<)P?=}O1?j1Nwu8mIPSmI)^n}n{#!2JqH<)$@>J5S7fS zsK~zm1mj<;*2kQvgg>LQy4X7N{r+AIr#uSR;2hM0D?e!#rFvM5at|zpgHfYsJgTb} zp~iWZcm6W6=vbxJo2^;f^^8M(dGvEa!{H(->3mO_KI?*da6IaPe)QoE@BDkHh@C|x z;WZ4!5*y4ET?v(}15puvz$-t7$&_F8a}du#!;O5M#K663U%6UD&8BfQl5zY&_C{3bFdFJTtc5S z5$J|WrZ|knH*g?EJa2Ztb1;GOIaJ68?=UmkWYqY665qs3)P7;+3y%B8r`@O-(znz7 zO~`MJ=RisGBJRgRyUZ%}8cwHNZMS1Rjcai;cFZs>`5INRf_uzxjK>(tK|G07GU+lR zco~;dt`Rg9%|PYY`>uYHDa(xi7*yY{!<>BV=})6V595=RKgNyt zz(MmZxAGy!dWZ7!I0mP`%+`yFoJDo*h*ub{Jiq^I{IH?%KjBR#7)(0CT7bLWa@@c9 zo;+%@wBs@J^VvL9C_lj0xUTTq=1b^1@0i8wGu+LCub?KN?KvhnUqOwM&r!?x_oy$g ztKVn*hjXx%17+z^tc^cCWvu30TWR>p;M?n{}l`2@Q)n#KRAfS4=ImD zO-LO+Hp6cQZl}B#HSVK6F>AwO>`M97Py8n9Lr$5#?~3|h9BMr8K#l)Hs2S{i9Em@p zDs<1MX1Se!RVW8hFFKA5@ER%tw|!hUiZ8}e&y0FMuGkW?Xxo`PXI8aM2 zqjI3wm)`P-S}r?d861epjX2bdx6CU)i)wieDpEh9HX!y_W|8TGJt;qcQ}I=thwZ+0 zbH;BS=0Fd)jtXs+Zyf7goQ~_U=eMTD7qAQEBInG5`=XL=8jfc)WTEb_dBKEwE~;Uh zQQ3bG&*0mr9NY1o8bS=-=0GRTp|ZN*_vQh&qe3_;^q2X+5>&%ZE|a*1CV|Eh8OU(Ij4gHXve40YpV z@BC8Ki_@_qK7|^dXRrqTjJmJvZ>GwSPu(cFT|+o3RtVj2f=a@8*0nRD}8=f1mOBGA>0Y zg!ua;#>9=88kaEEmpU#sCDxailp2?qkm8GuO7M+Nj*Xh&i%yJ*iM$0kI_ zruarr^^Hx8O9Z(Y9Qn-~?{ zcR>A&=N3NV)Sy8V(-PvMqf)6%T8bW#k{T12&?qr2)t3~P6q}M!YjL3hcko$kjOv(@ z8kL;t*0lG4e!i60U0C41fBi%v@> zT&cV*C56yO__#YIH8ClPD(PCTi_DEmM$r}Lo&0)W`k(1dT8&BLnbEQOM`A)M521%l zped&$#>e`mjEhb1C8s4M=xwHxxjZGK&Z?e;8Yjm_Q{Ab)q^Q)?*ktk{K8~Qrxb=^H zAUd9i(bAN0iD~gMYIk5qQ@g_LDb}5F|I?ilqf(;@2%*09Mmr^A;^SRHszoIyM@{v` zCB(-i#9CdccCyc$N{t#FADgjlb9hMI|4W4;Q><PCnl3ldFefU zraF&*68_0_hXz@sZzvDfNQ;V3N%YVj`5|d+nDM?gqOkA`tHaR&l zIVIxXuTo#8&{JvTKUGT|M-`{f%y=-fj$JY;Ej2MEYI1DDsTq-^2 zPob~ra82>Nl<^aB%WK_w>)r8DX$d5x(pf=wXJI$kW79NlHE2`EB`2nhHA$;mNNZBr zq%|v3T9P`Cr6?uRa$l|`F1_?yfx_K<^#&!xJy1Vm>sxi5_PLpxI~OM40 zK(i8rT&Uq5WHh-@${wu6fyIU{ie|auxs1Uvabwex-M+EfB^}+hEHreWCOMkpu529UuG(gUpe&BpTF*=r8kc*s znLxeH)AN~GQ@y=P-c?$i37Gn5Ok#X|;uN|u{fGFFo~_wKr6f&_dwA~3 zY}(4QkvB>i%d!yY+BQYU^Gy*dtV>sSFib!rn>8wyTa1#?jk$Q9nr~jFk@R=C?&3@Tegz9x#5-Ho@I{dC(X-#BLQb~UEQrzDL^OOBse zqkZrH(w%J^MY(g8H?#Pn({Ec=-5*D~bal6$k-qL^MT9#wD)43s)XbGT3*0;lY5&0X z)_WH@MOcy<1>D$gYU()cMcJ|3)Q`EZO-Uo0#>7UYrqL*06+MN^3Ez!pu?9?Grx_W{ z@7P)S0)qT^L_4Yl{faiN4lGOGgN3xBfg~M<-0d{bK4cgxOT@CJ3(*j5FQvCVy~~v7YTPL zXojX1x%+sk7`Zpp{>Rr&ozFg2Yn3lH{mEx4`W2$Qs=04dy;}R+ zdmDQ<57g`O?$eG_TS1l)y->Z7n*uklB)MNA%_vNW)!re|`##89C%Uye zky+OxLqqROOy4!5TyS%k{ZfJ8sfu>*0>Mr`dy4IkW_!U_felLRl-!M7ZU(y(yh-ug zx2D9#yZbOT^{Nz7MN1wvm3%_{DF(bK?*N=ZqKj?*?d_eD_&{um#d8GSKHU{{ws z*4^7E-`Ek}_*Rp|t)|uV61!hF&(e}2eVx4hux6uM#-dvbla+j#);?6Lz1!v5jcO8<9&3kU1#Ix%dmlz)t>u0-tW7p~Kk=;tV=WgtqdD~4bx+zd&;$r#6s7l_lbtCl{ zJ?0)J4z0x6eNK*wPxI<%4dfeAq7{58+#YE+kDJJMCU!*TD+66MMt!2HvUsvF(Aams z;PPch@ru$+8!TJXzM8K&U)tHYC;oA{bEZr!YL;CwT?n#wJFO`EiC(jpkv z+%6r~YISxndw+Ij_5t_*va&OMIgjMb&Cbf1@5?@zeJE!^c2@R2AD0}=-kY77GuOwJ zJ9B2|%nMZe%C1xI)}LRpSNgbjXZDL+z2BEJC;O$Gc{vYpA~Vort5d!rXLjYx&OVqk zCua#4X8N*s^RR{4S%FFS+ogMYV=%&(eTY}hrbhd+_v9?&Dpi0w<}AJW>rwV<&dkY~ ztJ-*<&&^rPZ#3B(0$)sUY6MQVaLPydviDGpW!|%I`4+|7d8{6heNYuYn4OV*Fv6Gf zkbAs~XHnsU{O_)uMMPw7&Jx9B7ynS5vv<+X%t$M6&j!0nux&T{&$9pesVCTCguSIo z-fum@Pad#~6e#GnJtFXK8@nQveJMLj?@@zy>z^v@&RLcn)awqp!BQypxb@sk1@<$3 zy`P2xhs!vngBNDnS3|lIwk&St4%)kf+vjoDe&S02O$d0^LF&BI{rmvOG}lM}@1&pS zaVsAME{(KH*Hb6cIeYXdx}6^P5rf4h0`9eLg!TnbJj4dUAK(dY)d`C-Nm)dr+^Q(R zi*B6V?Q{Ee4|lw%XV6@)ayNY)%z2QH_qnyD=iRF8qBVP{h58@&=gm05R~Ffm3j|(XYF7^1?QJW3xm5{-Ew{@C zrY^JRb#Mbrtqy9eC_nbNeYclaYLswt89A@qeNjQ61sV=yD*vQo0)2Kk)k>L5GP7Sa zpArwfp?vVKW%lRA{9D~7s<#f1gz6Wy)(r?LG-oljb)%7)ok4IJB1DGQaoVqH)7^>`gI+yKK61I0{lC?W(KAo=b+hV#`#d+1 zGy>fSn1ohqcGDj3*`#h>u2Jbe|DS68-zpvaXq{cLRNviZsvrt_sT%4|YdbkThpyXg zWv^5cQxQhGdQo-UrIg88kTXvcP~N5Lf*}8ycahTn=&9Un(FL^0o5koG^^<9>UYr*Z z_xwNl?;o-FpF=@U(M$jC!T%PEe|F%l75=|>-N>9}|EK!?AFCVK(2eQsuUG7>l7X#j zoZ`cC=DP#m^pN_-O;qm)t#t!a_Elu>>!y(Z!#ljba*R{*0)2%E-PmRT}yuj z4nN^kh~z5jILG8_25->vbkhjXPo{bjSMi-J5dNxDO*JcFX9nI~=36U@5!CE$$ysNV<+q?j62s$i2Wifnz^*6l%4K-=ensiVl$x_F zaN&eqDR}UN?RWgS&F|5#A$RX_8$!I)F^cj|Q%iSRqsRnLaCuyt?%`%-7;z!<=DAx2CErqaJ#?2|CPUwi zAm+^bUw-`!gcT*-ewgM|?swxZ^_AHy9iVH>qPyFRyf;p?VjWT*kazQl;(F>te`#)a zdy7nTYwW+N2^s|dsrm$g8hvAgx^t!1uG|K=iRHG?ERl*TAz(#hx9p`~(0v+R>P1MK z&;#WCQeDE8f%Wa2@^)l!*%iCEUAT8Q^Q(67iQn!0`2$0DJK;sO;icWIzS%DZ=49LD z1H~5El>>>pof3hWf7>NXF|nEi4>FG3ZzjR%f7@3Y6ncT}?qY6g+}#cjB>9{&;f&cE z>pXdrH{B@F^!s)&slM}JzTkZg9lJnD&9GYO&36*&x+2Q1m@g|k7-;*HU7<8@b-N+^ z0Gm5^&f6Ir*3>y%I52OWU8-;$_or`7py%w$c2?k0Tc>OwvW?R@uwqk0s}27hhu^swFP2Qp9E z)q@>7I=e!GWqLYamk+!>+$j;r$=%s%ywcO^kF3^#e#2?} z!)N(QRDHBFKYyW(>P=oAe$FmkE^p)N?#A4q&O{L0IL4WhFL3NTyN2KUo)#LK{XD%v z;BVPHn<+^%fL6kw{^{*}xzgJcvvZ|Jxx>g!LB1lIY@mzhhmxV@>l?FC= zTh#yYyM39dk^lbTK9JMhE?>}{)UpDHTH0ml+ng1F@Yzm3wWcIg6i(Bgd1)e zpgVK_6p+1^$L-DD6PP)R_mV?%-muFRz4?y-?ssq77g%z?U3q}n;m@O$%iQTnlesp1 z^UQa9%|}$i>+#%d7R|#9;^3a7IzcPOqE(;xZ3^xL^YTY&Hj<5+_aaw zwI_4uxy(Sv_v|X6>~Zz$jkc%MzKmP(a{6n5_bC0vz^i-Ui;wL}4*NUR+TD2SB6kD) zuPQUq_@+0V88|=QITNy(8uA65wN6t_ZEo?h-c2Dh*z|QNH;}A~;Q?0Da>36hIK}c6 zG*<^Y4zbJnegD@#E8Ox64dL2F?&x=qOol5{xPtcY{9T$iI%uNle5GGzPJ!9l6$L(@ zK_nu5fjRHn)tCfsRNg#Me-Py*d8>#9i8tS{1!kv_;Rc;az+H%x+NvgrwLkAK6q?VN zac=r~DK}{P0+L}WrQsQv?sGy4nzjUT((Ll(Z~Q2C^BW?A7ZRM;3kF9_cCrifU!J!a zr(H}IOmf;JX?WdSb9aYses<8fbTfUwyYE$-%r=o9?D#A*&}M~Ql}#D9D^`c7zGf{g z{@NYp?f&_cU;DU0k-V|H{f{%|zw2_#Hv_6{W&{%&ew+!EF6vY%>}~4z2BLa7Wkd7c z93D6|!>JkQGs8JsMw5kBg_raS_Xjk7R~*^jDaW3p;Y??6NTE$;WCgMxw=0I^t-yiC zQ~9m1_Bp#mO`73#iT(h=j!pYL{TxaI6(YU@JobQ7Da_5U47cCi-$x3~VUql&j!Y&7B2ve`H|_S(?2o@WLp&{CL96V@yu+KKDnG z|MWvvZa|cZ%qu>PF?EjCAljarPWoXilUS}b8LmCF`jO!f5!mp7T_(`+RVRNd9+ErF z?$_DR~PWi!lA!F(0@us@m9K)P>G|c-EKlRT6yr1U-KV7t|bul5} z@h>qR+_cbg|F1ujFOM@9lvST)7)`m`U+e;=u|H4Mclkj zD=)hWRz6=@s&Qj`#@BB5ht}ZAEzS>4rzhQX%2Z8F_}yP8c$XIm_m@)*KJRNx-XRl^ zCbfO$E4Y8VbG^{Wr-*3K{i#mT&2sx&-%Z?=K+W;KE#xeem90_E zl3QBYX}w*ss(x}c^St(RCQ5ma4bIr@+*>^0JkB297wY5>H2>Z%8TjgD=R|PQE6%k- ZHFE!kV73{V7R|c#)V|<^qt5;L{vV+TpP~Q& delta 29199 zcma*vcYGDqzW4E&2>}v%@384TKp-?B^d=zEr5i#ZfsjB7MUhPtrFSf`oBBTmetF)tY2_6wu!N#Q{#Dn(n3H@ws-f52@~N1e{7h6sOHd80cKr}l z&&QYp_o41Pg$40EW}$uS7Kuz0JO~ufbJT;+-F$GY8Cecgd45zyMX@}Vb@T182Kg>- zev<19*M+X{x~|6}v~PVxf>2l|u>z)JF?@nV-O3k7*l{%W#Dh2x3k|ibM)(?*K|hYc zD_94+4zsNF_%>E!>T1MW7FAk(hnuPX6utQ=_?|>Xe1hsgsRYZ)%>%8mCHbC-mQ@0m zV{Y7o4e&HJ!{8B?)g0TP?w^jG@f_B|(j%E(?20eoe5{9OM-qRHFmsZrU?}RsW^9Mo zQ4iEewygR%3{!9|Dk4S5JfWjeIGTK~QI-{s8K^~?i9$_LNz~$QiF!T?tK!Ac#J>?0 zIK)jOY%)Z49thCP*b%N!*Cx~#@k4gtpcx^T`>_=J`1%-{g?%hqU!w& zwM)MClE_Qq8mi|HF)LPl-GsI#DuhX>4y;ApzYA62Y1IAaU9Vyu^4Br24Y3pXKQRQ` zk2AZWJ8EFwXc8Ssj72S?xSMoXDVBYXKYE5(2>fu3DC@Wdwh7hI8>moPQ_La@ zMTN2)YHk~1O>B=^eBk)Jd=qKkYDS_i#-nangBsCSs1D_xYEHl+n1g&J zT!wW~YvOa%n)u1}cMK+<{Vj8_>S$H;YJ`nQ=!k8L%J;-(7>#;h9jf82 zmMT7{8dofz0er5l5c}q@MTm3 zub}RWb{&pY$&YsPYfuB&j8$+SDgrl94gG?8{vm2@{EekBavJg1KJGuwR4~hRIciQn zK#lkiYEd4;>i8RKgr%pOd?PGPekf}0XQI~1ek_g`U4KJXy_I{0d9J*d#LEE-$SjD_puP}cfEi`$a{Ywk&Q%_ zSu}$AP}`_Js)4>(ALCIYTZh&0B5KiPnr%8($h8FKpu8;VdM(uMYU$d?El)Cftv5+% zku1iXxCu3q-ER3Q%ufC)=ER>-4Lo(tJjaAIKUSf6X8W9m&UG2>MZr@sOK8 zf%$0P`kX{A{0SAR$8J8yJTo;VTq~o_i3n6f@u(@Ajny#?)zCH6$Zw+t^cXp_tkC)9 z`fSw8ZZUeb*ghtq#c>sL<3rcr1!iOgQ6Vdd3Sm{ZypEf1gq0|7g=#nfRnJ&dL({Ps zZbU8Cqp02S!vf;3?e!N0dC^{I%#W(5A}S)`s8!z%^I)9o>zJSXEcD=Ntcpiap}vRO zrjIc{=2>LwuYjtj!6M?X2ijAhMKv0=U*B}|tKA#7q9Sz|wU}z?5&}>JA_$+D;ze08BI%@y_hKk7Vs5#H%Gx^G{bx`*= zK}}Idyn;PY+qc$YGk~F}#X1+&A@5=m>d8J-2fjpg=qEStEHU|9sOwcx4b{iC*c#j6 z9MoKYi8@c7pr#;bskuKCwW|uF2P-0r*lV?QZy1V2xG)A4f%&Kgm!o#a`>2X{qZ<0u zz5WAgME6l^6PWIqLp|fXx3W5^7+)dto*fCjSl=#E@~>#-tUMB29gcFUK%YrcX{Vr|Ncy=U^BunhTCSef>%qa?I_?xHFxw8Grb z1dEX$h#J9oROlz8Iy?^*(p9L%xD~ak525ZqkHzsaDsqpo0NN`}c_H*F)OAQ`wML>I zY>wJ4ZLkpbMctQzip+S_bswsOAEM^^1gc}_P#ygiH`=^@QT4pDn!^sapr&Tx8s=XW z&Rk=9v<_AAVb@Ejxx9n5F!Ne-fYe8AtDcw-hoVL}9<}Ix+);aV;ven~+(tcB2}6WxeU(FjQzKU`KoxOW|$Q6lU9Cwp(pfeZ5cv z8Q?nBb%EDSY{rV*a2!2&4^?6Ajb^pi#FFG+a`VGcBbRnKnJ;`Lr8QJF;6P3C91aLh_R9u=Zg)B_W-0|Ag7_ zH_U?1Q60*%#q64rSeASx)LQ6(idYxSP5V|a5^8uTX2RD{q4Z)-oQ^qg35MVXH@^)P z%7bqCCDd-XhGF;!Rd2bE%s}d(hkPqk#|L9z|0k1BkKaJ8g@ve&96)vC2&(62u_Jzg z;aFxXKi^?@%)&ljf*RSXZDy)Ax*o#PlwWcE3l*u7+ljwMR$;p-sDo;_5$3{Xs5$J6 z8fkC0JPGrVpMMGG{WwvhWn#N zmgwduVlDFXQ6oKyiqL1Mj%B!BMb&cyb>DrQg-`JrPTgq+IOSv0!KJ7Uc|Rqgk$sNp z(RWx7Z@JojbH*1&U9W>`AQBac)~JfQpgJ@Fb>C1_M^jJ(8;e>)(@_JQi?rjlR+G>J zyIqf9Rq|(04gHP^t-Z^b2erRTqo$x9s>1%L4vfGC_&PSmoj4wU!?BpOn=-tEpK1TE z-owWPt2c44IZz_@o7Zta)EPe&>*5+zN3Wnp{v&GS_fZ{shD9*n0Ta>c*qHnaI0?t1 z?*9wN(O$?w)|mGH)KAQ!Sb_P-uSbn&4|?!3ER4UPLYVoGxj#FqBl%DfD&>~fz?|gk zqNcW$TizAbf!?k|(5n!qkkFiub1y8wlH^xnemsa8;TNb7-$F&=kz4*8YmzT;*mS%# z>iR3FjtxYufh6?cWK^Wq9wz>}aKOFcoa=Y4zoI(k95Ev)f*L_ZRHW*luD3-+Yyhgk zk*JPO#6tKEDk593Gw#QpnCmF<=Pa;>9Az(J(NE2s4Mr`lwO9pDV=a7)uVSTR=Bsxa zHXwfm>Kh*FdZm{8gLaK_8=z+bcIX{Z(*$)_kzo9CA zhFVOyPw^3gB~c?Aj#`{4s1VOW-M<`z@gr0RccRwX9@Ib&8@<+95~}D5>Va#h8*iW< zyoU7S}CQ zhn`?%{2QxcrO(U=yP+zM#&Vc|*>M5tU9b|hIQL>Co^Nd+T^|1>1p&0o6{~{6^ z*+=e$-KbFhi0bKYm<^wzD$aD)jJzXIMj$|U=G}bnwnjx zHE>}F(ZOn{8*89KT^ALRL8vJhfx0gRdEr^3-RlvT z%>B(Uobp7hfvZs+`W)5qOQ?bUh`DIr`kO>U3UXdHtF|3##Jx~c5RHn=NYtD!#N43mZGHR}ieQAD@Du>Fq#22w62LArHn1m`?iE3~&(lcvs;0DfqRD|+G26N|2vV;qKZe2WHjo=iI^YX z!mYU6E${f1xxcGxf9%NhI9!i@ERX%KnvPCEP00dmkH@eD=KY%Zw1vR4D(%O4#}vbFw93aq>%1&-qaY&lOZ;|H8$X{afZ=tAE|M z=4iZyYPk4!CS>KXF!}1JP`1W`*bQ~!4MR=ML{!J-VM}9G?Bl}SeeCm1;HMcihAEFwtzc)v7DC)VAsPYO}0c&72?14pb zvRnS1mxQ+8r>MEVgzCUGw>xFuo-qkEy{OL4X;Cucnj9T1E?v! zkBZzQ)P3G;Kbj8Y$9xo2K#jN|s)F{Y9``{l#!;vWX5cHh3f1r*m{kLehJkI{qGelm04(X|(y~Hx+3c!(=+&aC<`y(SHPi(|FxE8*)$lmi85l%I@q4F_N- z4#S2x3KgMkSPnly4dA+4{seV@=3mT!@}Q=$ikCzw5)Dy{r#H63Bvi+Cp&I@S^}t0p z{}XB?_faFydDq-u3Uyy?R76|2<*#54^6{7*U&CPZzC}VkorPs^1*+l0sERM5LVN=? zvfTH~zXymwbzmCS!VRbqeu--6M^vO9pziw{HPGVs%@j34?)O@)NNDlAjOuZ;n;(Pf z*bFR&@1h#m?Os2MxyfHbHFOiz&_g$$_gAxfN}|?K1gc{%q0W!Kn3MLc`6RTOSD{w# zI#fkxTyJA{@*xl0xyQ2PhoL$)2enNj%+Gw*F3;T+PCsN zG{2>O8S|0<2o;GVsERJ*CVYx|S*>|wLbxBbW{$b}Z?G}>8>o?%e{3RH8*`Cwg?hd_ zs{TX_{QZ9l35{ef=EV=O03O7gcmez3kJtd4Juw}99qW*v<9ZbHkpCSO@@!8{N6Mk5 zE*!PS8l&!e^(pZ$Od^Sb4mbk~;bp9a_fZ`y^SfEKk*E;2M^zAqIuBk)ZP$0)@~==0 z{(>67Q`anin9vtQ4K(5p;;#|3r9c(Ff$G230_fQRHdS+hBrBIP8hl)&d^q{vR2^|Cps2-)Dj^N3t24gua24vtsJ_+oZD@HgByP?6b&+6`ah3-}l# zFg%CpSS;#5T7z})CTdNU$mxb2bSZBwj$B z1Nr$kdE>DsCg4R>$6DnvBkqq{+~aT=uEoCCG_UD^_gxazDcFfx6hEK`gY(&e1Ed1p zB(%*?*Q*q;123fxsF22EF`SM)aXt3Kzp)ecEoeISA*$oW3Nc`Z4;?(L{eQ6tb4&wE zirUsfTwBbxW^!YL5_aG(7{_oa`EDgmWbPr4SQSdyfzR`%9y{m!I2|b9^Sg{$&2?SdpelL=_28?hMHz=$3n{2=x)SyJ z-h?{)4`4Ywjat0-QB(6bYAqBhYg@_M|5Zp#!S_(1EL+a>vLv9Y+hC(ACgM}P zBm%1xD|2BJ>O}hjhheL#rh#p!hnhEeVBY7wW z-D{cwuBc@qxdBzrR-_|dYcC0f{1hrw>2Cf8Y6QQaLjF5y*fh<%9a_;J*IXHaY8 zb5wmlqDFcLweO#x?#o@<4*UkS0ETM+7bc+w%DNY7p(?I}S`!UX4G%)yHw+c2v8dHN z7j^$qH@_0q@FrA8cA}o&j~d`n)ct4BtHp4hgnITpYMb3a9Yjx1A&EpI1P%(+B0G!v zqPd2;{s7e>r?ELwbD}y_6`Nu+^xz~^gKJO?Y(sVM1nT*#sCI6mrpEF{nufAsGx9~; zd>?#`e7u{#f$G>(*KAEpzNBj{)LGvWHP=z7NW`K>JP(KCX6%Z^n%dR^^d^#+K_aM` zd4JErhU9PHZYw&L+B@@-+YYhefeTHd>*Z5^fD)7lRFp8q=PfGW_&4*ay-0IQIn zgMOadgD;X#;!x0(??=8Lyw)ueeJIHKqHRsUL8t@fI*!5~?acvm2D^}N*1>$^EyOY8 z|HKKH($Tig;UgS}pT1;9+`N-*%^|-PQ?TjFrrw>nU;DpWXWN>~g5Csol$bko?ixe6UpRV~*-qQE%5tsNJ#} zb87#eATaBVvr5BICtO3Uf!$Gy>P?)2Gf~?wPn6jOWigz516+df zsHw_4&@Ad+NHDBr_$K9#qxm4=92hr<{XdU_WrNMfVf7)l)k_tiR{Js3Hv1BFF5E+% zh?!z-s~d))$|s{%|6=suPSg}#MBVosJ7TvuvmIBX4ziEqyk?G$P*9qJKTsVgKGZxg z3TKi(irO}vhnesBb(li_3O2(|@#dtPhnj+wr~!O|r5O1QR7bNUn8jQio06aFC81Df zptju|tc4X5%}9G;fAW*DGk%YQvCatF8i~tXbByG5OWuob;Yrjk=$vGB(^*u6nk1Wg z`=S=1cPa_io%JDVuCu0^FPQqM{oDt$=tk5Uc?)&I%}1??m6!)Npzhm^T2qHmi}Yul zjenpHqG_Yd&l~HIDezk7NvJ2^p>D`L+E@nFU?kSTcBqQSqgM5LR7XBR-FMc_f9ZMy zwRRq%<~rLLb0inWT;ywD;P-z`NW@d{5^8_$4P4-#V53&?EmQ@K#~NQmjjSh5!dPsD zw^0XKt=G&VoaMR&t5bdsRnIf*fuXP4R-E>K6bT(Hr*RmDjI#s(J)l%#$XeNmA*f*QbC^s0exNhtJHCz!9(HmJFu zgNjHRa#maCQ6u>kt6`~$rXwA&3Hi5CyXXX};ajftCz)@`si=r<#wfHVv;W)Tz{%#w z`~Y?0T|s>~{DA7%1Ju6HImPUb`dEhiP^^HnP?6Yy>e#pL_3Uq&`)XiG%DbRmzaw3z zy~)V6NLEoG{iur1U?;qfTAWR%nuB97YJb0m3gH%1g$J=dUP47G_gkhTVW{tbKBxmJ z33X&ILk;k_mxNa1MYrH5)INQLnwq?Cn+E!$*2*Y1{}^>{mddD#BT*wAiVbiY zDuPE)9s3ql|1%ti-lEgZ;`57370z4tbw<&HTk2c zh-I5))6qdpWJ-jxn{Ap#~O<13=*n%FV@9JsJX8;&%6u9p?ZE1)$n!H zTo#*e7GFy&M}8Q##JN})FQ9hCUDQCr7MR7`9yPUZU^UI@NfMf~$EZ0fu+ZFC+cgFi zAs=e7ZNx_S2?h=nY)U?Kk?BBtR6TQ1U%gvUQ@0B>kT2Z)59n1z&q?S=ZS;<5pa<%K z1XRP*kwII_a0vP8i``!&pgMFHU%<>ujLlK?jYl2bi{1Pg)YRN?^F^1k|Fw$iEH$4{ zov;`A4XCNGmzf6Yqk8_b>i`UN5W^^+jx}*Ns^MFxsmiq6yqp@MwqXa<^=Q<<-dN85 zR}W{qHy&|syntHGcTf$~e%EwhAnL)9*aIh_cEOjZsknm*{qLv-3%zGL7KUnP0RD;# zu`y2dt}wrsJBq6C)s<%Ce*BL7U3>-4t+E6EMTAPL?ZAK2`8Xz1-eip(_~&?O*nzyW z)|~yFa4`7=s44yfn`6CoW|zE%rOA8exCPr>kD*rcSEzk#tv4gDfZfSPpdv5>^*UaT z`fxdl3hi^$nkcft98k4TbKU_pC0^7~z7yFUUh7*D3UR58Cgd-n8d!wdX6sQEpTqYZ zJ}9sO`2`=?*4r$~GpKW;$7a)!VW=;q^{D%vqTUVRTTBP~VKn&#SXcZ12?@) z^O3)aT5Nw|JuJD;EY=?T*#ByH4Fy_Emr!&09JRmm?>D=lCaQuVs1Zy<&2c(vQI$Dh zLjMYCwU0q{d?$MFCTd%T95nS-L|t!j&}&;$NsOXkFg`<#XwWC-Yj_LRBL5Al1Gx{G zFPm^|Mt%aS`o(4x8tuxUP3Sfm$QCyd<<3avU+M`~}p((H*m4JZdeB z!2-A))$j?_w_NB^v&agdDlCKQXe-q9A*gpt5_)hR>criSs?U3dgjVej?uDRF&4rSv zk=McDI2cv&7pM_GMuooQF|!N0VIK0sQFH7?tpz`-BYBUTbD;!kS45ys`@cDfkrZ6P z_1NJA4LgJo)zk8)&B&U%Mq@+Dr(#z;ggLOlXJ)&WMKw?tGh++XZg~;)4jF-3Ti$@| zzhxvUP~b=H`)^QRDED0RoiQh3JyZpKQSbL*7>QF*AwGgS$i7F-eV()C0BeRXkspe> zZ#OEEm$8)g{~sh`vFJIoZzrQde+-A>Q`Cn=bh_E58?iX~tEdtFj#@KThUs7-)Bq}= zj@lQoCZ=FNT!)HS(C6%bjVKQZh1%o#0&0XkP;;7y1#JG@ig(d>-n`AGUNArT>_s(b zUo_jQJnH0p1=a8{)Z1@4Hp2{5hx1)x|EmL4E}1!NfZC_6Q4J5mTsR7~h^C@KJP$+g z6I3Klp(1nx)o``TCK63hC*ez&1?Qm}UgEm-GW*{{!EyJ-o2Zdj{nD(~miQ|9zW6ro z$79&~3V#*D9AELP79#Q(2Vl(Cw)LFF`{_64w_TrJGa=9Yz1hYI_&U#R#L`&K`-9m| ztugRLgWB)YQQPaNTmA=5Az$!E+q#U)P_NmP>+BvHoQZYG|9!(mFyf|Zpew50RP2HC zP}}_`YL|Gk{_Jia)X`fOH3jwEd=FGlhoK@f3$;tuV-fVbW}rfQ8#P7FEpxpVwjkdg z)$knDcKisLDz9~cgolEgs6`ZV+x!q(9hFZ-&FKk;7in0at(rS&<@HN*= z_js+^d`seJ%Fo?5zYl2ht9jk7!H$&wh}|*#fo)yT{(qaq0PO#pIWiAmBKaZ@%~$a> zyhZ*RYR*1;WFqk;>I{E^nxY(!-QR4Wrl<*4$Cpt97>|n7Hq;S)6TJ$l=ZSfHMWVJ< z8&rjzQQIpSbwbWXZA(8YLML$x-bID{-KVCWL#Ta!3N;0PqUtU1yE(c`q1Hg}-`W53 zNlc(Xi!a|F=2NT^>W0CnDVXWzcc3D01$8dmKo34a?S{gCn#EkxH4@c<4yfk_V zuniNj6Zxr_4=s*RehZ2RVVCUL)}( z^3zcbeTiMLawaEmQjSA47#wUmlp9mYS42(4yLeIie-jD4Mki)=0&lZVFe~}vs0z|i z9r()iPt@Wpmc^7eM$K_QY>%U{7al>akrG)=z130A)j`$U1KVi-N0HFm?|p2AM^T{( z4KWo~M$Ks|s-gF>1|C6$_BXdYOQ;i=%F>vZ@&>5w*#Wf;2ckMS7qwQdV&H%OBU3ih zqr#}rmqm@Z8#cleJc!#|M`m~Q>15T);RF`bNz{>BFsBoEzqdjyvNfo=zK^<|J(uaw z3#i@J8NC|$U=nR`BI;y1i3e4yW$(D$Y!7psQY;x^S}RL=W_!83uT#6J+7VKv68S8CgNe#Di1H<1ilw$ zqlf&vsE8dvReTReVov_bSPv(lcGoUcMAK2<1NTwew{{_~6F3?>6fzBa(ZhvhsQtSS zN8?#kk6RZuyP_xRWL$~UaX%^|ZHt&S($#f1ssnGK*2D@_!`o5!pZ1c_6kK!*enmy# zFE?Mfs99uXQFB%gb)a-W-S-Nr!W7glnuUtQXQ*$y+o+EJj*38NF$T!gR>7R)z3q#e z9`|-l!S!62ja9iZqJ$IpZ!Sgg2b(ICZ%6I(uWQv3fc5>+WU=HBoCbCEA!(eyL|wfKgi=I}TwlvgkT z|G-)pSIG(d?6?fIo33LEtX|nfWF)HL@vbYe0PS0cmB7oWkln?0_zYEXn<{1w$D+39 zbkv8$ch~^Ks+yxX79+^}Q6v5Z6*;Gx$+tuepciU!$D_9(iSZ;fqD43tccMDfy1MCk zU(^)E;UJuaTD=cY9ejp5(Q1U5ZPybUlAnY6;5db<->zXgS{fDc5jEKVx^X!L@puV! zRJN>XB9VmZ;6&6aUX1!Kcpnv^bEt-k)G`e>L%pWQy82KN-HG~OIf`1uKVxgmAI|KaF)I+BKJ@Cj;H6sTv8-d9mmw-B`k zHlap*5X12nHo+40&6Ilkkx*zRqC&YIwTRB5D#+Bp956*tBM8UNI0kk76lx88iyB#; zhGuT7V`cKaTqmPe|7KKV&LI)>S`SEQv#>d z`1&>B2*t^$(62>}>>jG(JWb7Wjj;#$UN{-IqK@i1&CJ^Ah$U#>iY1}#F%`$+deoG7 znw$OJ3RUqkRQYk#{dZ8`2iaSg4z)wAjiFcvx8OAV0ksW>w=~bKMMd~1dNsnkBoyk< zR%YbwP;)v4b>k*%iC>@^EYjL6o*t->zlNdcLq%dWYUD?;9u{k3BGMIgGA5$F6Z~!1 z|BAqE3Um$(Yimxn^{D;+5VakH+nH5e0@Z=qs1c1ot(^_1`*vX9?T1=3w^4JS^F^~3 z>Y>(3XVm>OUi6wdT|p2 z)ONd!IzXPIrYgLLiO_IVMAo35`wE-lGt_gDJY%U_2Kd zV>CwhGK=gus=?c+Rb8RC33VUrLOvCn;0e^xo3)P<`1eDdQH%O8jzYVyxo-^WpxcG* zwEDgyp^;VTXFewT;dJt|QAcNy{^o&}sO=YzT4eJu47a*|i~Y#w9AM@)9@~*$g6h~0 zsQatEYV3pUwfd)%(6+dU>T$^^b3;GWHk^qy@c?$md#J_PYM|K-ucKD^E>xthp*mhX z+U%w_sQMS-EBH04ow|eA|2p|nNoXJ6Lha)$G3M=*7gb?p)a!B(YP)@ay>S<6PV)^m zYo#vg`4lXRUeq>TgQ|ZAYOQ483m7tl{r?h)Rzu7e$wJfv>rwmDk2=fG;ZV#PYqo1L zW+uM`o8n4TXs_Xmm^;oypbx6!Q&CfV234;$)I_ZCP_NmK%_#Vq^4mBEPYyHZL9ckz z;8@fmTY=i9-=fa)vcsLg&-IN^5m|!T*AKA(mQFBJSP!d^k3&UbA?o$L%}YW@@Hu>f zw{a2vkmv;d&DPivPT=2k6dY;h`UA|z^_$oM|3XbkyCl&ElO`A5?M*SiQ2!jaS3ikRs3R#dAan&9OTDfUYv^B1xrw&--TK$H{9|vspj3$ z44YFv3_Z97b&#DyBH*7KaUsV%C-B$qzPOtFpEwWSo9|d>u;v02k-xEW zCcfns^5sN_<}5Z-7qXPqPD9U^@e2&f3$JhjznU$vk|Uga|5Z-lFQSuIGbjA|*7{|& zS*2Iknh=#*XF`~WN4dTh^)q4I2J`Y6i#kXapcdZ;sNHcK_4>Vlx_{zEv#aKz7F`2#msKqw*1G60` zerUGwK2$v?QRl%89ERSzB#Myez1hrh3T7dH1&N^bJzm0}un`{EVz$+vxQTqkM`oXY zhxN#ZZZ$tAG{+anzm1yvqi*>P)HbcYEwHVyyjzo1R9oyqA)R9?tyJ@H^ zmL~s(>k8B&K7=~iilv!dQy2A-IRgjbLDcRlx5NA#FcE8M|8FDFlnX!LYr0{l6ZjX4 zE3i5Fu#e4l8Ho|(51?Ku57C1!_}NC-7uE5rsJVWO8fnp8=H*ru^OJ9kS_56Mm>wMC z7A!=KbPwu?y@Y`XU={Lpcbn}Og=5Koh?B9x9PY^&Ih%&=HxAp%`+?T*!|KZE4h^ zX@WX%x?vc`qdK?@IRLHgsDsFly6?POejC-$eQb))P>VA1wAq&4P9*eT5-NnVQ61Tc zx-kvafupF3zC?}uCpZ7d%{!l&NajI3-vsrA^deTmXw;OXa~@<`Fm&NBX_pp# z?PN}lPKq0mvS8QZT4|M*w9Jwgw5n-HTI{BxA!)gGoXnE8YhNKdE!zS9-Pug+rM zu>Bu|*zelDGTH5-z8X2~7~jO4_C$ZhT=sR_pFgj?HKg8VD+ID*BrjkJ4hDVL1J0nx$k{FVD zmYfnr>#EuvgoZadHZD5W6Q{Tmfq`6$ijR*Ooci|2$>}&V|6}V#dTor_$vi9(6Lg^1pX6+;p+sU{6d^QeslFCowuYHOY-iO!a>Y zjCXA3lQsR)LsNvN-BjRHy@I=SPL=R&uQD#7$Q#^?ShsH#wBzvM5E_aWJ zNus-h7<5wNSdAwpY)F`AY+|ZMafyu@6$nZyRVg4*DJe;D15;CC6xz-yVLh7-Xc-gV zE+!?WQ$maQ#NiCQazD~sbTh5+yAtd9cBCH*R~%A`9G;=@5$_& z8EGfPnoZ&MJlqqNFeotO zrv&@DMmu?nrteALn!d-AF)@8l#)R};=?60=xf9}Bwbm}+o4m%J?4P;T{w9atpJo@z z>q~FpWb-#TX5Y)}pL)eUncE-wiyi9tSKhO|j{n04_O%e-fM<3Uf7NI9*et$A!A@3x zm0+h)u&;DqI`?gMXLMG7Xdx$u?e`RR_Ga>xE8#@=-zec+uj(t=&dKaQ-p)A}lz-K~ zx|1=(?T~+AM<*!Q|DcPrKdbNB=XQSIpM9KWzQn#xe1U)OfBM#cMsTOEQ^Pl4pp(rP z6XoQ~@=raD`Z?hHl$quXBATpReW&J4b$|VRFW#^quLu|Jeo^6Mc<$IE8ER{Lb_p zipl@HaQ;^{fp)fLOr@ z+sDr3(3dvT;8qVkJ!qcwq;F?&?9G^+@fJ&DC+i}81*m;L1!)-*GA5_{xwwn zcTli5(CPFQzC*v;g@P;bN~-Kz`Lk0X_kX^%!aTkqmmN?3Ky`Z<>;z_T7cK5jKj7P( z;1u$moor`rvYpbMp7eeHQ~53r-D9140)0@FSh(CGRIc^=KlAVLZ5u^I=U#B~wR2CC z|F8E{puLaHkPoIGU`=Sy+vo%n=c}>TF65j0iWA~X?C)gpPNaBm#>{Gj%R^VVoBwFS zcYD&eWz0z5%LVZ3+4TLMO1e!$(2eeZ)Ay` z=-EL0cQ#1hNA3SRM%$PPW9o9u zaL(}1KQ=_hTY;he)1hWpoRWoz5Y?;VFf;KVYy2MHt2v$gk)FWD(L6Ikn)>&5(a_8Sd!(e{xXjL@{X+RzxIjqYG^O*hy$9M|FVl2 zfVKn|4{G>S#wPeLyY^obuuoyvSkia<4n4E;=lMUcQ{VdE?ONVy9upQlc_6TESPZlg zn9Khu$e3f+r?%oh+rXS&|FK)#HAUuoozGYFj`M|Y z(o#EbQ98WSJ?lN;Y)pEuEx0XXf-h>kozJUT)8y+g`#2EUz#Q+RGun4EJn35q1;<+8 zuC0L=cKYIUfAv5ZGiC+W4(rM7p>`&b({^XqnQ8f7!wa(%8c!g2+x!pjI8U?q^FDI= G2K_%maQa99 diff --git a/spyder/locale/ru/LC_MESSAGES/spyder.po b/spyder/locale/ru/LC_MESSAGES/spyder.po index 9096c6b6e05..7665edaac32 100644 --- a/spyder/locale/ru/LC_MESSAGES/spyder.po +++ b/spyder/locale/ru/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-06 21:16\n" "Last-Translator: \n" "Language-Team: Russian\n" -"Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: ru\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: ru\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: ru_RU\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -59,14 +58,12 @@ msgid "Dock the pane" msgstr "Прикрепить панель" #: spyder/api/widgets/main_widget.py:344 spyder/api/widgets/main_widget.py:448 spyder/plugins/base.py:204 spyder/plugins/base.py:512 -#, fuzzy msgid "Unlock position" -msgstr "Позиция курсора" +msgstr "Разблокировать позицию" #: spyder/api/widgets/main_widget.py:345 spyder/api/widgets/main_widget.py:453 spyder/plugins/base.py:205 spyder/plugins/base.py:516 -#, fuzzy msgid "Unlock to move pane to another position" -msgstr "Перейти к следующей позиции курсора" +msgstr "Разблокируйте панель, чтобы переместить в другую позицию" #: spyder/api/widgets/main_widget.py:351 spyder/plugins/base.py:212 msgid "Undock" @@ -85,14 +82,12 @@ msgid "Close the pane" msgstr "Закрыть эту панель" #: spyder/api/widgets/main_widget.py:442 spyder/plugins/base.py:506 -#, fuzzy msgid "Lock position" -msgstr "Позиция курсора" +msgstr "Блокировка позиций" #: spyder/api/widgets/main_widget.py:446 spyder/plugins/base.py:510 -#, fuzzy msgid "Lock pane to the current position" -msgstr "Перейти к следующей позиции курсора" +msgstr "Заблокировать панель в текущей позиции" #: spyder/api/widgets/toolbars.py:123 msgid "More" @@ -195,27 +190,21 @@ msgid "Initializing..." msgstr "Инициализация..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." -msgstr "" -"Не удалось закрыть предыдущий экземпляр Spyder.\n" +msgstr "Не удалось закрыть предыдущий экземпляр Spyder.\n" "Перезапуск прерван." #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." -msgstr "" -"Невозможно сбросить настройки Spyder в значения по умолчанию.\n" +msgstr "Невозможно сбросить настройки Spyder в значения по умолчанию.\n" "Операция прервана." #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." -msgstr "" -"Невозможно перезапустить Spyder.\n" +msgstr "Невозможно перезапустить Spyder.\n" "Операция прервана." #: spyder/app/restart.py:145 @@ -247,15 +236,14 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "Shortcut context must match '_' or the plugin `CONF_SECTION`!" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" -msgstr "" +msgstr "При загрузке конфигурации Spyder произошла ошибка. Для запуска Spyder вам нужно перенастроить их.\n\n" +"Хотите ли вы продолжать?" #: spyder/config/manager.py:668 msgid "Spyder configuration files resetted!" -msgstr "" +msgstr "Файлы конфигурации Spyder перенастроены!" #: spyder/config/utils.py:25 spyder/plugins/console/widgets/main_widget.py:512 spyder/plugins/explorer/widgets/explorer.py:1710 spyder/plugins/profiler/widgets/main_widget.py:462 spyder/plugins/pylint/main_widget.py:868 msgid "Python files" @@ -714,11 +702,9 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "Установить для экранов с высоким DPI, если автомасштабирование не работает" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" -msgstr "" -"Введите значения для разных экранов, разделённые точкой с запятой \";\".\n" +msgstr "Введите значения для разных экранов, разделённые точкой с запятой \";\".\n" "Значения с плавающей точкой поддерживаются" #: spyder/plugins/application/confpage.py:238 spyder/plugins/ipythonconsole/confpage.py:29 spyder/plugins/outlineexplorer/widgets.py:48 @@ -1043,7 +1029,7 @@ msgstr "Загрузка" #: spyder/plugins/completion/providers/kite/widgets/install.py:213 msgid "Kite comes with a native app called the Copilot
which provides you with real time
documentation as you code.

When Kite is done installing, the Copilot will
launch automatically and guide you through the
rest of the setup process." -msgstr "" +msgstr "Kite поставляется с родным приложением Copilot
, которое предоставляет вам документацию кода в режиме реального времени.

После завершения установки Kite,
автоматически запустится Copilot и проведет вас через
остальную часть настройки." #: spyder/plugins/completion/providers/kite/widgets/install.py:221 msgid "OK" @@ -1070,19 +1056,15 @@ msgid "not reachable" msgstr "не достижимо" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" -msgstr "" -"Установка Kite продолжится в фоновом режиме.\n" +msgstr "Установка Kite продолжится в фоновом режиме.\n" "Нажмите здесь, чтобы увидеть диалог установки снова" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" -msgstr "" -"Нажмите здесь, чтобы\n" +msgstr "Нажмите здесь, чтобы\n" "снова показать диалог установки" #: spyder/plugins/completion/providers/languageserver/conftabs/advanced.py:28 spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:60 spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:268 @@ -1119,15 +1101,15 @@ msgstr "Использовать каналы стандартного ввод #: spyder/plugins/completion/providers/languageserver/conftabs/advanced.py:154 msgid "Modifying these options can break code completion!!

If that's the case, please reset your Spyder preferences by going to the menu

Tools > Reset Spyder to factory defaults

instead of reporting a bug." -msgstr "" +msgstr "Изменение этих опций может нарушить дополнение кода!!

В таком случае пожалуйста, сбросьте ваши настройки Spyder, перейдя в меню

Инструменты > Сбросить настройки по умолчанию для Spyder

вместо того, чтобы сообщить об ошибке." #: spyder/plugins/completion/providers/languageserver/conftabs/advanced.py:206 msgid "It appears there is no {language} language server listening at address:

{host}:{port}

Please verify that the provided information is correct and try again." -msgstr "" +msgstr "Похоже, что не существует {language} языкового сервера, прослушивающего по адресу:

{host}:{port}

Пожалуйста, убедитесь, что адрес верен и повторите попытку." #: spyder/plugins/completion/providers/languageserver/conftabs/advanced.py:224 msgid "The address of the external server you are trying to connect to is the same as the one of the current internal server started by Spyder.

Please provide a different address!" -msgstr "" +msgstr "Адрес внешнего сервера, к которому вы пытаетесь подключиться, совпадает с адресом текущего внутреннего сервера, запущенного Spyder.

Пожалуйста, укажите другой адрес!" #: spyder/plugins/completion/providers/languageserver/conftabs/docstring.py:27 msgid "Docstring style" @@ -1139,7 +1121,7 @@ msgstr "
стра #: spyder/plugins/completion/providers/languageserver/conftabs/docstring.py:41 msgid "Here you can decide if you want to perform style analysis on your docstrings according to the {} or {} conventions. You can also decide if you want to show or ignore specific errors, according to the codes found on this {}." -msgstr "" +msgstr "Здесь вы можете решить, хотите ли вы выполнить анализ стиля ваших приёмов в соответствии с соглашениями {} или {}. Вы также можете решить, если вы хотите показать или игнорировать конкретные ошибки, в соответствии с кодами, найденными на этом {}." #: spyder/plugins/completion/providers/languageserver/conftabs/docstring.py:51 msgid "Enable docstring style linting" @@ -1183,19 +1165,19 @@ msgstr "Пропускать директории, начинающиеся с #: spyder/plugins/completion/providers/languageserver/conftabs/docstring.py:132 msgid "Show the following errors in addition to the specified convention:" -msgstr "" +msgstr "Показывать следующие ошибки в дополнение к указанной конвенции:" #: spyder/plugins/completion/providers/languageserver/conftabs/docstring.py:135 msgid "Ignore the following errors in addition to the specified convention:" -msgstr "" +msgstr "Игнорируйте следующие ошибки в дополнении указанных условиях:" #: spyder/plugins/completion/providers/languageserver/conftabs/docstring.py:143 msgid "Directory patterns listed for matching should be valid regular expressions" -msgstr "" +msgstr "Шаблоны каталогов, указанные для сопоставления, должны быть допустимыми регулярными выражениями" #: spyder/plugins/completion/providers/languageserver/conftabs/docstring.py:146 msgid "File patterns listed for matching should be valid regular expressions" -msgstr "" +msgstr "Шаблоны файлов, указанные для сопоставления, должны быть допустимыми регулярными выражениями" #: spyder/plugins/completion/providers/languageserver/conftabs/formatting.py:27 msgid "Code style and formatting" @@ -1207,7 +1189,7 @@ msgstr " #: spyder/plugins/completion/providers/languageserver/conftabs/formatting.py:39 msgid "Spyder can use pycodestyle to analyze your code for conformance to the {} convention. You can also manually show or hide specific warnings by their {}." -msgstr "" +msgstr "Spyder может использовать pycodestyle для анализа вашего кода на соответствие конвенции {}. Вы также можете вручную показать или скрыть определенные предупреждения их {}." #: spyder/plugins/completion/providers/languageserver/conftabs/formatting.py:48 msgid "Enable code style linting" @@ -1263,7 +1245,7 @@ msgstr "Длина строки" #: spyder/plugins/completion/providers/languageserver/conftabs/formatting.py:123 msgid "Spyder can use {0} or {1} to format your code for conformance to the {2} convention." -msgstr "" +msgstr "Spyder может использовать {0} или {1} для форматирования вашего кода в соответствии с конвенцией {2}." #: spyder/plugins/completion/providers/languageserver/conftabs/formatting.py:131 msgid "Choose the code formatting provider: " @@ -1283,11 +1265,11 @@ msgstr "Форматирование кода" #: spyder/plugins/completion/providers/languageserver/conftabs/formatting.py:162 msgid "Directory patterns listed for exclusion should be valid regular expressions" -msgstr "" +msgstr "Шаблоны каталогов для исключения должны быть допустимыми регулярными выражениями" #: spyder/plugins/completion/providers/languageserver/conftabs/formatting.py:165 msgid "File patterns listed for exclusion should be valid regular expressions" -msgstr "" +msgstr "Шаблоны файлов, перечисленные для исключения, должны быть допустимыми регулярными выражениями" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:26 msgid "Introspection" @@ -1302,12 +1284,10 @@ msgid "Enable Go to definition" msgstr "Включить переход к определению" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." -msgstr "" -"Если включено, при левом клике на имени объекта при\n" +msgstr "Если включено, при левом клике на имени объекта при\n" "нажатой кнопке {} произойдет переход на определение объекта\n" "(если доступно)." @@ -1324,12 +1304,10 @@ msgid "Enable hover hints" msgstr "Включить подсказки при наведении" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." -msgstr "" -"Если включено, при наведении мыши на имя объекта\n" +msgstr "Если включено, при наведении мыши на имя объекта\n" "будет показана сигнатура объекта и/или\n" "его docstring (если есть)." @@ -1363,7 +1341,7 @@ msgstr "Другие языки" #: spyder/plugins/completion/providers/languageserver/conftabs/otherlanguages.py:36 msgid "Spyder uses the Language Server Protocol to provide code completion and linting for its Editor. Here, you can setup and configure LSP servers for languages other than Python, so Spyder can provide such features for those languages as well." -msgstr "" +msgstr "Spyder использует Language Server Protocol, чтобы обеспечить завершению кода и проверку для редактора. Здесь вы можете настроить LSP серверы для других языков, чем Python, так чтобы Spyder мог предоставить возможности этих языков." #: spyder/plugins/completion/providers/languageserver/conftabs/otherlanguages.py:47 msgid "Available servers:" @@ -1387,11 +1365,11 @@ msgstr "Протокол сервера языка (LSP)" #: spyder/plugins/completion/providers/languageserver/provider.py:399 msgid "It appears there is no {language} language server listening at address:

{host}:{port}

Therefore, completion and linting for {language} will not work during this session." -msgstr "" +msgstr "Похоже, нет языкового сервера {language} с прослушиванием по адресам:

{host}:{port}

Таким образом, завершение и подсоединение для {language} не будет работать во время этой сессии." #: spyder/plugins/completion/providers/languageserver/provider.py:439 msgid "Completion and linting in the editor for {language} files will not work during the current session, or stopped working.

" -msgstr "" +msgstr "Завершение и помещение в редактор для {language} файлов не будут работать во время текущей сессии или перестали работать.

" #: spyder/plugins/completion/providers/languageserver/provider.py:443 msgid "Do you want to restart Spyder now?" @@ -1411,7 +1389,7 @@ msgstr "Недопустимый JSON" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:132 msgid "To create a new server configuration, you need to select a programming language, set the command to start its associated server and enter any arguments that should be passed to it on startup. Additionally, you can set the server's hostname and port if connecting to an external server, or to a local one using TCP instead of stdio pipes.

Note: You can use the placeholders {host} and {port} in the server arguments field to automatically fill in the respective values.
" -msgstr "" +msgstr "Чтобы создать новую конфигурацию сервера, вам нужно выбрать язык программирования, установите команду для запуска связанного сервера и введите любые аргументы, которые должны быть переданы ему при запуске. Кроме того, вы можете задать имя хоста и порт сервера при подключении к внешнему серверу, или локальному устройству, использующему TCP вместо stdio pipes.

Примечание: Вы можете использовать заполнители {host} и {port} в поле аргументов сервера, чтобы автоматически заполнять соответствующие значения.
" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:153 msgid "External server" @@ -1443,7 +1421,7 @@ msgstr "Редактор сервера LSP" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:178 spyder/plugins/completion/providers/snippets/conftabs.py:53 msgid "Programming language provided by the LSP server" -msgstr "" +msgstr "Язык программирования, предоставленный сервером LSP" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:179 msgid "Select a language" @@ -1471,7 +1449,7 @@ msgstr "Использовать каналы стандартного ввод #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:227 msgid "Check if the server communicates using stdin/out pipes" -msgstr "" +msgstr "Проверьте, поддерживает ли сервер связь посредством stdin/out" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:238 spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:559 spyder/plugins/completion/providers/snippets/conftabs.py:59 msgid "Language" @@ -1491,7 +1469,7 @@ msgstr "Имя хоста должно быть действительным" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:324 msgid "Hostname or IP address of the host on which the server is running. Must be non empty." -msgstr "" +msgstr "Имя хоста или IP-адрес хоста, на котором запущен сервер. Не должно быть пустым." #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:328 msgid "Hostname is valid" @@ -1499,7 +1477,7 @@ msgstr "Имя хоста верно" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:336 msgid "Command used to start the LSP server locally. Must be non empty" -msgstr "" +msgstr "Команда, используемая для локального запуска LSP сервера не должна быть пустой" #: spyder/plugins/completion/providers/languageserver/widgets/serversconfig.py:343 msgid "Program was not found on your system" @@ -1534,11 +1512,9 @@ msgid "down" msgstr "вниз" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." -msgstr "" -"Статус автозавершения, проверки\n" +msgstr "Статус автозавершения, проверки\n" "кода, сворачивания кода и символов." #: spyder/plugins/completion/providers/languageserver/widgets/status.py:74 @@ -1555,7 +1531,7 @@ msgstr "грамматика LSP" #: spyder/plugins/completion/providers/snippets/conftabs.py:42 msgid "Spyder allows to define custom completion snippets to use in addition to the ones offered by the Language Server Protocol (LSP). Each snippet should follow {}.

Note: All changes will be effective only when applying the settings" -msgstr "" +msgstr "Spyder позволяет определить пользовательские сниппеты завершения для использования в дополнение к тем, которые предлагаются Протоколом Языкового Сервера (LSP). Каждый сниппет должен следовать за {}.

Примечание: Все изменения вступят в силу только при применении настроек" #: spyder/plugins/completion/providers/snippets/conftabs.py:69 msgid "Available snippets" @@ -1599,7 +1575,7 @@ msgstr "Некорректный JSON" #: spyder/plugins/completion/providers/snippets/conftabs.py:171 msgid "There was an error when trying to load the provided JSON file: {0}" -msgstr "" +msgstr "Произошла ошибка при попытке загрузить JSON файл: {0}" #: spyder/plugins/completion/providers/snippets/conftabs.py:180 msgid "Invalid snippet file" @@ -1607,7 +1583,7 @@ msgstr "Некорректный файл сниппета" #: spyder/plugins/completion/providers/snippets/conftabs.py:181 msgid "The provided snippet file does not comply with the Spyder JSON snippets spec and therefore it cannot be loaded.

{}" -msgstr "" +msgstr "Указанный сниппет файл не соответствует сниппетам Spyder JSON, поэтому он не может быть загружен.

{}" #: spyder/plugins/completion/providers/snippets/conftabs.py:198 msgid "Incorrect snippet format" @@ -1663,7 +1639,7 @@ msgstr "Текст активации данного сниппета" #: spyder/plugins/completion/providers/snippets/widgets/snippetsconfig.py:311 msgid "Snippet text completion to insert" -msgstr "" +msgstr "Текст сниппета для вставки" #: spyder/plugins/completion/providers/snippets/widgets/snippetsconfig.py:318 msgid "Trigger information" @@ -1746,17 +1722,18 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "Для использования команд типа \"raw_input\" или \"input\" запустите Spyder с опцией многопоточности (--multithread) из системного терминала" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" -msgstr "" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" +msgstr "Встроенная консоль Spyder\n\n" +"Эта консоль предназначена для вывода внутренних\n" +"ошибок приложени и изучения внутреннего\n" +"состояния Spyder следующими командами:\n" +" spy.app, spy.window, dir(spy)\n\n" +"Пожалуйста, не используйте её для запуска своего кода\n\n" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 msgid "&Quit" @@ -1771,9 +1748,8 @@ msgid "&Run..." msgstr "&Выполнить..." #: spyder/plugins/console/widgets/main_widget.py:191 -#, fuzzy msgid "Run a Python file" -msgstr "Выполнить скрипт Python" +msgstr "Запустить Python файл" #: spyder/plugins/console/widgets/main_widget.py:197 msgid "Environment variables..." @@ -1820,9 +1796,8 @@ msgid "Internal console settings" msgstr "Настройки встроенной консоли" #: spyder/plugins/console/widgets/main_widget.py:510 -#, fuzzy msgid "Run Python file" -msgstr "Файлы Python" +msgstr "Запуск Python файла" #: spyder/plugins/console/widgets/main_widget.py:569 msgid "Buffer" @@ -1941,14 +1916,12 @@ msgid "Tab always indent" msgstr "Клавиша Tab - всегда отступ" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)" -msgstr "" -"Если активно, нажатие Tab будет делать отступ\n" +msgstr "Если активно, нажатие Tab будет делать отступ\n" "всегда, даже когда курсор не в начале строки\n" "(когда эта опция включена, автодополнение\n" "кода можно будет включить альтернативной\n" @@ -1959,12 +1932,10 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "Автоматически удалять конечные пробелы в изменённых строках" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." -msgstr "" -"Если включено, в измененных строках кода (исключая текстовые константы)\n" +msgstr "Если включено, в измененных строках кода (исключая текстовые константы)\n" "будут обрезаться конечные пробельные символы.\n" "Если выключено, будут обрезаться только пробельные символы,\n" "которые добавил Spyder." @@ -1979,11 +1950,11 @@ msgstr "Автоматически удалять лишние пробелы в #: spyder/plugins/editor/confpage.py:134 msgid "Insert a newline at the end if one does not exist when saving a file" -msgstr "" +msgstr "Вставьте строку в конце, если она не существует при сохранении файла" #: spyder/plugins/editor/confpage.py:139 msgid "Trim all newlines after the final one when saving a file" -msgstr "" +msgstr "Обрезать все строки после последней при сохранении файла" #: spyder/plugins/editor/confpage.py:144 msgid "Indentation characters: " @@ -2039,7 +2010,7 @@ msgstr "Сохранять фокус в Редакторе после запу #: spyder/plugins/editor/confpage.py:210 msgid "Copy full cell contents to the console when running code cells" -msgstr "" +msgstr "Копировать полное содержимое ячейки в консоль при выполнении ячеек кода" #: spyder/plugins/editor/confpage.py:223 msgid "Edit template for new files" @@ -2115,7 +2086,7 @@ msgstr "" #: spyder/plugins/editor/confpage.py:304 spyder/plugins/editor/plugin.py:884 msgid "LF (Unix)" -msgstr "" +msgstr "LF(Unix)" #: spyder/plugins/editor/confpage.py:305 spyder/plugins/editor/plugin.py:879 msgid "CRLF (Windows)" @@ -2123,7 +2094,7 @@ msgstr "CRLF (Windows)" #: spyder/plugins/editor/confpage.py:306 spyder/plugins/editor/plugin.py:889 msgid "CR (macOS)" -msgstr "" +msgstr "CR (macOS)" #: spyder/plugins/editor/confpage.py:330 spyder/plugins/history/confpage.py:27 spyder/plugins/ipythonconsole/confpage.py:416 spyder/plugins/statusbar/confpage.py:26 spyder/plugins/variableexplorer/confpage.py:31 msgid "Display" @@ -2343,30 +2314,28 @@ msgstr "Выполнить выделенное или текущую строк #: spyder/plugins/editor/plugin.py:702 msgid "Run &to current line" -msgstr "" +msgstr "Запускать &to текущую строку" #: spyder/plugins/editor/plugin.py:703 spyder/plugins/editor/widgets/codeeditor.py:4447 msgid "Run to current line" -msgstr "" +msgstr "Запускать текущую строку" #: spyder/plugins/editor/plugin.py:709 msgid "Run &from current line" -msgstr "" +msgstr "Запускать &from с текущей строки" #: spyder/plugins/editor/plugin.py:710 spyder/plugins/editor/widgets/codeeditor.py:4451 msgid "Run from current line" -msgstr "" +msgstr "Запускать с текущей строки" #: spyder/plugins/editor/plugin.py:717 spyder/plugins/editor/widgets/codeeditor.py:4430 msgid "Run cell" msgstr "Выполнить &блок" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" -msgstr "" -"Запустить текущую ячейку \n" +msgstr "Запустить текущую ячейку \n" "[Используйте #%%, чтобы создать ячейку]" #: spyder/plugins/editor/plugin.py:729 spyder/plugins/editor/widgets/codeeditor.py:4434 @@ -2691,7 +2660,7 @@ msgstr "Сообщение об ошибке:" #: spyder/plugins/editor/widgets/autosaveerror.py:57 msgid "Hide all future autosave-related errors during this session" -msgstr "" +msgstr "Скрыть все будущие ошибки, связанные с автосохранением во время этой сессии" #: spyder/plugins/editor/widgets/codeeditor.py:648 msgid "click to open file" @@ -2722,33 +2691,24 @@ msgid "Removal error" msgstr "Ошибка удаления" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" -msgstr "" -"Невозможно удалить результаты из блокнота. Ошибка:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" +msgstr "Невозможно удалить результаты из блокнота. Ошибка:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 msgid "Conversion error" msgstr "Ошибка преобразования" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" -msgstr "" -"Не удалось сконвертировать этот блокнот. Ошибка:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" +msgstr "Не удалось сконвертировать этот блокнот. Ошибка:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:4412 msgid "Clear all ouput" msgstr "Очистить вывод" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" -msgstr "Сохранить как скрипт Python" +msgstr "Преобразовать в Python файл" #: spyder/plugins/editor/widgets/codeeditor.py:4418 msgid "Go to definition" @@ -2903,13 +2863,9 @@ msgid "Recover from autosave" msgstr "Восстановить из автосохранения" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." -msgstr "" -"Найдены файлы автосохранения. Что бы Вы хотели сделать?\n" -"\n" +msgstr "Найдены файлы автосохранения. Что бы Вы хотели сделать?\n\n" "Этот диалог будет показан снова при следующем запуске, если все файлы автосохранения не будут восстановлены, перемещены или удалены." #: spyder/plugins/editor/widgets/recover.py:148 @@ -3205,24 +3161,16 @@ msgid "File/Folder copy error" msgstr "Ошибка копирования файла/папки" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" -msgstr "" -"Не удалось скопировать этот тип файл (файлов) или папки (папок). Ошибка:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" +msgstr "Не удалось скопировать этот тип файл (файлов) или папки (папок). Ошибка:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1416 msgid "Error pasting file" msgstr "Ошибка при вставке файла" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" -msgstr "" -"Неподдерживаемая операция копирования. Ошибка:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" +msgstr "Неподдерживаемая операция копирования. Ошибка:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1436 msgid "Recursive copy" @@ -3677,11 +3625,9 @@ msgid "Display initial banner" msgstr "Показывать стартовый баннер" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." -msgstr "" -"Эта опция позволяет Вам спрятать сообщение,\n" +msgstr "Эта опция позволяет Вам спрятать сообщение,\n" "показываемое вверху консоли при открытии." #: spyder/plugins/ipythonconsole/confpage.py:35 @@ -3693,11 +3639,9 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "Спрашивать подтверждение перед удалением всех пользовательских переменных" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." -msgstr "" -"Эта опция позволяет Вам спрятать сообщение,\n" +msgstr "Эта опция позволяет Вам спрятать сообщение,\n" "показываемое при перезапуске окружения Spyder." #: spyder/plugins/ipythonconsole/confpage.py:43 spyder/plugins/ipythonconsole/widgets/main_widget.py:475 @@ -3709,11 +3653,9 @@ msgid "Ask for confirmation before restarting" msgstr "Спрашивать подтверждение перед перезапуском" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." -msgstr "" -"Эта опция позволяет Вам спрятать сообщение,\n" +msgstr "Эта опция позволяет Вам спрятать сообщение,\n" "показываемое при перезапуске ядра." #: spyder/plugins/ipythonconsole/confpage.py:59 @@ -3749,12 +3691,10 @@ msgid "Buffer: " msgstr "Буфер: " #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" -msgstr "" -"Установите максимальное количество линий, показываемых\n" +msgstr "Установите максимальное количество линий, показываемых\n" "в консоли перед усечением. Значение -1 отключает функцию\n" "(не рекомендуется!)" @@ -3771,8 +3711,7 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "Автоматически загружать модули Pylab и NumPy" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." @@ -3851,14 +3790,12 @@ msgid "Use a tight layout for inline plots" msgstr "Использовать размещение tight для встраиваемых графиков" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" "that created using savefig." -msgstr "" -"Устанавливает bbox_inches в\n" +msgstr "Устанавливает bbox_inches в\n" "\"tight\" при построении встроенного\n" "графика в matplotlib.\n" "Когда включен, может вызвать\n" @@ -4175,7 +4112,7 @@ msgstr "Выберите файл ключа SSH" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:64 msgid ">= {0} and < {1}" -msgstr "" +msgstr ">= {0} and < {1}" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:290 msgid "The directory {} is not writable and it is required to create IPython consoles. Please make it writable." @@ -4227,11 +4164,11 @@ msgstr "Очистить консоль" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:510 msgid "Enter array table" -msgstr "" +msgstr "Введите таблицу массивов" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:516 msgid "Enter array inline" -msgstr "" +msgstr "Введите встроенный массив" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:535 msgid "Special consoles" @@ -4251,7 +4188,7 @@ msgstr "Новая консоль Cython (Python с расширениями C)" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:601 msgid "Remove all variables from kernel namespace" -msgstr "" +msgstr "Удалите все переменные из пространства имен ядра" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:622 msgid "IPython documentation" @@ -4282,20 +4219,12 @@ msgid "Connection error" msgstr "Ошибка соединения" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" -msgstr "" -"Произошла ошибка при попытке загрузить файл подключения к ядру. Ошибка:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" +msgstr "Произошла ошибка при попытке загрузить файл подключения к ядру. Ошибка:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" -msgstr "" -"Не удалось открыть ssh-туннель. Произошла ошибка:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" +msgstr "Не удалось открыть ssh-туннель. Произошла ошибка:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 msgid "The Python environment or installation whose interpreter is located at
    {0}
doesn't have the spyder-kernels module or the right version of it installed ({1}). Without this module is not possible for Spyder to create a console for you.

You can install it by activating your environment (if necessary) and then running in a system terminal:
    {2}
or
    {3}
" @@ -4343,7 +4272,7 @@ msgstr "Произошла ошибка, смотрите консоль." #: spyder/plugins/ipythonconsole/widgets/namespacebrowser.py:97 msgid "The comm channel is not working." -msgstr "" +msgstr "Канал связи не работает." #: spyder/plugins/ipythonconsole/widgets/namespacebrowser.py:98 msgid "%s.

Note: Please don't report this problem on Github, there's nothing to do about it." @@ -4466,11 +4395,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"Компоновка окон будет восстановлена по умолчанию: это отразится на позиции, размере и объединении окон.\n" +msgstr "Компоновка окон будет восстановлена по умолчанию: это отразится на позиции, размере и объединении окон.\n" "Хотите продолжить?" #: spyder/plugins/layout/layouts.py:81 @@ -4543,8 +4470,7 @@ msgstr "Интепретатор Python" #: spyder/plugins/maininterpreter/confpage.py:72 msgid "Select the Python interpreter for all Spyder consoles" -msgstr "" -"Выберите исполняемый файл интерпретатора Python\n" +msgstr "Выберите исполняемый файл интерпретатора Python\n" "для всех консолей Spyder" #: spyder/plugins/maininterpreter/confpage.py:75 @@ -4576,9 +4502,8 @@ msgid "Enable UMR" msgstr "Включить UMR" #: spyder/plugins/maininterpreter/confpage.py:122 -#, fuzzy msgid "This option will enable the User Module Reloader (UMR) in Python/IPython consoles. UMR forces Python to reload deeply modules during import when running a Python file using the Spyder's builtin function runfile.

1. UMR may require to restart the console in which it will be called (otherwise only newly imported modules will be reloaded when executing files).

2. If errors occur when re-running a PyQt-based program, please check that the Qt objects are properly destroyed (e.g. you may have to use the attribute Qt.WA_DeleteOnClose on your main window, using the setAttribute method)" -msgstr "Эта опция включает Перезагрузчик Модулей Пользователя (UMR) в консолях Python/IPython. UMR заставляет Python рекурсивно перезагружать все модули при запуске скрипта Python встроенной функцией Spyder runfile.

1. UMR может потребовать перезапустить консоль, в которой был вызван (в противном случае только вновь импортируемые модули будут перезагружены при выполнении скриптов).

2.Если при перезапуске программ на PyQt происходят ошибки, убедитесь, что Qt-объекты уничтожены должным образом (напр. можно установить атрибут Qt.WA_DeleteOnClose для Вашего главного окна с помощью метода setAttribute)" +msgstr "" #: spyder/plugins/maininterpreter/confpage.py:140 msgid "Show reloaded modules list" @@ -4609,11 +4534,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "Вы работаете с Python 2, поэтому вы не можете ипортировать модули, содержащие не-ascii символы." #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"Следующие модули не установлены на Вашей машине:\n" +msgstr "Следующие модули не установлены на Вашей машине:\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4953,11 +4876,9 @@ msgid "Results" msgstr "Результаты" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" -msgstr "" -"Результаты Профайлера (вывод profile/cProfile python'а)\n" +msgstr "Результаты Профайлера (вывод profile/cProfile python'а)\n" "хранятся здесь:" #: spyder/plugins/profiler/plugin.py:67 spyder/plugins/profiler/widgets/main_widget.py:203 spyder/plugins/tours/tours.py:187 @@ -5274,11 +5195,11 @@ msgstr "Рефакторинг" #: spyder/plugins/pylint/main_widget.py:122 msgid "messages" -msgstr "" +msgstr "сообщений" #: spyder/plugins/pylint/main_widget.py:124 msgid "message" -msgstr "" +msgstr "сообщение" #: spyder/plugins/pylint/main_widget.py:191 msgid "Results for " @@ -5362,11 +5283,11 @@ msgstr "Управление конфигурацией запуска." #: spyder/plugins/run/widgets.py:31 msgid "Run file with default configuration" -msgstr "" +msgstr "Запустить файл с конфигурацией по умолчанию" #: spyder/plugins/run/widgets.py:32 msgid "Run file with custom configuration" -msgstr "" +msgstr "Запустить файл с пользовательской конфигурацией" #: spyder/plugins/run/widgets.py:33 msgid "Execute in current console" @@ -5873,13 +5794,9 @@ msgid "Save and Close" msgstr "Сохранить и закрыть" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"Открытие этой переменной может занять время\n" -"\n" +msgstr "Открытие этой переменной может занять время\n\n" "Желаете продолжить?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5919,11 +5836,9 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Spyder не может получить значение переменной из консоли.

Сообщение об ошибке:
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" -msgstr "" -"Невозможно отобразить это значения\n" +msgstr "Невозможно отобразить это значения\n" "потому что в процессе произошла ошибка" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:621 @@ -6392,7 +6307,7 @@ msgstr "Изменить на родительский каталог" #: spyder/plugins/workingdirectory/plugin.py:65 msgid "Working directory" -msgstr "" +msgstr "Рабочий каталог" #: spyder/plugins/workingdirectory/plugin.py:68 msgid "Set the current working directory for various plugins." @@ -6503,8 +6418,7 @@ msgid "Legal" msgstr "Правовая информация" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6514,8 +6428,7 @@ msgid "" " Hint:
\n" " Use two spaces or two tabs to generate a ';'.\n" " " -msgstr "" -"\n" +msgstr "\n" " Помощник Массивов/Матриц Numpy
\n" " Введите массив в Matlab- : [1 2;3 4]
\n" " или Spyder-стиле : 1 2;3 4\n" @@ -6527,8 +6440,7 @@ msgstr "" " " #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6538,13 +6450,11 @@ msgid "" " Hint:
\n" " Use two tabs at the end of a row to move to the next row.\n" " " -msgstr "" -"\n" +msgstr "\n" " Помощник Массивов/Матриц Numpy
\n" " Введите массив в таблицу.
\n" " Используйте Tab для перемещения по ячейкам.\n" -"

\n" -"\n" +"

\n\n" " Нажмите 'Enter' для массива или 'Ctrl+Enter' для матрицы.\n" "

\n" " Трюк:
\n" @@ -6748,9 +6658,8 @@ msgid "Drag and drop pane to a different position" msgstr "" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Прикрепить панель" +msgstr "" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6925,37 +6834,32 @@ msgid "Remove path" msgstr "Удалить путь" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Импортировать как" +msgstr "" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "Синхронизировать список путей Spyder с переменной среды PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" msgstr "" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "Синхронизировать список путей Spyder с переменной среды PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "Менеджер PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." msgstr "" #: spyder/widgets/pathmanager.py:276 -#, fuzzy msgid "This will export Spyder's path list to the PYTHONPATH environment variable for the current user, allowing you to run your Python modules outside Spyder without having to configure sys.path.

Do you want to clear the contents of PYTHONPATH before adding Spyder's path list?" -msgstr "Это синхронизирует список путей Spyder с переменной окружения PYTHONPATH для текущего пользователя, что позволит позволяет запускать модули Python за пределами Spyder без необходимости настраивать sys.path.
Вы хотите очистить содержимое PYTHONPATH перед добавлением списка путей Spyder?" +msgstr "" #: spyder/widgets/pathmanager.py:394 msgid "This directory is already included in the list.
Do you want to move it to the top of it?" @@ -7006,9 +6910,8 @@ msgid "Hide all future errors during this session" msgstr "Спрятать будущие ошибки в этой сессии" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "Открыть консоль IPython здесь" +msgstr "" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7118,20 +7021,3 @@ msgstr "Не удалось подключиться к интернету.
< msgid "Unable to check for updates." msgstr "Не удалось проверить обновления." -#~ msgid "Run Python script" -#~ msgstr "Запуск скрипта Python" - -#~ msgid "Python scripts" -#~ msgstr "Скрипты Python" - -#~ msgid "Select Python script" -#~ msgstr "Выбрать скрипт Python" - -#~ msgid "Synchronize..." -#~ msgstr "Синхронизация..." - -#~ msgid "Synchronize" -#~ msgstr "Синхронизировать" - -#~ msgid "You are using Python 2 and the selected path has Unicode characters.
Therefore, this path will not be added." -#~ msgstr "Вы используете Python 2, и выбранный путь содержит символы Юникода.
Из-за этого путь не будет добавлен." diff --git a/spyder/locale/te/LC_MESSAGES/spyder.po b/spyder/locale/te/LC_MESSAGES/spyder.po index fde45120fb9..81f6dd6dfc8 100644 --- a/spyder/locale/te/LC_MESSAGES/spyder.po +++ b/spyder/locale/te/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-01 17:40\n" "Last-Translator: \n" "Language-Team: Telugu\n" -"Language: te_IN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: te\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: te\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: te_IN\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -191,20 +190,17 @@ msgid "Initializing..." msgstr "" #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." msgstr "" #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." msgstr "" #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." msgstr "" @@ -237,9 +233,7 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" msgstr "" @@ -704,8 +698,7 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" msgstr "" @@ -1058,14 +1051,12 @@ msgid "not reachable" msgstr "" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" msgstr "" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" msgstr "" @@ -1286,8 +1277,7 @@ msgid "Enable Go to definition" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." msgstr "" @@ -1305,8 +1295,7 @@ msgid "Enable hover hints" msgstr "" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." msgstr "" @@ -1512,8 +1501,7 @@ msgid "down" msgstr "" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." msgstr "" @@ -1722,16 +1710,12 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" msgstr "" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 @@ -1915,8 +1899,7 @@ msgid "Tab always indent" msgstr "" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" @@ -1928,8 +1911,7 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." msgstr "" @@ -2327,8 +2309,7 @@ msgid "Run cell" msgstr "" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" msgstr "" @@ -2685,9 +2666,7 @@ msgid "Removal error" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 @@ -2695,9 +2674,7 @@ msgid "Conversion error" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4412 @@ -2861,9 +2838,7 @@ msgid "Recover from autosave" msgstr "" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." msgstr "" @@ -3160,9 +3135,7 @@ msgid "File/Folder copy error" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1416 @@ -3170,9 +3143,7 @@ msgid "Error pasting file" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1436 @@ -3628,8 +3599,7 @@ msgid "Display initial banner" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." msgstr "" @@ -3642,8 +3612,7 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." msgstr "" @@ -3656,8 +3625,7 @@ msgid "Ask for confirmation before restarting" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." msgstr "" @@ -3694,8 +3662,7 @@ msgid "Buffer: " msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" msgstr "" @@ -3713,8 +3680,7 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." @@ -3793,8 +3759,7 @@ msgid "Use a tight layout for inline plots" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" @@ -4218,15 +4183,11 @@ msgid "Connection error" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 @@ -4398,8 +4359,7 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" msgstr "" @@ -4536,8 +4496,7 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "" #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" msgstr "" @@ -4878,8 +4837,7 @@ msgid "Results" msgstr "" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "" @@ -5796,9 +5754,7 @@ msgid "Save and Close" msgstr "" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" msgstr "" @@ -5839,8 +5795,7 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" msgstr "" @@ -6421,8 +6376,7 @@ msgid "Legal" msgstr "" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6435,8 +6389,7 @@ msgid "" msgstr "" #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6645,9 +6598,8 @@ msgid "Drag and drop pane to a different position" msgstr "" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "పేన్ ని డాక్ చేయండి" +msgstr "" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -7008,3 +6960,4 @@ msgstr "" #: spyder/workers/updates.py:136 msgid "Unable to check for updates." msgstr "" + diff --git a/spyder/locale/uk/LC_MESSAGES/spyder.po b/spyder/locale/uk/LC_MESSAGES/spyder.po index 35347a11b97..b474841d831 100644 --- a/spyder/locale/uk/LC_MESSAGES/spyder.po +++ b/spyder/locale/uk/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-01 17:40\n" "Last-Translator: \n" "Language-Team: Ukrainian\n" -"Language: uk_UA\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: uk\n" +"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: uk\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: uk_UA\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -59,14 +58,12 @@ msgid "Dock the pane" msgstr "Закріпити панель" #: spyder/api/widgets/main_widget.py:344 spyder/api/widgets/main_widget.py:448 spyder/plugins/base.py:204 spyder/plugins/base.py:512 -#, fuzzy msgid "Unlock position" -msgstr "Позиція курсору" +msgstr "" #: spyder/api/widgets/main_widget.py:345 spyder/api/widgets/main_widget.py:453 spyder/plugins/base.py:205 spyder/plugins/base.py:516 -#, fuzzy msgid "Unlock to move pane to another position" -msgstr "Перейти до наступної позиції курсору" +msgstr "" #: spyder/api/widgets/main_widget.py:351 spyder/plugins/base.py:212 msgid "Undock" @@ -85,14 +82,12 @@ msgid "Close the pane" msgstr "Закрити панель" #: spyder/api/widgets/main_widget.py:442 spyder/plugins/base.py:506 -#, fuzzy msgid "Lock position" -msgstr "Позиція курсору" +msgstr "" #: spyder/api/widgets/main_widget.py:446 spyder/plugins/base.py:510 -#, fuzzy msgid "Lock pane to the current position" -msgstr "Перейти до наступної позиції курсору" +msgstr "" #: spyder/api/widgets/toolbars.py:123 msgid "More" @@ -195,27 +190,21 @@ msgid "Initializing..." msgstr "Ініціалізація..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." -msgstr "" -"Неможливо закрити попередній екземпляр Spyder.\n" +msgstr "Неможливо закрити попередній екземпляр Spyder.\n" "Перезапуск перервано." #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." -msgstr "" -"Spyder не вдалося скинути до типових налаштувань.\n" +msgstr "Spyder не вдалося скинути до типових налаштувань.\n" "Перезапуск перервано." #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." -msgstr "" -"Неможливо перезавантажити Spyder.\n" +msgstr "Неможливо перезавантажити Spyder.\n" "Операцію перервано." #: spyder/app/restart.py:145 @@ -247,9 +236,7 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" msgstr "" @@ -714,8 +701,7 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" msgstr "" @@ -1068,14 +1054,12 @@ msgid "not reachable" msgstr "недоступно" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" msgstr "" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" msgstr "Натисніть тут, щоб знову показати діалогове вікно встановлення" @@ -1296,8 +1280,7 @@ msgid "Enable Go to definition" msgstr "Увімкнути перехід до визначень" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." msgstr "" @@ -1315,12 +1298,10 @@ msgid "Enable hover hints" msgstr "Увімкнути підказки при наведенні курсору" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." -msgstr "" -"Якщо увімкнено, то наведення курсора миші на назву об’єкта\n" +msgstr "Якщо увімкнено, то наведення курсора миші на назву об’єкта\n" "буде відображати сигнатуру цього об'єкта та/або\n" "документацію (якщо є)." @@ -1525,8 +1506,7 @@ msgid "down" msgstr "вниз" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." msgstr "" @@ -1735,16 +1715,12 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" msgstr "" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 @@ -1760,9 +1736,8 @@ msgid "&Run..." msgstr "&Виконати..." #: spyder/plugins/console/widgets/main_widget.py:191 -#, fuzzy msgid "Run a Python file" -msgstr "Виконати сценарій Python" +msgstr "" #: spyder/plugins/console/widgets/main_widget.py:197 msgid "Environment variables..." @@ -1809,9 +1784,8 @@ msgid "Internal console settings" msgstr "Налаштування внутрішньої консолі" #: spyder/plugins/console/widgets/main_widget.py:510 -#, fuzzy msgid "Run Python file" -msgstr "Файли Python" +msgstr "" #: spyder/plugins/console/widgets/main_widget.py:569 msgid "Buffer" @@ -1930,14 +1904,12 @@ msgid "Tab always indent" msgstr "Завжди табуляція відступу" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)" -msgstr "" -"Якщо увімкнено, натискання клавіші Tab завжди вставлятиме відступ,\n" +msgstr "Якщо увімкнено, натискання клавіші Tab завжди вставлятиме відступ,\n" "навіть якщо курсор знаходиться не на початку\n" "рядка (коли ця опція включена, завершення\n" "коду може викликатись використанням альтернативної\n" @@ -1948,8 +1920,7 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "Автоматично вилучати кінцеві пробіли на змінених рядках" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." msgstr "" @@ -2347,11 +2318,9 @@ msgid "Run cell" msgstr "Виконати комірку" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" -msgstr "" -"Виконати поточну комірку \n" +msgstr "Виконати поточну комірку \n" "[Використовуйте #%% для створення комірок]" #: spyder/plugins/editor/plugin.py:729 spyder/plugins/editor/widgets/codeeditor.py:4434 @@ -2707,9 +2676,7 @@ msgid "Removal error" msgstr "Помилка вилучення" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 @@ -2717,9 +2684,7 @@ msgid "Conversion error" msgstr "Помилка перетворення" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4412 @@ -2727,9 +2692,8 @@ msgid "Clear all ouput" msgstr "Очистити все виведення" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" -msgstr "Перетворити у сценарій Python" +msgstr "" #: spyder/plugins/editor/widgets/codeeditor.py:4418 msgid "Go to definition" @@ -2884,13 +2848,9 @@ msgid "Recover from autosave" msgstr "Відновити з автозбереження" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." -msgstr "" -"Знайдено файли автозбереження. Що ви хотіли б зробити?\n" -"\n" +msgstr "Знайдено файли автозбереження. Що ви хотіли б зробити?\n\n" "Це діалогове вікно буде показано знову при наступному запуску, якщо будь-які файли автозбереження не відновлені, переміщені чи видалені." #: spyder/plugins/editor/widgets/recover.py:148 @@ -3186,9 +3146,7 @@ msgid "File/Folder copy error" msgstr "Помилка копіювання файлу/теки" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1416 @@ -3196,9 +3154,7 @@ msgid "Error pasting file" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" msgstr "" #: spyder/plugins/explorer/widgets/explorer.py:1436 @@ -3654,11 +3610,9 @@ msgid "Display initial banner" msgstr "Відображати початковий банер" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." -msgstr "" -"Цей параметр дозволяє приховати повідомлення, яке відображається у\n" +msgstr "Цей параметр дозволяє приховати повідомлення, яке відображається у\n" "верхній частині консолі після її відкриття." #: spyder/plugins/ipythonconsole/confpage.py:35 @@ -3670,11 +3624,9 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "Запитувати підтвердження перед видаленням всіх користувацьких змінних" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." -msgstr "" -"Ця опція дозволяє приховати попереджувальні повідомлення показувані\n" +msgstr "Ця опція дозволяє приховати попереджувальні повідомлення показувані\n" "при скиданні простору імен з Spyder." #: spyder/plugins/ipythonconsole/confpage.py:43 spyder/plugins/ipythonconsole/widgets/main_widget.py:475 @@ -3686,11 +3638,9 @@ msgid "Ask for confirmation before restarting" msgstr "Запитувати підтвердження перед перезапуском" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." -msgstr "" -"Ця опція дозволяє приховати попереджувальні повідомлення показувані\n" +msgstr "Ця опція дозволяє приховати попереджувальні повідомлення показувані\n" "при перезапуску ядра." #: spyder/plugins/ipythonconsole/confpage.py:59 @@ -3726,8 +3676,7 @@ msgid "Buffer: " msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" msgstr "" @@ -3745,8 +3694,7 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." @@ -3825,8 +3773,7 @@ msgid "Use a tight layout for inline plots" msgstr "" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" @@ -4250,15 +4197,11 @@ msgid "Connection error" msgstr "Помилка з’єднання" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" msgstr "" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 @@ -4430,11 +4373,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"Компонування вікна буде скинуте до типових налаштувань: це впливає на позицію вікна, розмір та віджети.\n" +msgstr "Компонування вікна буде скинуте до типових налаштувань: це впливає на позицію вікна, розмір та віджети.\n" "Хочете продовжити?" #: spyder/plugins/layout/layouts.py:81 @@ -4570,8 +4511,7 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "" #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" msgstr "" @@ -4912,8 +4852,7 @@ msgid "Results" msgstr "Результати" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "" @@ -5830,9 +5769,7 @@ msgid "Save and Close" msgstr "Зберегти та закрити" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" msgstr "" @@ -5873,8 +5810,7 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" msgstr "" @@ -6455,8 +6391,7 @@ msgid "Legal" msgstr "" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6469,8 +6404,7 @@ msgid "" msgstr "" #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6679,9 +6613,8 @@ msgid "Drag and drop pane to a different position" msgstr "" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "Закріпити панель" +msgstr "" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6856,28 +6789,24 @@ msgid "Remove path" msgstr "Вилучити шлях" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "Імпортувати дані" +msgstr "" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "Показати змінні середовища" +msgstr "" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" msgstr "" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "Показати змінні середовища" +msgstr "" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "Менеджер PYTHONPATH" +msgstr "" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." @@ -6936,9 +6865,8 @@ msgid "Hide all future errors during this session" msgstr "" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "Відкрити консоль IPython тут" +msgstr "" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7048,14 +6976,3 @@ msgstr "Не вдалося підключитися до Інтернету. *ptE=>1|8sn(2xH zcn$sVx%Dmfb{wyhF5EmAg!ySO)Rt#p8p`vnOEEL$Rj2`DZT$g!kMa@JfM1~ox@f(L z>gO(I!W2}y7nqyz9cQ@Xq=P(|8jIMnKkC79wp;}@!#cLUF{-2HSOPoP@@T9`*=x&t ztcR_iThB?2f0YZR^k>une_}~|iv`esgyRgvM%Wxb!S47F2Vm0($Ek1ySvy)7kt6=|0Ceuq%OSWqgHwtobl?rwA z1j}OZWKv28i%~PZFvXq@)QtV6I!-Rkk2$as4xxQh)DrHR<~SAbGgSM#7=XVc2gxZs z-JG5|UM_TF1!@tXP)qq1_1>sH*LRG)PGc@q(FSv45A$X+pu8Q^;Ag1!%Tas}uVXcx|6jQ%PDP&ij?)n9Vo4m28gLUf#ba0kodqVq zlBi5IM+M#ymEs|I0!Ls*thCS^&w8l-qEOE*!F-JGtmQ%}K7?BH@30cyLJgRCk=gat zP$?XaN@=Y1Dk>9y+xyiQn{z)570@QkjGtmAJc?`ZEPAzBf|i)gGS)g5m9q7i8b3jG zv>lbw{g@TwQ8T=N8sJA;PQk{M|3I~`xzr5&KB~VCm=3!xCI4#BhYEEx1k>V3)LM^n%PEF%J-l$^CN2D+oZJt`BQdATUZ#Stuz zNvN6Uj5g(pSd?-ftb|_FCfbX7aeZS=!km;-tu%WfFRH)x*a1Jrc6b2;u+S>Ax4g}{ zs7S>y)XdgdH=|~<9kbv!sL%16sI`BI+Dv|{O-l1%4$8$a57w}@!+ewnp_XnE`r|z0 z)OnqKTxfvnSPK(TGcC*(_00gYQ~nqg;3De^Q~+!3{T-N_a=i7Lt$$$s8?}eBtu;$h z9JA{DSK&f8nxI|`U2TH_r~yV;y{Ht=N9~18sKC#oHt{XgOp{TY&%Ms{ml3t*c~P0G zfXY-u%*^;sXD$@L093<~s2R_}hPV#Z;Vo+tYBxVd?Tr_Si5)HMk#!8gL704ZlV$(L>aL`8S$O_@f4@h*Pl% z>i*YQ2rpv^e7uoiwOMk-nADWFHbBj=J!(n1qcRX=>xbF$C@f9=MAQ;&L#22>YM?K$ z0N%mE_!hOt3T`rcrRyfIIftQCDCHxp)35>MXjI4FqcU{|i{MMtlI7iOEQ8t`_0WSI zQSXxo)Do=63b+OH<26+K$6hWpaOzJ?M|n^W2B0=yS1gDFZFv%E;034@ZbWUmuTV3; zjau8Mm=|4JjQKGY<*KOhYGQHpw&Ef+7h$M@B2g(@gi8HR)Y=_E1@r}`!ON)3+(3W) z)t1w3HTBt1?F*rnt}OnD)lf^2Wt(pTUZ({YTI(=WM59rGtVRV8hYILRTfS|}k8Ig5 z)(n&zThPuQTjDU(9*9G|c&=kwyn_lX88d6!|H*}iiZt8J?k=@Q2~uZ zrFbT)<7m`Co9+G2F&*U#sJ-$%s-Hhm8OyrEWV`^DWqilOMQdz>t#Bo(gS*xTr~#kb za;i@q=Q!nzs5L%|zRii_DZfMyj@)U!D=x?P)eb9S#$D#?eO>hOwsi(_p&Nf=N35`$ zje?U<<+!i3j*5iz_e#hJ0o^9El2eK5F0{)|05UzlN3Z zH`I&BbI=^ahF&i8s_cZC=^#vx5vVmCk2^D@|o@^n;6zru=` zh}xXFj+q}y8=^8Ah04$rRQm;3Qs;jc7x}5UhDym})Lw9N#bJBdMl|YRbgkJ6Pkz8cODX2}h6r14|`~V-?`&CYv zc1`ddr=kn$jo9V1No^peqdW{1@F>)=n}>R}uR!gcqo|CXI&IJY*Hm1j;s;b@v0s>o z52KbK9+mpDsDN*vI!?6Z$Eei5we`8aG^eTvR-nErs^0;qiAJCYr+(=*Gx&rGb+``| z`7zWQUcuD(1{H|wjG4iEs8f<1t6+a@f=e+BZ(uqy)a)xW({^8*rR{1B!=kjC>9rTz zF&!1(qGov2mhYefNI}iuH`H$a8#Uu}XH9(()Y4VNblBL|w?xgjCu(zlgmv*_)P%f8 z?2Ru`FQOav#skbr`A^hl%lM7ys3>ZaRzY>p8WnIi)bR?!k~kc-38PVg@5eHD8Vlni zWIV6)-Z`@b9@M5NkA7GU(_;hF44R{6*u$1T!pfA#qh`Ddm8pX`5szX9to^OADXPEr zsP;W@iOzq2F6LA59RGupzcary3OvvIo$^iGj!iDG!SE3(BM&Z`RJtyinbpUp)DJH>w0<+;* z)If8vE5@Lndtr55G2byWqE`df9EI6%Eh>=RSR0REeSD5edF89T zW^peb#4^|TWd`2HGn|H9*Ubwp@`lOOnj7T5v>H%R5dD5Id!QuNpj;jmc@QciVW^CZ z!?HLN73cxfo9`P`%I~5Adx}%A;!X41ew;ucC$Tv7^?oA%dAaESlNmS)bqXe-X1okN zxE0mzYgDRlqc&?2DzHbW48F4Uem|QRR#sF%MbNjoQG28sY9diydocsG6iZQSx5n1* zM;(`wsN?txYR1n|OW}9RWGE-9zA#q8YN!bW+xufs0ZvBknZ@Wq?um22Mh!d>HQ*vt;2Tjdtb>>t&tpfO|DU^mZ~1M z!&aCUS78KhL~lbba-^8u-5Iqup{NeVV;Wq5sc{*m!&RtE#8`KsIzEDGe+t#^E7Wrr zP#L|7ewcv8@V6B5uL!fiBus(W6*&7FNG8IR$6xMreA`C*!AO!Vbq%Duf%#^2N zMqFm^Z?wju&i!62hi6ckeTJI(TclsF<9=d}$$MCy8(C2uH$cs(Eo!FWwmbzj<4^EC z`~oxKCDaoAg4!dmPy@Q38Z)6Lk{1VbQ<{1o4({5NW?8^1I^T(&}$Kf+cRiOR$YR6k##_R3Yvf_GlB{%Y`) z3Jv%Qm6{B%Od#1%9ePlEA^?@@YN+R$qdIPn>bN)h0zjoc64hT6>iAAV^|KB&p;#{$ zYPcV@Sq|e~Ja6kKzBUg`vo6H8++U5YF&VdFwZF`d=Rct`=z3$8C@a>b+yvF$i%sxb zY^idVw`T2nq5|oQnpu!7hoY7y0<}k$qcRYKO7Uk{8sA|VEd95cX=l`P!%@e0DJta$ zaV4HXPKDR$`_8;NH=_o=j!NMz%!^5=)V{;qn8|VZ-iZFFC98u9tTmRz?wAKBq5@ih z>hE(@0Ozn1{^%=v+0ibSZ@1P*4>!7_KaNH1?scfZ&Z7prZcRe1{R?Yqw;3oK=Ab@5 z>bVNGz6L75hFA^U5+;w7alIuum);?hNu~IM9nxD)xl`Yk26r4H3s$kA?$(Q zp#m$B#tcvm)o*Q7Cf-N&-wI1(?=&v2FXHJ`XsuVGI@pI=^HZqfa{+y+M-7~WCGaoQ z8W&CL^8Lo74SFa~LG6jn);*}q97FA)3+Tbbv|cknT7Fp1Kt(20%JX6&EQY?6qc7#C z0RvH~pN9%`6)Hp9P&17~51vGA#@p7HsQ$91clq82#k^c-h7D1vZH9Uv5H*7+REN`1 zOEModgBVom52FIRfC}Ii>bXa#{$8ObmebGlQv?fAE@kyLwKoD$OAvyZ*%Vt|h#Dvs zm6;RPv#5cuSnr@dJD#8d{2dEpMn18$WMxoG&=5;uCnR96GmZ<*Yz`_l>roN!we`nP zo9H5{qaSSf5vu*4sKC=_H07eGnODWK*bcSX#-aAmW>kNlVKIIF$8%Afirc7}rhd=m z`*h2X%0ydKN_(O9#2{Ng4Hfue>lReIBUld4Aa6M58LFR}nM?+oqT02?3_Ab4xTu4{ zsFZF*y@2+hW_ZokC!sR)2WrMnX0zt`un5Hf)ZS@{A7CIVz-_3dJBHdLXKeX8dNrf} zaG}(^MLm!?i)mO4mGUa6`sS$J{vm2cA*j!c(WpQtpx*flQ0@1j`aOeMg6~lidxxFT zKP%^70gUA@Mk?b9)C|v}2D*w$)g4s3N2r-*%x2cGEUJAq^ldI{CsaVexEjZxeniWj z-L$WOYF|4$=U)-Fp+W=oK@AjQ%QI2Cc_}L8J5Ygrfz|OcYRS^&FuOlDYIo;H_4B^9 zKX#=&5jD;=^zAt>7m6%3f2N>;Jg5h2qGs9^Yv3rXk2`S!-bWp;!MR+%A4FzgU&?1t zsrS!qmaZ16pSHLIhhaXbzzwU^?!A8gKw=2E(iq zP^q7b+C)206NpCz^bD2J)CEnT1yS`4Q5oro8gDR`Vti+cy|Dwe2Tq|1wa;O06qF%XuP~!|jO)#Pe=U;0#j|xS! z1r@+))TiP()B~4M$K@85Lzl-K+Y0FW^h6ER47DUfP%|BmI^Hp;=l7$Q;Coc29(uek z=O!1gsZd1Min^R<_!|b|onkI08k-b%IbHB4oQ#$H&6{jL)}wp_m8qO1Oy&ZxEakeW z(=-@2;ubuC%}TnQ<(S4>%H{jR!!@YLvX*xF{&~D4)~4JK^+H*NO6ljQ8D2sSco(%) zFHy%cLx5?QANj7~1mHO8E0=Nk{$XM#j-p(kta$}{7jvQGl!#i(^yN(Ia-f!?Hfo^8 zsQcYeOAv_K3ky)2Z7FK0*4g{p?fvgjf&YY>(0$aB`Iq-Cjn^s5g$AmJy3y3!a9X3* zv^Oe%iMD>JEq{Va@ex$ViKrzkQo*cwZPX@hg{g1|DpR4TOijd$I{yo}@Esr2F5Qlr z*?!cL#Gz((9+iQcsLhjrTH9w>2P;){IUnOt9F8g2gC*!y$>n@Q`9NitvxoA>Rb0*i z%H^xMoIyJOcetpGEvma5501kHxCR?wnHpv$A*hehnb-vP;t>2DmBBtW&GW&ymhud& zg2ijOeE*!^4VAGus8e?c_4jAaMK0>%pxWkBZ4+unKcL=-IqH}n>5E@vUO!(RAHW6pmF7q#Da`FRLTW7fsKl)K>xW_%En zab$DTzK^%1`Fxg|$p+M?RXl!%H?cD=XyxKxtN6!c?1!UTyPWBG9mnE_ZA>87un*;O zZC$=U=beMf*pJv8TedS9UW`$0K7TK9ahe;gI*5G@%2mSFl z`hNd+hYKBt$EaPJzK_}EjWLpP7o3J?QA^XdulbYcHpFSD_sN&2kLRn% zOr86v7ts^c@%|kP>ikdbXExnN>_Pb?>W!DTzlpp&YHu{gBG?Z#Gq0_mjS65JDuX*w zfgQk-_ysCM_ffC<=csnM13CYCWftN>57a~*qvohLUpOjd(^1E032G)MQEPq%)$R!v z$Fu`XyE4}9sD7uRmS7obZ^U9zJT-vxua17DLht5G1I>p|5mdtuFhBM|&3G)f!YQb= zK9718|9}cC2{p5as2M*;_4@|ZE_0A67ep;lg&@wqHc@>ll$sChjUlMD8-x0MUx3Z= zThvnI2sY1E#sJE-QSbbLI25m;252x5(~bLmhq;_Pco3Dr=rEJ=4X8k3Q2`%7EzN0EM$g*v ze~^iKokT9QHcwG0dWV`>_HZ*(f7I@-h008Q)TV4{>$_P$L}jeMEe}Q=w@_4o!%=%; ztaUP`*605mE)>8*R0r!&Dg6X}yB!t48B}2BP#yk^nrVV9KSmAk0`)#|hMVW|A#Ylz zFsgk~)Oh7ouk&Ar3k_5swV9fr0vU#CI1-h*DX6tvg=)XqmbariK7b1B1nT*(PywAs zwf`QqBtN4j^Z>m&pHH~Zo6Q+vQd|)AU};pUE8B7dR6xzK1a?I|H{RAqqXyW5x7aJ6 zA=}1T66tbQ;@>09`yzU@8E4mM&cD|52o=5*qh@fa!*tSy<52`!^Io)$5P|XH=;IJmGU9ng!j>(HJm@ekzf^S{U1=JcAoo9|$Y19MlP&4m`193KL^E^VW^()k=@XR;Qmq#7zYPQ@I zbvjyOCG3xCzZkuhxmeFdDZFHFytJlSU^>i>I)3?Vxj3qWGPYb3wR8<^{WMeni&2{} z1{+~KDnoyx#tT@;`M=0T$A#vrSJ6c-=TFMLP+!G5E;gHT4%VQ&2bG~D>myVkzoR}o zTuaP1shp^GRZ%abI;i_CQK|2Z%EXu@>`FxvONGAoUqYRN$EX?STxvQhjOw5SYG$=i z?OUK`I2d(or(#juhiZQnb&8U(1m@?fwFYj43UGv%3w1aaHIrGm2bZJP{)6S_3rIKA zfJ;#i?ne!91ohl^)|*(8auO=AOe;(P0jL1`pe8&Qi=g*_t+@QTqa?z&aUYLvW zVAOyg<6c~Z+Ek5Kn)}^Q_d`(udaW}s9p(A9yc`wqT2t?Jw%dw*s7#!|7I?vy^Q5iJ|N2mdpST~@ScBfCy-ywVB6lUVadE4M8%)_xvM12~* zU2T>m?HbcA59+g^1S(^dPy_X~^+QpcZ!~I8O-D^&hjkzN>HHt&qA8w6ZJu;%&3D28 zR7QGRLs1_(6Hqf)jyg^oPyw7nE!9g~&b-d$cqrFHWugyiPfWMQpjREnbD@F0N2NXq z^+3h-=Evmj*p~8s)Em&*V7}Gn$7&QCpuTEFqGmV-HRFk>rPz$xyoYT0I%>S+4V-_? z2vj-WF51T}%|F=n7TsK8oTJEE4V zM~v5W6ikH%oQUdhwRM;El=Uj=!6aLLj+#l@O{P9K>ia}-Tdrwsit4u`>XZeco?Gs< z4Yr_Wv>z4ex3>O@^*`2!sEoWqeWS^;*>qGAH9!q(OVo>}H!8!6Q2nm9#-K9gjkOKV zVQVU`*|O&o^FSGEWmLqqP#I`}%2-#7!V$Lq0s58{HK2Qo`Q?@$Y7f*!4crUav|eWd z7izcz_253#0LO3`p0)RDY&9L!xAsPLG#=G{9V);bsD6&x@|URouUUVx{)$EQ`JZAd zGHx>+Wk)SV5mZWRSzBWT%6(BkoX$seyv5$%X+4QLMORRP6^J$cl}2sK#;ATnq|W~Y zTd@!|@FpyW$4~<#V*oxzr99tu)2=$Iqjsp72ck~L2+V}bQ7PYy3gk4Z{Y6{J zIo&SKzXr~=%jNseWQ$=t%8{t0IE@P6C)7ZRsP+%7uTbsM?lu7wLEUeRN_{)j^TSZj zk3lWfL|b0qX7SZyZK-a1!;vFR0Z2X79WAm;wDzU%kp&N1z6Zvz|Z&bjFsi z;2O#|(YJ)7_L}oK3&(TgC~D1W>@y!OZBSpE$6yiMhx(p>87Jd!s3jb}-@HFoqTc-p zs8ir~zy#U}dr=;VTEdISb6)4IZBXJfGeC3HW*Ur|$yC$~=AZ(Lw(hd^U!XE{3Dy2K zDx>%8ednMlXF_GPB&z=pFs;siAQ$@d8se+q8w_foNK{9&Q2}hS^?R&mP#yk+TDsS$ zKvEwv<(zoS#pXu!^Cr&aoWfj(&HeAucm8j4p^l!T8fHIY)}}0~+yKjAcT{Sp+4}9M zJ#iWp;P^E=`E*oE>(=+y%!xljjRS+AlVxQ)91#Fk%R4CQpk zOkmrsr%}i6Cv1yvQGvETZXAyK_PZR_-!atph||Y8|GN0uR{Uwr8gD)(%b?ckL+cn+ zrk0{Iv)`7#MJ?HV)W@rS*RBawM*WD^0F{Z}7=huaU+evPg7dEqi<~qwXn-29KdOEL zYHin`GO`6V@Ilmo7i>Af-hYdlVWv|ifSlH%)(WW1)J2_w{$4KhzyegH%TcL3fEw_+ zEhnM|O!v8&Ng>n>E1}xex8=5|?}WWj15HQ$24#o!xb*^RT<>izG}C*ifzzHg<?q;VeeP^!VKIV6-Ym8DC&h2Wj$%@(|lva><3#T0_kfEq0i9oIC1iXL?QGxXR+O|WTjtJ`vRQuJajP3Ht`H$y9YknTp zK?-V(|3sxQ%UM%j4YdS~Q2};Gbu`-EpMe@+DQXF}q5{5b@Bd)SiKs8D53vN}JH@{- zug;bjLU}pH;yYA8vFFSej)T_o=%GFlm6^2Pnjf)>qkiBhi^@}EIRCowiEXeCmAVtCB}qXAmgc;vFMz6VhN>Tc+TCMOGo52yY+Z?3q7A5V_Mo0S zjvD96d9R7&Efwk@-39aO_ROeM&aFipf-=tL9U+l9vl@vX5-VLexOtpd$Ys6<8ALtJovd3{rE3l=A$jfXbuV z)kHntz}nj0?}1vf0jPGv?0xS@+h8)Plt_v*~JJR>}>m z?J*PO{;2N@VW=fqfa+%>YWMF#zE^mi1TJP%QTTh8GXi6ACg#6k{$yek&gF&CrTDRAv^U23}>|irRz+FuOkguXCXpC))<8e=^@(vY`U$ zh3e>696%rsQ3JQXWdaIA4LlRIBnwbW7lRsTA1XsvQSA~?{XI}w=l>5b^xzxYAj>Z% z;@qhE;;08}pngQFj~ZaMb(Oup9o2p>`o8_5_RK|829i+EJ-6jl|Ka>AlB`_lMt;=H z%Gh#C)WAJa9rU;L!%#1tk5PMOHTnidwfn+)3AGn)Ss$SKdxbh(&TV`Cv)nch7DROv zfaoo)9OfXi$PTXjNI+hzS0Kdd?_#5)A)hUo@+BHGVXa#DQue0t!otDp0OYt2l@E=gW z6;DJ>G`%;;L{b1tQc(@{8_Ew+UrI-#1{jB0qRFU^7UOALkJ?;;_sl>Ms6Zy7K86>e zmiT*AMsK6~`xBK}?^`a^alT~JFaULsZu8rlX#pZR-~yfq9*E_Qp0-;p|35dKk5NE}&BNGpggqsAKiU))&5S?pH=FSyNjM zLA9TNdST5*1#r~*x$i#b?<^OZ@paUucL&y>F)yn9O6wN%DwX@W(7C^c%EWV9&hnf2np^>ux}nwx)GId1mUrP8 z%AcWr0a5O;Y2U%x-P+e0j0!OHG3Q?w6YPz7s2MLsrD`3P#l5Iw^fM}uWYkO^+wvRK z_xV&$OvjC|H02JcpZ}w5c>!v|aj0=`KH>c9B83X2@D-}#Y)_4at!1sXPyv2`dagaH z!+xlMC)@kWtm{!r5R2;f1ZpY1#on0UwHJ+_nZMgvk6MB^sD^o;n|}qXj9Q`*r~nq( z@@iDaM^Ksh3KiIS)F!=!6)?^3d=|KPzo6Q+{=+=y?Zky5?}xe(ff^tR)8JGrg0t=Y z?bf}h0pd{Y|6}V@tWPkW`WL7G5B_Psa-BvU$46LE=fCg^GjKc9o3AVC!(}*XCiAf& zuCwJ^s16^Y0)LHK>r5|AxdtkOEm7@)tr4jAi5IKmeDu@j{}){7f%B*gq@d2{6I-tR z%5>BSHPg1X+#S_E2o>m1TON-JY>F)}M|~EoMQz4ys5jzq-Pid)#f2jM8ufy?rW^RP z^)Bi~a~~Bzrq{+isCGqBo2)dh!3MVeM^yVe)(2Rh@*k*-R{e|fKY@!#F0|R6pdRph zW4`C-Kz(dBMm;bd_1*25Ek8pAQ01+$wzUzepXR83JKFk>(6@G@dI-1RH&_e9-i4qO zUi^Y;_`o)JX3K83Y3PUQI5+Cp)k4jvzb!{uV^9NqgUZBB)Wq(hcKto z50tgm#p;w>qXL?YigXt0RlMBRe~#MCXYKu4=-Z@Nj`~cgO=jw#`tOMfBm}ht)6IRa zvxW=(HtHy9pe$+3zy+EZ=jyLhidn`txuQEJf8=(WTjB; z8cCi1wzfepRKy>l@0g$-ScK|uJ8GuKQNJs?g6ilAs@+>_?)0Xh>rm}?qXs;IYWD}~ zMU+0XnMiq5yC$fCJEPk5xAopgF3M0b6}48ou{EAUHO!sGJQ#o~*RbW5s7!UU^`W-x zwJt|x_EYPZsON8?0(pWgIluq6H&SOcDawP2xHhU`OH^RpP&11_9oJ}Ee-^dY38?4Z z%VsQydcGWLiE5()>Vf(=9)Z5!|4-mTkxfO-Xa(xQowyl~*!n)%%_faR-5+P2X`-mdDgN2vq043KzQZK6emeZM-bk9rSu#HBbC zmBM$Z8OBLT4PbakT{H*;Ke+ge?9ns3U&Mv^)&6iLQ zxC)vEnNbgxM>TA39e_&RDC=TW$GcFe{Q~vI{1KJg8gPTXe-Il`K8`%+b^hYQ z_r@w>Iw+5Npc*OztxyAmTBA@YoMGL9zCcmmaBiap{ta~u|3>wb-eW9-y59zU=f5`> z>R=#hATMfwdA9tiEgwcL#SPS}_&%P)zfc{1Rn+YybX@V-M&9i%vZ{^pM-iZ zY_OiQ^?#r;le4txznHZl`u_g6Hy6GXTcgnBBE>k72WJJC_d~ieZr^{P&=d8weLrgH z{z5(PDQiwgdDLF$ipp4T)KY|_ex8Uz{RDKNEazVhTEP@zZz%9{@Ap&o35igW<# z{vc}iK=BfWAhxyJ1bh%9NjaxzHDk;uYM!fBWfyy(pi?IxIn< zif-SZ<@cz>T2szh+3ovn`S>bk3DQ(GySg3fJK=B~g$qzGq}0_+;7w6W)g6_|3D^$3 zd$~~L?@*gBZFRTrKS(T&IyNhd;$lo+!@OWtqdwIxqkc|!hkCJb|eh7M^2)a;0l(*77g9L-}F%9o%v&u6HQ z=X1D&wa?nv?eKeJ=h*uur7=xSfZw1#q>`O~WadJ*kM?czhIJyN)*>9`s;r`#45*mBhKTTsXCFsl7E)WG*q6Y;*_LILFKWo~#- z50*!r-$u561S&HVZFwtdMh9&9JA3~o>ecIFgQ%Z^SP?6t2JVN&G1T1mI*YhaB%AGx zGgyLh3M%3(y^STTbuobYj#wSX+xmUzq5QQiCtLqU4V<%&+xJ(tMN$9wGzfF){Ey;7 zshx|;#6s%|^sO;!33l0X9IC^wQSGmxUaddbdZ(|MP$tx;WJRopV^K?S5cS+;^!@k$ zKX4I2h11XN`)@rX(Ki6<#dHnzmFqdGgAD!M&LS*{I+k&$4j)*bSzqC3>YYH--x%vO z>tgi%{J)+Hb?_PLQ|TBg^^Y(ErW)Y({rWvKcBPyjBXKzDV>AI{Fv~!<@4uGchYGx9 zkf|Sng^!6txesF~MArM?Aft$Lt39E=Kd2CChBRJ+xv zfOl9=S-(fMe~4Pb=cwo2+WHJbyrx06A;!F@hDA_&BEZ&{w^p)P@Mr~up8@`tEX zF#z@ac+^)fFZu#T^}pKsiEb#u-PVJsnH;y}E7qH+2XCV~x{qYkd13FrLS@JaG5r-m zWu_$R=YblwzMZwJ)!Um3ozsD+h)3HS<4^-lw)G2ad5OKh0kt=_qJBo)hZ^u(d;cd} z{uLGYGgKztU=hslk#AGyaLRC@4jNlO{OCabp}T6N9uyK9eBgY{%*+81frCAPVL_gt zi16W_;elbno{{06Ku=J}ph3YA!C@mk!^0y(Muvok9q6=oTE?Xpj}=HaJUn6~t)jP< za}_<1e5_T0rDbmnj^0+zY>&lbKH2f!wp(*tC1}(3k?h#JSuQ-uqQG&VsvnXCy-k~LB6a6hI&H7 z2L>uZk?JEdI51-15YM3S2#-y`u<)Q!p*4deE7Thh(Ln#daaa_CDxQ!qPgMA*2v1}b zaSij-8#%H;hn`(pv~Sm;ahDeLDpHKzSI(8Il4oFGm_iL1rqmPc5SGRhGDuIC3SwGA zeXBMq5{3j1s}NniuItaDfe|A|4fl)*j~E&fHkbvC_;<dwQXf`TJTMf#%lB~deFr0CXNTpJ2%PEErV zi)TzA8-{xWLrH^L#QojHb=0pqDPpPZ3OZfGd}Z1V3m>fou$wL{oF^jwZ5Ma2uj`|9 z(TfJT?iLLX3yt!yIRb|Tdxi!_k?J9VBRzp+A#!8{(~FGzXs~N$svQ4Y3M+U*B0a&w zhL4OY=ZW;it^`JUG^DaaEGD@XoSv*m^oYtX|G1n(T^Unns9C;JWl!Zwb*fd04hVM@ z_VbmiRIO7vu0y!%*Su~|>FDaST%}zBas6kxzRr>M-j3*`jmP84uXSxspZT9nkhEfE z!m&efKY!wi$y6X|?uw*kCrrDF!_9Skanz(Rf%FKmvU&p(mU3S#ypp&j9|3t9sq-#wcbv1oUbh8_- z-i80SE)soh;@01A75%59xHCVvy0{7_&YPYzcjvzcBWc05#94a-x@`? ztmXF4Woqa>X;xgq;bW#*-1!>r?P>nmP!sC8r>6H`8*ArZ@&8I2@iP>CL=S7@D&Qp& z4?~!sJ?1UqVSrnA)(XT~Qj-|MM2ph3i_Fnu1?t2m&6C;8-}VDIwCKxsbQ3Tj+QN`?y230EfW}E0+UenObJaNO(grf_X zUdNH;2L$SaDLSy3+rN}2anF(DeKAQZ_Ye=AC+*sNFJ^bjvPnsMCVNUJoR~{jY?Eky z{4C|Kq;?!xp?l+gO@l*&M+UbKYZ4kB=}Rz)OqsMg`Q*&#eeGPivL>xKl5}8s(z-3l zyHBtt0nz!IyO;d4-us%nC%XT6vs7>8j{fJyl~!*4ZT94<=#y(7H&;AJSpMu4*M?2-OXJsUG&jD z?!9FgiG^a-wb+Z}6Bo@-+!cLqC++v&+jlH+{akIyJu_04Y>5l$>&~C5oeBB>;{|Aq2t)&0} delta 29231 zcmajnWqg#!-v9C4MFI&Lq&SNtSO_GzdvS--;t&F)K!79^XK}Y;DHQi&fu<1LiWDf; zLQC-i2?2^ztjPcK-I<>MeO~v2`#d=BnYrc~`OVCAZ8o8QUr77mWLnR4|CI9`{BLV& zha(RzE~ni8|6iUy4#yWxha)cz#=(6Zj+|V6+|S|g!?ckOhc9MBnsB%=1s1_HSOW85 zSxk$qt(~x=!{KrCBB2|PU@kmm^GTSB{6p(=%s~DPs>3w>&Gjspj(kp3hk>Y$N?9wR z+Np!-u_3BnJIqf1j;V?>(PQ)DQ8!Mv`8lY8F0$n-Pz|la0NiTxzhNozH*7xR z08=lAwScv_q<=>l5(;TmR0p47Aa=ssI3B}rIo8LN0~tLw!S1*ktD;Yo!%-0H;t=eG zW$`Ai#ca_IM-WT67ax$n7sF(Glejp@tbO&b9FDByJ75tUfEvg*n28$?U|sU(2D2Y9 z8@*)0GFS=gVl5nnwecXTeu|+EM@wvgA-D*uqfpas{VSch&5G2j*>Syu852y-YN#GLpXwPfi>lgLA&%xKe4 z3oJqYC|0F`r>L0*k2Uq0pk^F_*>D(U!P(fG`m0e(n0`F>U{+N9I#>+bAP31Y3Uzw! zd+ddms9o%yU^Zh()Q#m($LUk_$401`bV6;u*;oq~qC$HGHK6npP5lC>&0Q8%zpAw! zW+v}xMnWBQ#pc)pb$ot69hd#6nf{DT@fH@u(vys>@hSPQur>Cc%)p7nUhF~s^c07q zIEGF&OWX-H^ZDpY|Be;5U=wEN!cKJKSyV)xpf=@u%z^&X%=Hpjo_rH5f#Wa;EBVCL?H?WV-5Tk1Mv!~L)Q$mi*sWD`L5{3iKs}eLk)Zz zD#SnGemswUID4i!o{LfK{eimg8Rn#a$2$@VarRke&4aKs`D&;RBT>749_o~w!{V4` zwy_K<5}i@k=VMtsjT(^aYxCsv!}R2H;kQ^6J=!csNN96ivOd66i+tuz0(r&k$I8n zpd41@LOs+>N1=BH=uiF{YJg9yFHr+{XRrIRrL&XIYb}equd%f=Y7Y%WEy*~{r1L+= zURZ_cxUk(;IE?Dxy!8eu#1FAJx)z&(mqbON8fvBuP@Atis=Yp_H6MbC+)PxYmSG0^ zcf^y>$PS|_UOSY&iSbj^Zf=g+8|`fQP;5$mENWApLv7N_HvcE8;|HiE z{fHigEaOrw1W!HGg|(=)+F?D6+Eiyy9j01l*02a_iJG7~9EOU-cvMHTa2&3(*NZGS zZ_^Oe=4-y3ZnaqkQJ~Pwur5W-Fb);M1XKi$pvq6%{6#EE{yM5-pB1JZf6Pn15az~O zm=8Ol_Sgu_f!kMj%sD(mfkOVPH3_See}ihc;!3l*>R^8I?NLiM%sL&lH2Q)Q!_nn=b+L;8B~ug{t=i6++i)v+0VWB3K)> zgso9C46}~F6y)cl`df&F(6fm|N)qQ#9bH6)>=`Qb8P=G!&5asR0H(q+sK`{p!dTbl zBdjr~`X1EM&A`jJ0JQ`I*Lo-5acm%=H9dzK(IwPC-k}DNbDbGbASz$m=9}AmA5=#} zumMiQhIkgW2Xd}Ao4Ep}CSM0tzY%88w*Q=jn}S|g04LiD8&KzYCu(>9f*Qb8REY1P zPRScoN2$Iu*9)N!`O>JpQW4e8=ctH9VM-i{CFtKVmPBLRf{pMks)2eNj7?D;wzc_i z+)KV6YK@C;w3`!$lW&i1{0&3#71qY$o6MuW9~L3M1U)=G9Y^hjuA9y0^x0U3@>@2a zeT(_5*BjeWJ`IcEZ7hoE<4l9)u^{=*s68|WHPa=ikgvpyxD_+t!8qctU3-=S?dqGT z8=j%|z$^5_tXs{K(TyrEk7=<~L3RA6^(AV}{eCbX%L7mkpmykw zUtt!U;vu0KF2OYT9qN(%J*wgfRAjDVXMBY1vFUa*2JiyHGPdi>WXf73xR0 z6yKxTUA)5#*b_&>I|FRWg+Ecdwp4;y)8-gJelV)RC8(LLvhK6~ZheZ{Tz)&vCM<($ zuMO&y#GoQE$K*YZZ6vZ&a2Cs95^5>(>@ttaAXJC-Q8Vv`HE}Phq4!t{^XxX8vI$lq z{}n2tTTv0(g{prP1Mv>#()st>V?q*$+6$FXYg`kvVPjMXyP-N7U|o)%kUxhSa5B2_ zwJp!N*F2I#thG_~T4N3j!*cZR7(qfI`yLgE%eKN()Bt?!^WkIZXUD;{AtB$bUqI`V4Bs*H8^V zu=&4HAy4t6DbI~MMgbU%l~4`$Ms+v@-8cm`feonk5>Nv_^ds>vM&c3$8i?zN8Hf*R z2L7m1kpn}qC)UA*7>U==$H^z4qh_XUj+wRXV2!~7)SF?ALq+a9YJz`wY{6YjNx@^( z3|^piZ_49l#_3Vz0jRYrhdx-}mN!SuxC`oh55Ouo4z-yN*z%L8h+VVgo~I;Pv_y@#BkDMPiGlbPY7;I&4R9wG$Dc4CK1X%vf5I$5Af_f?2Gg?t z9F<9=p`b2m22D{j>}>M`FogUV^uc&kr1sz_Jct@#jg!VksP@{T>UYLD*b`^q8@z#& zPU*WL*1y-!{1ijM4g3xpp5~!~&ruP1dd7s(=d77o9jr@vBx+N|qat$<)8ZM_=DK8k zjJe6DK4-2ML=7wm{pjCOmV_FriN4qr)zIgtnRdra*bB8OhoB-f3f0kUY>Vqq_r16J zoHw5(Gow1Jh8eM*wJmxy!*CK>%i)+ASE2^;1L{$F0IT5}RLCp*!Ut8{f!ne4uY5m* zNqCgg5P!iu(1!kIBDDgG>N@7ZjK7{oP>~pl8rVeClWs98#5+&} zIf7%+b;;D9fFtO54r2$nkvGRrum>tKBd{e-!|(AL9>m$#&1c4zoH><$hhJmIo8Aw-9>*gR)hMX^ zr`d!rIKj-I3PA21BF zCz&N2hMDQ#F`k4PSb*9b%TOcTkJ<}oP#s-DE!6{ThHo%6Hn_`rV{@#5hf%xRm24uA z8+Cti)Ml@SDX|WEv`ZV1(5`P`?TBi)H|mD|sCt7?H;zPwbS!FsbFm;UK@ISr^^7gQ zYV#jan>_tJ^Swcid&EBt1)ox&hMS{8)dn@7zNk$(1T~-uSPZ9Q3EY8N<6EePpJHKr zhq|x8ee-e(Ms4bbSRFfIF8=$dwp>UXsHo>-fFG-|1Lyk!0J zlQ>`tZeSzw$*4$_d1V@^g6gmVYG9wCuJ=TB7>SCE2Q%PAR0s3XAD5!)Z$k}i7plEu zuZX`YUZ6lBzlLh)HtO8oN3FTfYcmsnRJ}Zy6$|24tYFKNQS~2L-(XYnDc_i{cv|6F z@=GxQ>v`Uq5%)o@%`mKjo3Sz8!#Y^*AM^GbidwQCQ3Lr2HM27|{~KyauA+Bupd#SM zn@N$)hea_0i=$@_i996sp>Dj4I=}Bxq0axFUz=bl)M@w$LoodZ({T+{1nXlCY=H`G z1ZKyVB0Z}{vC%%d_qAoYDA8Yroj@Z0hGm7Sl8xbQJZufy73Se##>kl zeH>12L@J;GZxtYM_p31ZuYrM-6C#E#H9JlzUMFIAy(r-t(VGq81lkqbgQ*X=JQ7Dgv!gA@7b_ z`+2C4E=1LfLk(ajX2FxFnO{fU{}?s!kEl(XD}}kg6nZ|VpdJZzycE^JW>mx9qav~g z)xnQg6fdGS)d$p?XHIGEFOFLKN~lv*A2pC>sE)g!c6|(LsTZYmdc5EH9Hqcb!9S?Y zktdb0D5|5fsLfO#-Pjq`fd_qYA}aJ>V_y6gHQ-&SiJd}qcp0?>sZyJP`lt4o5V_mn30IL3F)C8WP+WUZ7lGJ=jpb6wcEnNs|VD&vD zv__wyZtRI_FbXxZ8R*@VsE(Ig_u2Bxs3o|Cn%O@#pC*mzr~oQ56|A*T9XGam+LO@B zBLX$TewYu(qte=sQaUlfqNVi zY{630%(r3*JcZh9uTY!F%{x>LhM*o)m9Y?ZLWOh;hGHx#5+_j+{T;O@ZrgIFpBZol zublsaBvi3H24QWii~Ugz{eTMLAymCnsG0tbmGKrTq#<652e+upVAVg|={d({V-A4Yh5)6>3HuQIQ#jsy_`?Zy74&akl&j>R4Vv zO(+TV?s$$Kjr0u(h14&DX($-gaBb8Qv_?JAMqq1Pff~Ty7=l?cni98g=7ARJ~KEfnG;-_!!mU zKQ^B;v)R-EsL)qM4X`bi!?4Vpf349%3UmtAqjvcwR6`f6&#*1|O#Y^$o~X?ig&Nob z)am&ib>9ipjPGK3^vUA%er&IfBgl_How7F`64goM;Lj8~V>{GZ?m$K4B&wksxB)++ z-hLahnNS`x2g zx{4j~0an5WpO}G;#j@lVSWlsr&XL1}+#fZN5~!uCgxYI$k$U|79|^6^P;7#8P&2xL z+FY+t11px(?B05)5Vt~gGywJ77>hc#OKkZqREIB66ZmM&kjsQVFZ%2KUzLPr&>Yp^ zc+>zEphmjImY+vOkamHMc3RirNFMP$7;$9k+2<8)u_FPyC9y|1D;xe@C`F zW`segP*p)aa2jF(Y>gVwAk^z~2I^f9k81cFY6-LCHSdc2sEHIot#Kt(hs{v0_Xt!3 zBhjM}O()^TSk!}ICn{w7Q3E)QI=7Ee9lb`)Fl9coboo#Ns(^Y~wL!fdJEH1`p-xMG z48pmnV|y?k=U)}iQ=pD6p*G)J)J)UoHx0d9F5&D zx`5LWix;sC_ABUgjKRaG2U^`i9;f%m?1(}pRBKV8+k++WH0n6L!R1(?u+#guU6*h^ z`Pcxb_YV+D2AYAb#EO*f#7g)SwMR-6F%fNznqYU-(hTyDP=`}d=W{VC^qbJf$v|)z z`5%iqy}xj%Ud-wJ3&+iL?FEm!gxPEX zsI@AEs!#=0p*Lzk{ZKO+fm*U1s5RV&>gbFu|ILUc2L!EvY#kE1&L4YjtJN}2|Wqjql2PBooQcy>kNn)_n264QdlGuh4naj=1@^{7RA`%(Hw}D-i^&hhP|RAv z>HRf*eI%5Q;ixC+I$VGUu?n`UXx?^nQ4>0Wbuncn&VN}F4M}KMjzX>3*Qf_hJPyZG zHebK8d7E`X4PXIkx1Yc==&EA&PI(+lJ`6R}B-Dc{psLgR`-9#XN&aqC&c8K!-BO<1csrT{9??7`%tgvOSqr4u29G6NXD0SP5m36n%A_mzL`h? z)Jv-d{(v1&$2C&}r}vjpJ+KS;R~`}*NVIF{bPU03sDXUe$m!^WTd+B%YiuIY5$lsb zg$iw!CM=PQmku5#f2J9mnSp)X-07Hyi(5Dy(Nx&^TC+Q-w_&FC<^xAAOh-N#3u6`3vF(c5OOdEeISI9? z4`MX_isP|<2eULkV=BG>lS$~o@d)*R_<%JqMMv`hse|74I%=j7s0UAf)On6Zz5QIB z%%;napOdeSdeX&W7Tkn-a2>?_cpcNyzvF|wkfyU4Kp-lFB~Swk#Xzi$icka=#3)p~ z`KTx4V$_pu2P#58qaNjdpa!10i&^4ysCpI9Q;0-U5~?`Rx)jy%Db&cXp!ULJEPy^; zO+&>{kKQ&IfIU(5rlU^HYShvlz(#liwX|8gnI+5Ljq|UOm83v3DTA6}WmLm;Y{j-V zABGtyAB5U7<57{AZp-6POSK=X;ziVH@$YUz-yU_}5G;nHyL-$teFFu3D99aVI+%ot z%mUOV+Krm|Y21X5Q4glYJGq96IosEH(@-Wd;31MU-UA~_5-v6-j{E%%U6NcN!C z_!w%_JVb@g)ze&0hkE&Zf_gGeM(yfXxCi_6ayr&yk>2K+e-8BlC0&Hm!6U`768|Ls z*Ow*&XZn~(dwwIK0o*{1^ge1y-k?JH(dM)FH8aeEI&MW#ktvUwQA5;T>3}*#{ZSE# zL2bs-wtR+l4iYJkBi0ryMIEbEs5M%L+6!^kov4Nnp$2da73vG9P+ma|{3WX1JJi6O z{Y-mVP!r8<^MUC7{x6t>9vl@=H?}~YsE)R%8$L&M7-q`{qB@E}ZK7eQfviT=+lY$X zF4Pj9L)E`*^EXlL-bWvu|G!D7f%m8xr;Ic=q(?1D7SzBBqK;(|)XSv;D#Wc(_jN&q zy0^^_LJepX2HLAX)LNwnNtg>#g?y??1h59U(z$-TI^OgDhkOg(U zmbIz%3v0M_AnH*)61C*99uk2hmZH|?DE7cVu_1ms*!)yH8x_K9SRBg?F>k-l7(sp< zmcfU(5(^CFI!ky2&yc@2%zS{^Iou53lM&{nJ{H4JB3v0eVl|9KeXPHTeRTdajy4|-hhupz?8ln;H>zU! zF~$Knk^BZ!BtpiTC*dUgiJ88{o|GRL@AUq~MBoH7({@;Z@`0$=?_3;)+pxIa|3MRZ zmeW8x)O&pDBy;0B)FwKFdcgdGdb>Tuw$x8G*{orQDdu=}N7bKgaI0|(i#B}7F*kVCwskz z%@0KH(xOKV?~(IxL2oaWhnh zpQAeHf|^(qs{UBi1Xo~5+&7zDsLk_&sVShcOP% zqSoFs$9(XZjq30;>b}2F?LSA|mwK)-i-$xY1#Z;H>Z8ta7;1#Es2Lx{{P@u3)6X*l zD~GDr6V>oM%!Vsa9VXybJc-& zb3Ge=!m-ScdK*^w#w0_-0_$4TOJ@h_zO$&)^apAHDHpL6I{%?0RL}t3_!TM=v8X+9zXX)H^k|0LNNC18QA=?RwR@k~ ze8wfF!-A-pgrYWCRaE^}sDXyt@-e6ERj7do-sX`3p7BRLf0A>8v@efv8B8!R*)&)lOGb`vW{SF%EU^7ob9X64mep z>lIX_ZrST8SD0@?GN9_WN7e6P?Stwl3KhArsEEzNp}5hOdjeM4HAOX074?#-gW3aw zP#w=hZPFd6dUsGAKR|WxH}*ri)sj1*mqmqw4>T4AA4aLqZL`Fa?ees17o$ zHfFQtK{Zgw=IfyvYJysdFHj+kvQEHY^53AA;5e$?YxepbubjVEB=iLHU1J(-jcTwf z>R1g$HN3+5gY`J7-enBJ=co?cYt8$=2rA?)QT6(v+L?%2nuSG3owWLHo# zdyBfkXPqg}h00e#HP8fAuOq6X2-GHjmtj|cF3)Sij@&P-rAs=WiKr9Ac>=U*dBra%vj ze^75JpA9A=c~JS{HeUlZu%_4o+ha4_hzjvr)Bv(`%*$mw>Qi$Z=Er-e54CCI%x^vdQA@ZU z8>8nO2|fGsY&EB#7HXtZQ7@BCs5SK2W^OEpy50%Zfd{pRmZ4_42Q`7isDYieCff3U zP!US=y|+H!|B_Hh^P_I8X!G?@A?=K6cqHnrHQweYqdJ&{x_=Go{{6Q6l=V;aE)8nQ zvd5bNxG}BX|D{P>bFxOLhH`9oIu2q8>V{{i(7!`9lyQft7mO;eZ}aUjh+?Qt$=U+2wN`ZbhYmW-)5RAfUsPFMUpc<^Q*UX?js>2be@^4T} zm4J%KAymibQ61j5dB;9;Jtt~{#rE0rUz!39q?)x6DkAMryMF{~fE!R9{DcbSB~%A* zQ3KDk-?UQ*Rj;(o*F=5xYl>=j2{ryxE~7enWAnKWIK6*BSQ%CC zPppi|sQQHtnvQFuCe+H>1@++RYyIAqdtQ^!$WtA1IxaZb{iu;YJZyZA3T^ryO$VQ# zK1P>EJy>d?2GSW-FC5ige>{U@Q3Gjt#MVQdhH#_DF_eUEn1SkexpgaQjSr$4xQbfS zd#Dh)j+*i!s3jzuz+w%`)#Q|S%VDad}zJR+-N z1o_Fh9-pBG^zCu;eZYF_L3C4o2^E<)=!4mRGCx=3Lq(`0Y5*0`d;Y82g2t$kw!-un zj+$Aty*>ri@oei7)ZW;D+N3|DmgX%g0;x`z_OhWmE{cj^s4Z`d-k<+FlTbr_P!(sO zI*PTfMul!0YDuo52KL&PXErB)@m!Rrzz?t|14#zq_J008b07m1$)8;FgM7&A9;Tez9kw~KW zSre*E=gjM~5NeZkviY&7j`pHPegZYHKTsdJZlWge3blmk&zk|cQT2+W?hmn6x7Qnc zNNCO4qAGT^6?)kUF{p+|p+Y|oOW{^jgEvw4KSVu>U!yi%v0uzjzaiGzs6Evh_1f-+ zS|ZOF5^88JYWFWkeSr8Kr(ot^osNO{El$GpJS3Lme4NJfqWnd(sSf{U?!SUMhW@{s zfrnrj^7ThlE153^l{=ZG|(akL6cT9T&c28rX#0>39cfH|M-;A{UBk zrxI$3nxHmyN6dtyP!XPm8pr}~p7Xz)gl=4CE9^#%@Q^J(W6S?UePl{Tbx`(-u`a6N zHmG(wpgzn-qV~oxRC`~e?ptc}ThROazdiQCQB>$I+WZSt!zr(t2K-PBL}7W9JS_?tqV}?t;P(v5xwVsHwoQ%9M#YTRKs^sOY$D|&1cSQrd|kEAzvL= z;4s{dd9Is~|AGt2zrbBM^M=_|4Q`t61ID73ZtYFZzdATTfxLpP@D=L#HTu*1DAgZ> z$S=eqcnnqV5o$)YZkgTOz}g;lD*B?9VlZlg<4|9A&qhr&{ubw7BRNJvAl^WI;h6fi z`LJ04)jUcLqt$lCh z>EAJ)gg)i2LWSt2^)Bks`N-xAK5%;fYj(v@U#qP^)jw+e+4`&XGHQT-TA$i-=U-;R zY0#rk`I9Jt0jT5B64h`g)J%HV{6N$|hoBnXjz#e(>c{j)HlO05nQ;)RUNdWFR0JbY z?M{4X-~V%M!E)_k4x zW3vPUAKUXkgMu1dSc_VtB-8*>Ju&%=sD^`4k*R_jSZ&lMZH2)&46{4=N(WW%;8Szo zan!&spvv!}+JEFBk&47?%#R;zg?!IU#Q;eogMq%(3nJ&tf&um#o7cGOG{+x*Y=`X8u)-L&~9sDZt*dEb|26J|y| z@d8mD6-Tujf_ko0x8z368*PsLj~^wW%MCCCQJ(O7!p8NJ2LxqCSEJyfOKXs2MD_uCi`G4Rjl-;XSte z0&4SKK|T52TI;+u`RS-Nzlb`9&(Qnt|9$^4p>*RKE|kHF_y=n9`M)y*sf_Bl7Ahi* zQK9XI8c3uqpM+YfCDzTTwcm$YvOiD*{p+1Q|9N#-E z`V1AJtRGB=xveEo1FK?ffU4KlUhio0eLryiRWX_ZH9Q(MAdS|cuJ6+!Il0s1JOhmQM?;lNKk#&WwupR@bupQO#6;y){ z>~*KhG?W3=P;S)P7POW_4Wt1oqCHR#r0J+1@%AAD@i>xA!tpn%BUcL3kUwf>0jN;d zMO|-(nn7n&hkb1MDC=YoJN7QBvLxpgHEnk6Z za6M+iL#PgaLDfq{Mar4l=!c3}Hq__+095;BF+Wy8Paug7B=kTSiyG-748pCb4zAhq zC#V6W_AxWak6POxR7Y)4*L&FOBT@HHwdG4}`3_V+Kl->l-Y4H56qKbPXBxZ4=v`w} zB>JHC!VFXgn@~^6qc;Bzy)#W~>Ib0iuaEjT-Uc<0o~U|5Q3Icn*5mSieYVY3_zM-< zkJe1S=7s{O4nt54^+r9AhNEV(9M$j+RL93q^?tSGNvK`_617wX{9N9jC98QzsNxjV zjf-vmJDcB&>fnSezhU#wtZCAjh~%?YK(*Hb)m|8C%?H@>!KjE%MGe@qiG(WdMUCtP zYG#S3^Xi-4lvhE8tQ~r9w9Z1^zYMiRn@~%13iTt{9n=6GqXzcUnl^)XVjf2x5^5k6 zS78lv!EqaP;~RV3kTj>b3eF6}eoQ&7P`(QRIiABKHvWGhsG= z(|!l^{{DY634JB{BkJq+-*GNJMRhnKi;2Wi)Vp8}YG%8!4jxAh$SI;bK zs0p@3-8T@`?kMzr{*NW01~ypt;g{skVKJrs+2Sv%P);)!|>L zfn~^HHf3SdeRWWq`ZH7{CgtG#YuC=BKsUzQiWjZPsL=gm{UoPpxC|<^4N*_duBg2- z1ofPljB57)Y9QCGFHoD-HKvM#Ixy$V*x+Nk@+pf>4Dd)*UHLO1M2Mc^E&;xp?<)J)RlF_u8@KvAD~dZIdx zK^?>KsCH&rx7zE!paygcb^kr2AO8M3uW8sH)p3Z;S4XW`C)6W(0G`A#s1BRtb2*Z+ zBlf_0`CZ=cem7uS@|D~!?{~nnP##avo1HXmd2un5mk%ZGphyz^Se=3n7 z&^#b!;OAVwf%-gOv4~l_F{lRCqE1IVDzsNn5xa$2ioa2N<|FDSqDnZm)agZ|d9Py-){YHu#8-YVabz3&!AMRKq<&jT2GFXFY00NAPRBfO_!s zEMwkwOHe;YoI*YEUZIw{XjyY#3sgIvt|TU9zcI0{qXNUE1FOTphi3zHGoZ61yA7s z^r>WCt3yz`Iv!i#L(~MSS2iJUi9zI#V+VYJy56FSX+I1
nhfB&0ALLtsv)ohkB zsP}Y3+`wATLcU#fRIYA9=&or7*Z}ob>t!8{JIF7@HrS$;%P|($U}G#=+vWW}AO^$8 zKgDJ`|847-4p(Ds^7pX|maJf5f*8=Keo4opS8o2b2z)P(b|P-bXqX3_wklJACEtJckoVR()kqcIibbz7M7MyTV| z9{XW0)W9#=>*ZRS>-DUyP{*~4HKL_G|Irj^L?cnBU=6C_P1aplfc!Dkz>-mG`WjoJ zUn^7I2{n*@sL)?QEm7yz<|m-RsCFl!`kC9B^RHdK$X@sr)$mQ!NS~t~Jnyj=I@_2% z5`k*igZ1$n)WB|{?tg+hUMbp|`uR~EmqSgYHfjJpJoZ8#)Qv+>Ydy`D??pxCgv~!i zb^HPK$o=Fqb6+TyB3}>H(I6~@W9{{|sE&8p@++vfqUR$CJt70z8LL=ZVlm2lq0aNy zw){A{$zQYi57w-on~sZPU9ML^{m?lA)&4Y8Bv+t!iH#n|CK3u=JSyZr+WcwMOs=7B zxQ}{pJhkQ7zc4c@gnB#HL%qIdqC$TPb>Cf71Rr4(W^eEE{!`2;=>7ZOYb5l*xsUp& zm9~Rvz>TxXS4BOmPoo-ib~L83X2h>3&yE`K4C^B6I#dLHK;3^5_0Bo3a-IJaoy?C) zInj@NVeF5ku`MpgXnct)@XO9F@4sqK*ToEcHLCna%j~=(RQzqxDoW{tnIw?Xc?WiEA% zKX7TZD?aQ>(e44=VtTv#MnuQB z2S>#8o_lC-feinnUT|=5!kv=No*7~rS93mz{j0h&DB)st=aNkScKcOxXRuG9=>NOr z*dw1gm(3l$C0oMicFtXC69#s3_VVdHC^{mtm)njy)ZHzzhdZWsxH~$$Z+KWtcn>qW z-rb_zZ6hPX`u7NThxP6j)s0J0(ZN-_M^$&X=^Y*w-m`yHcn~*6*y#+8=-byF*+0hJ zJ>1=`N00Cx!Let1JO4^ZjBt)h5j!T-St#LJKc`>Hka8tUS8|7hRxVeuWa)CHOZ&EH zUb0pAR}s+>{Ub}(?G_VW*&SLsG?Z)JdX*}NCghBA-pY~U@!rX?@25J8l9-YZIL&!H zOX_<|RwgYuknnt|b5)vjWW)f zc-UFz-CPZFyc1`bZI!l~0JuzWT z;)H~x<+Bp9KXA^@kif4k&ZYhD?nkF^{gwK^ieiWPx(fYQNy0{7*VF&5lrTTDt84Ch ziL<9CZC>Lrm`!tc(fH(qxWxVA6DP+euUOA0T71!_LGxxUYPD%_cV=AT%5jOazPUGP zmc2<^=tYq0!ha@mCfF738(Xxp>uf^vDz5dZ{+<2%nyztaJhS8O&RcIcL@9SelS-Po zE^+dX#93SK?A%QE?zYk4QSPSwdkpFu?rs&{w|_VGGHcSdX)6}w_UN(h#C>z`>^ne9 z{~2=P#0huzZAqN8FKPbPs@7Q(pU&fZ0Is$TZ& z{-5>k&Rdo=fAat8F4T4x`?!%Shfj;4En~PTHcMky;lhb=yOOu9NM5-yY4fVPE4JL5 zH##Y93^BX2e_G<&vD}w5c71H;#;zg_6`htb!R=~wsmn2p32z=*r*Hpg6DjK58@(v` zz$8_Q4v(o76B8BDeNc?KnzUe7QvCenE&GWy3lV#!v1`u1tJ|}QYn1EXO@F_cEB4t-E%-H=cT*2kczN1%@xU*|o;_R7>G;vnkojq#^ zjeU^0b!GJevyiblTDk%&2D`Ni)J+-}PsA0*ggq>lyY*1-vyaI9*EXIqBWdS^dka@5 zcv`xercH2maFy|Cdw2YfqD{;{O2^yylz$ diff --git a/spyder/locale/zh_CN/LC_MESSAGES/spyder.po b/spyder/locale/zh_CN/LC_MESSAGES/spyder.po index e20c908514c..30402e7e4e0 100644 --- a/spyder/locale/zh_CN/LC_MESSAGES/spyder.po +++ b/spyder/locale/zh_CN/LC_MESSAGES/spyder.po @@ -1,22 +1,21 @@ -# msgid "" msgstr "" "Project-Id-Version: spyder\n" "POT-Creation-Date: 2022-07-01 10:40-0500\n" -"PO-Revision-Date: 2022-05-09 19:24\n" +"PO-Revision-Date: 2022-07-01 23:14\n" "Last-Translator: \n" "Language-Team: Chinese Simplified\n" -"Language: zh_CN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=1; plural=0;\n" "Generated-By: pygettext.py 1.5\n" -"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" -"X-Crowdin-File-ID: 80\n" -"X-Crowdin-Language: zh-CN\n" +"Plural-Forms: nplurals=1; plural=0;\n" "X-Crowdin-Project: spyder\n" "X-Crowdin-Project-ID: 381272\n" +"X-Crowdin-Language: zh-CN\n" +"X-Crowdin-File: /5.x/spyder/locale/spyder.pot\n" +"X-Crowdin-File-ID: 80\n" +"Language: zh_CN\n" #: spyder/api/plugin_registration/_confpage.py:26 msgid "Here you can turn on/off any internal or external Spyder plugin to disable functionality that is not desired or to have a lighter experience. Unchecked plugins in this page will be unloaded immediately and will not be loaded the next time Spyder starts." @@ -59,14 +58,12 @@ msgid "Dock the pane" msgstr "停靠窗格" #: spyder/api/widgets/main_widget.py:344 spyder/api/widgets/main_widget.py:448 spyder/plugins/base.py:204 spyder/plugins/base.py:512 -#, fuzzy msgid "Unlock position" -msgstr "光标位置" +msgstr "解锁位置" #: spyder/api/widgets/main_widget.py:345 spyder/api/widgets/main_widget.py:453 spyder/plugins/base.py:205 spyder/plugins/base.py:516 -#, fuzzy msgid "Unlock to move pane to another position" -msgstr "转到下一个光标位置" +msgstr "解锁以移动窗格到另一个位置" #: spyder/api/widgets/main_widget.py:351 spyder/plugins/base.py:212 msgid "Undock" @@ -85,14 +82,12 @@ msgid "Close the pane" msgstr "关闭窗格" #: spyder/api/widgets/main_widget.py:442 spyder/plugins/base.py:506 -#, fuzzy msgid "Lock position" -msgstr "光标位置" +msgstr "锁定位置" #: spyder/api/widgets/main_widget.py:446 spyder/plugins/base.py:510 -#, fuzzy msgid "Lock pane to the current position" -msgstr "转到下一个光标位置" +msgstr "锁定窗格到当前位置" #: spyder/api/widgets/toolbars.py:123 msgid "More" @@ -195,27 +190,21 @@ msgid "Initializing..." msgstr "正在初始化..." #: spyder/app/restart.py:139 -msgid "" -"It was not possible to close the previous Spyder instance.\n" +msgid "It was not possible to close the previous Spyder instance.\n" "Restart aborted." -msgstr "" -"无法关闭上一个 Spyder 实例。\n" +msgstr "无法关闭上一个 Spyder 实例。\n" "重启已中止。" #: spyder/app/restart.py:141 -msgid "" -"Spyder could not reset to factory defaults.\n" +msgid "Spyder could not reset to factory defaults.\n" "Restart aborted." -msgstr "" -"Spyder 无法重置为出厂默认设置。\n" +msgstr "Spyder 无法重置为出厂默认设置。\n" "重启已中止。" #: spyder/app/restart.py:143 -msgid "" -"It was not possible to restart Spyder.\n" +msgid "It was not possible to restart Spyder.\n" "Operation aborted." -msgstr "" -"无法重启 Spyder。\n" +msgstr "无法重启 Spyder。\n" "操作已中止。" #: spyder/app/restart.py:145 @@ -247,13 +236,9 @@ msgid "Shortcut context must match '_' or the plugin `CONF_SECTION`!" msgstr "快捷键的context必须匹配 '_' 或者插件的 `CONF_SECTION`!" #: spyder/config/manager.py:660 -msgid "" -"There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n" -"\n" +msgid "There was an error while loading Spyder configuration options. You need to reset them for Spyder to be able to launch.\n\n" "Do you want to proceed?" -msgstr "" -"加载 Spyder 配置选项时出错。 你需要重置它们以使 Spyder 能够启动。\n" -"\n" +msgstr "加载 Spyder 配置选项时出错。 你需要重置它们以使 Spyder 能够启动。\n\n" "你想要继续吗?" #: spyder/config/manager.py:668 @@ -717,11 +702,9 @@ msgid "Set this for high DPI displays when auto scaling does not work" msgstr "当自动缩放不起作用时,为高分辨率显示器设置此项" #: spyder/plugins/application/confpage.py:205 -msgid "" -"Enter values for different screens separated by semicolons ';'.\n" +msgid "Enter values for different screens separated by semicolons ';'.\n" "Float values are supported" -msgstr "" -"为不同显示器输入对应的值,以分号 ';' 分隔。\n" +msgstr "为不同显示器输入对应的值,以分号 ';' 分隔。\n" "支持浮点型的值" #: spyder/plugins/application/confpage.py:238 spyder/plugins/ipythonconsole/confpage.py:29 spyder/plugins/outlineexplorer/widgets.py:48 @@ -1073,16 +1056,13 @@ msgid "not reachable" msgstr "无法连接" #: spyder/plugins/completion/providers/kite/widgets/status.py:70 -msgid "" -"Kite installation will continue in the background.\n" +msgid "Kite installation will continue in the background.\n" "Click here to show the installation dialog again" -msgstr "" -"Kite 将会在后台继续安装。\n" +msgstr "Kite 将会在后台继续安装。\n" "点击此处以显示安装界面" #: spyder/plugins/completion/providers/kite/widgets/status.py:75 -msgid "" -"Click here to show the\n" +msgid "Click here to show the\n" "installation dialog again" msgstr "点击此处以显示安装界面" @@ -1303,12 +1283,10 @@ msgid "Enable Go to definition" msgstr "启用“转到”定义" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:37 -msgid "" -"If enabled, left-clicking on an object name while \n" +msgid "If enabled, left-clicking on an object name while \n" "pressing the {} key will go to that object's definition\n" "(if resolved)." -msgstr "" -"若启用,则左键单击某个对象并且按下{}键 \n" +msgstr "若启用,则左键单击某个对象并且按下{}键 \n" "将会转到该对象的定义\n" "(若能被解析)。" @@ -1325,12 +1303,10 @@ msgid "Enable hover hints" msgstr "启用鼠标悬停提示" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:47 -msgid "" -"If enabled, hovering the mouse pointer over an object\n" +msgid "If enabled, hovering the mouse pointer over an object\n" "name will display that object's signature and/or\n" "docstring (if present)." -msgstr "" -"若启用,则鼠标悬停在某个对象的名称上时\n" +msgstr "若启用,则鼠标悬停在某个对象的名称上时\n" "将会显示该对象的签名以及文档字符串 (docstring) (若存在)" #: spyder/plugins/completion/providers/languageserver/conftabs/introspection.py:62 @@ -1534,11 +1510,9 @@ msgid "down" msgstr "已停止" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:46 -msgid "" -"Completions, linting, code\n" +msgid "Completions, linting, code\n" "folding and symbols status." -msgstr "" -"补全、检查、代码\n" +msgstr "补全、检查、代码\n" "折叠和符号状态。" #: spyder/plugins/completion/providers/languageserver/widgets/status.py:74 @@ -1746,25 +1720,17 @@ msgid "In order to use commands like \"raw_input\" or \"input\" run Spyder with msgstr "为了使用像“raw_input”或“input”这样的命令,使用来自系统终端的多线程选项(--multithread)运行Spyder" #: spyder/plugins/console/widgets/main_widget.py:121 -msgid "" -"Spyder Internal Console\n" -"\n" +msgid "Spyder Internal Console\n\n" "This console is used to report application\n" "internal errors and to inspect Spyder\n" "internals with the following commands:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"Please do not use it to run your code\n" -"\n" -msgstr "" -"Spyder 内部控制台\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"Please do not use it to run your code\n\n" +msgstr "Spyder 内部控制台\n\n" "该控制台用于报告应用程序的内部错误,\n" "并通过下列命令进行 Spyder 的内部检查:\n" -" spy.app, spy.window, dir(spy)\n" -"\n" -"请不要用它来运行你的代码\n" -"\n" +" spy.app, spy.window, dir(spy)\n\n" +"请不要用它来运行你的代码\n\n" #: spyder/plugins/console/widgets/main_widget.py:179 spyder/plugins/ipythonconsole/widgets/main_widget.py:503 msgid "&Quit" @@ -1779,9 +1745,8 @@ msgid "&Run..." msgstr "运行(&R)…" #: spyder/plugins/console/widgets/main_widget.py:191 -#, fuzzy msgid "Run a Python file" -msgstr "运行一个Python脚本" +msgstr "运行一个 Python 文件" #: spyder/plugins/console/widgets/main_widget.py:197 msgid "Environment variables..." @@ -1828,9 +1793,8 @@ msgid "Internal console settings" msgstr "内部控制台设置" #: spyder/plugins/console/widgets/main_widget.py:510 -#, fuzzy msgid "Run Python file" -msgstr "Python 文件" +msgstr "运行 Python 文件" #: spyder/plugins/console/widgets/main_widget.py:569 msgid "Buffer" @@ -1949,14 +1913,12 @@ msgid "Tab always indent" msgstr "Tab键总是缩进" #: spyder/plugins/editor/confpage.py:114 -msgid "" -"If enabled, pressing Tab will always indent,\n" +msgid "If enabled, pressing Tab will always indent,\n" "even when the cursor is not at the beginning\n" "of a line (when this option is enabled, code\n" "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)" -msgstr "" -"如果启用,按Tab键将始终缩进,即使光标不在行的开头\n" +msgstr "如果启用,按Tab键将始终缩进,即使光标不在行的开头\n" "(启用此选项时,可以使用备用快捷键触发代码完成:Ctrl + Space)" #: spyder/plugins/editor/confpage.py:120 @@ -1964,12 +1926,10 @@ msgid "Automatically strip trailing spaces on changed lines" msgstr "行的内容更改时自动移除结尾空格" #: spyder/plugins/editor/confpage.py:122 -msgid "" -"If enabled, modified lines of code (excluding strings)\n" +msgid "If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" "If disabled, only whitespace added by Spyder will be stripped." -msgstr "" -"若启用,将会移除已更改代码行中的结尾空格(字符除外)\n" +msgstr "若启用,将会移除已更改代码行中的结尾空格(字符除外)\n" "若禁用,则仅仅移除由 Spyder 添加的空格。" #: spyder/plugins/editor/confpage.py:126 @@ -2365,11 +2325,9 @@ msgid "Run cell" msgstr "运行单元格" #: spyder/plugins/editor/plugin.py:719 -msgid "" -"Run current cell \n" +msgid "Run current cell \n" "[Use #%% to create cells]" -msgstr "" -"运行当前单元格(Ctrl+Enter)\n" +msgstr "运行当前单元格(Ctrl+Enter)\n" "[用 #%% 创建单元格]" #: spyder/plugins/editor/plugin.py:729 spyder/plugins/editor/widgets/codeeditor.py:4434 @@ -2725,34 +2683,24 @@ msgid "Removal error" msgstr "移除错误" #: spyder/plugins/editor/widgets/codeeditor.py:3852 -msgid "" -"It was not possible to remove outputs from this notebook. The error is:\n" -"\n" -msgstr "" -"不能移除这个u出。错误:\n" -"\n" +msgid "It was not possible to remove outputs from this notebook. The error is:\n\n" +msgstr "不能移除这个u出。错误:\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:3864 spyder/plugins/explorer/widgets/explorer.py:1679 msgid "Conversion error" msgstr "转换错误" #: spyder/plugins/editor/widgets/codeeditor.py:3865 spyder/plugins/explorer/widgets/explorer.py:1680 -msgid "" -"It was not possible to convert this notebook. The error is:\n" -"\n" -msgstr "" -"无法转换notebook,错误:\n" -"\n" -"\n" +msgid "It was not possible to convert this notebook. The error is:\n\n" +msgstr "无法转换notebook,错误:\n\n\n" #: spyder/plugins/editor/widgets/codeeditor.py:4412 msgid "Clear all ouput" msgstr "清除所有输出" #: spyder/plugins/editor/widgets/codeeditor.py:4415 spyder/plugins/explorer/widgets/explorer.py:488 -#, fuzzy msgid "Convert to Python file" -msgstr "转换为Python脚本" +msgstr "转换为 Python 文件" #: spyder/plugins/editor/widgets/codeeditor.py:4418 msgid "Go to definition" @@ -2907,13 +2855,9 @@ msgid "Recover from autosave" msgstr "从自动保存的内容恢复" #: spyder/plugins/editor/widgets/recover.py:129 -msgid "" -"Autosave files found. What would you like to do?\n" -"\n" +msgid "Autosave files found. What would you like to do?\n\n" "This dialog will be shown again on next startup if any autosave files are not restored, moved or deleted." -msgstr "" -"找到了自动保存文件。您想要做什么?\n" -"\n" +msgstr "找到了自动保存文件。您想要做什么?\n\n" "若存在尚未恢复、尚未移除或删除的自动保存文件,则这个对话框在下次启动时将再次显示。" #: spyder/plugins/editor/widgets/recover.py:148 @@ -3209,24 +3153,16 @@ msgid "File/Folder copy error" msgstr "文件或文件夹复制错误" #: spyder/plugins/explorer/widgets/explorer.py:1369 -msgid "" -"Cannot copy this type of file(s) or folder(s). The error was:\n" -"\n" -msgstr "" -"无法复制该种类型的文件或文件夹。错误信息为:\n" -"\n" +msgid "Cannot copy this type of file(s) or folder(s). The error was:\n\n" +msgstr "无法复制该种类型的文件或文件夹。错误信息为:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1416 msgid "Error pasting file" msgstr "粘贴文件文件错误" #: spyder/plugins/explorer/widgets/explorer.py:1417 spyder/plugins/explorer/widgets/explorer.py:1445 -msgid "" -"Unsupported copy operation. The error was:\n" -"\n" -msgstr "" -"不支持的复制操作。错误信息为:\n" -"\n" +msgid "Unsupported copy operation. The error was:\n\n" +msgstr "不支持的复制操作。错误信息为:\n\n" #: spyder/plugins/explorer/widgets/explorer.py:1436 msgid "Recursive copy" @@ -3681,8 +3617,7 @@ msgid "Display initial banner" msgstr "在IPython控制台中显示内核初始化信息" #: spyder/plugins/ipythonconsole/confpage.py:31 -msgid "" -"This option lets you hide the message shown at\n" +msgid "This option lets you hide the message shown at\n" "the top of the console when it's opened." msgstr "禁用该选项可以隐藏在控制台顶部显示的内核初始化信息。" @@ -3695,8 +3630,7 @@ msgid "Ask for confirmation before removing all user-defined variables" msgstr "在移除所有用户定义的变量前询问" #: spyder/plugins/ipythonconsole/confpage.py:41 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when resetting the namespace from Spyder." msgstr "该选项使您能隐藏重置Spyder命名空间时的警告信息。" @@ -3709,8 +3643,7 @@ msgid "Ask for confirmation before restarting" msgstr "在重新启动之前要求确认" #: spyder/plugins/ipythonconsole/confpage.py:47 -msgid "" -"This option lets you hide the warning message shown\n" +msgid "This option lets you hide the warning message shown\n" "when restarting the kernel." msgstr "该选项允许您隐藏重启 IPython 内核时的警告信息。" @@ -3747,12 +3680,10 @@ msgid "Buffer: " msgstr "缓冲区: " #: spyder/plugins/ipythonconsole/confpage.py:75 -msgid "" -"Set the maximum number of lines of text shown in the\n" +msgid "Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)" -msgstr "" -"设置控制台显示的最大行数,超出部分将会被截断。\n" +msgstr "设置控制台显示的最大行数,超出部分将会被截断。\n" "设置为-1可禁用截断(不推荐)。" #: spyder/plugins/ipythonconsole/confpage.py:84 @@ -3768,13 +3699,11 @@ msgid "Automatically load Pylab and NumPy modules" msgstr "自动加载PyLab和NumPy模块" #: spyder/plugins/ipythonconsole/confpage.py:89 -msgid "" -"This lets you load graphics support without importing\n" +msgid "This lets you load graphics support without importing\n" "the commands to do plots. Useful to work with other\n" "plotting libraries different to Matplotlib or to develop\n" "GUIs with Spyder." -msgstr "" -"这将允许您在不导入绘图命令的情况下加载图形支持,\n" +msgstr "这将允许您在不导入绘图命令的情况下加载图形支持,\n" "对于使用Matplotlib之外的其他绘图引擎、或者使用\n" "Spyder开发 GUI 软件来说非常有用。" @@ -3851,14 +3780,12 @@ msgid "Use a tight layout for inline plots" msgstr "为嵌入绘图使用紧凑布局" #: spyder/plugins/ipythonconsole/confpage.py:158 -msgid "" -"Sets bbox_inches to \"tight\" when\n" +msgid "Sets bbox_inches to \"tight\" when\n" "plotting inline with matplotlib.\n" "When enabled, can cause discrepancies\n" "between the image displayed inline and\n" "that created using savefig." -msgstr "" -"绘制嵌入Matplotlib图形时,设置bbox_inches为\"tight\"。\n" +msgstr "绘制嵌入Matplotlib图形时,设置bbox_inches为\"tight\"。\n" "启用后,可能造成显示的图形与通过savefig保存的图片不一致。" #: spyder/plugins/ipythonconsole/confpage.py:191 @@ -4103,7 +4030,7 @@ msgstr "
正在重启内核...
" #: spyder/plugins/ipythonconsole/widgets/figurebrowser.py:64 msgid "Figures now render in the Plots pane by default. To make them also appear inline in the Console, uncheck \"Mute Inline Plotting\" under the Plots pane options menu." -msgstr "现在图形默认会显示在绘图面板中。 若要使它们也在控制台中内联显示,请在绘图面板选项菜单中取消勾选 \"禁用内联绘图\"。" +msgstr "现在图形默认会显示在绘图窗格中。 若要使它们也在控制台中内联显示,请在绘图窗格选项菜单中取消勾选 \"禁用内联绘图\"。" #: spyder/plugins/ipythonconsole/widgets/kernelconnect.py:35 spyder/plugins/ipythonconsole/widgets/main_widget.py:442 msgid "Connect to an existing kernel" @@ -4111,7 +4038,7 @@ msgstr "连接到现有 IPython 内核" #: spyder/plugins/ipythonconsole/widgets/kernelconnect.py:37 msgid "

Please select the JSON connection file (e.g. kernel-1234.json) of the existing kernel, and enter the SSH information if connecting to a remote machine. To learn more about starting external kernels and connecting to them, see our documentation.

" -msgstr "

请选择现有内核的 JSON 连接文件(例如 kernel-1234.json),如果连接到远程服务器,则还需要输入SSH连接信息。要了解更多关于启动并连接到外部内核的信息,请阅读文档

" +msgstr "

请选择现有内核的 JSON 连接文件 (例如 kernel-1234.json),如果连接到远程服务器,则还需要输入SSH连接信息。要了解更多关于启动并连接到外部内核的信息,请阅读文档

" #: spyder/plugins/ipythonconsole/widgets/kernelconnect.py:50 msgid "Connection file:" @@ -4278,20 +4205,12 @@ msgid "Connection error" msgstr "连接错误" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1174 -msgid "" -"An error occurred while trying to load the kernel connection file. The error was:\n" -"\n" -msgstr "" -"加载内核连接文件时出错。错误信息为:\n" -"\n" +msgid "An error occurred while trying to load the kernel connection file. The error was:\n\n" +msgstr "加载内核连接文件时出错。错误信息为:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1197 -msgid "" -"Could not open ssh tunnel. The error was:\n" -"\n" -msgstr "" -"无法打开SSH隧道。错误信息为:\n" -"\n" +msgid "Could not open ssh tunnel. The error was:\n\n" +msgstr "无法打开SSH隧道。错误信息为:\n\n" #: spyder/plugins/ipythonconsole/widgets/main_widget.py:1605 msgid "The Python environment or installation whose interpreter is located at
    {0}
doesn't have the spyder-kernels module or the right version of it installed ({1}). Without this module is not possible for Spyder to create a console for you.

You can install it by activating your environment (if necessary) and then running in a system terminal:
    {2}
or
    {3}
" @@ -4462,11 +4381,9 @@ msgid "Layout {0} will be overwritten. Do you want to continue?" msgstr "布局 {0} 将被覆盖。 你想要继续吗?" #: spyder/plugins/layout/container.py:368 -msgid "" -"Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" +msgid "Window layout will be reset to default settings: this affects window position, size and dockwidgets.\n" "Do you want to continue?" -msgstr "" -"窗口布局将被重置为默认设置:这会影响窗口的位置、大小和停靠的组件。\n" +msgstr "窗口布局将被重置为默认设置:这会影响窗口的位置、大小和停靠的组件。\n" "您想继续吗?" #: spyder/plugins/layout/layouts.py:81 @@ -4515,7 +4432,7 @@ msgstr "解锁窗格和工具栏" #: spyder/plugins/layout/plugin.py:806 msgid "Lock panes and toolbars" -msgstr "锁定面板和工具栏" +msgstr "锁定窗格和工具栏" #: spyder/plugins/layout/widgets/dialog.py:195 msgid "Move Up" @@ -4570,9 +4487,8 @@ msgid "Enable UMR" msgstr "启用UMR" #: spyder/plugins/maininterpreter/confpage.py:122 -#, fuzzy msgid "This option will enable the User Module Reloader (UMR) in Python/IPython consoles. UMR forces Python to reload deeply modules during import when running a Python file using the Spyder's builtin function runfile.

1. UMR may require to restart the console in which it will be called (otherwise only newly imported modules will be reloaded when executing files).

2. If errors occur when re-running a PyQt-based program, please check that the Qt objects are properly destroyed (e.g. you may have to use the attribute Qt.WA_DeleteOnClose on your main window, using the setAttribute method)" -msgstr "此选项将启用Python / IPython控制台中的 User Module Reloader(UMR)。 当使用Spyder的内置函数 runfile 运行Python脚本时,UMR强制Python在导入期间重新加载模块。

1, 启用UMR需要重启现有的控制台,否则它只会在新建的控制台生效。

2,如果重新运行基于PyQt的程序时发生错误,请检查 Qt对象被正确销毁(例如,您可能必须在主窗口上使用属性 Qt.WA_DeleteOnClose ,通过 setAttribute 方法设置)" +msgstr "此选项将在 Python/IPython 控制台中启用 User Module Reloader (UMR)。 当使用 Spyder 的内置函数 runfile 运行 Python 文件时,UMR 会强制 Python 在导入期间重新深度加载模块。

1. UMR 可能会要求重启调用它所在的控制台 (否则当执行文件时将只有新导入的模块会被重新加载)。

2. 如果重新运行基于 PyQt-based 程序时发生错误,请检查 Qt 对象是否被正确销毁 (例如你可能必须在主窗口上使用属性 Qt.WA_DeleteOnClose,通过 setAttribute 方法来设置)" #: spyder/plugins/maininterpreter/confpage.py:140 msgid "Show reloaded modules list" @@ -4603,11 +4519,9 @@ msgid "You are working with Python 2, this means that you can not import a modul msgstr "您正在使用Python 2,这意味着您不能导入包含非ASCII字符的模块。" #: spyder/plugins/maininterpreter/confpage.py:232 -msgid "" -"The following modules are not installed on your machine:\n" +msgid "The following modules are not installed on your machine:\n" "%s" -msgstr "" -"下面的模块没有在您的计算机上安装:\n" +msgstr "下面的模块没有在您的计算机上安装:\n" "%s" #: spyder/plugins/maininterpreter/confpage.py:239 @@ -4947,8 +4861,7 @@ msgid "Results" msgstr "结果" #: spyder/plugins/profiler/confpage.py:20 -msgid "" -"Profiler plugin results (the output of python's profile/cProfile)\n" +msgid "Profiler plugin results (the output of python's profile/cProfile)\n" "are stored here:" msgstr "Profiler插件生成的结果(profiler/cprofiler的输出)存储于此:" @@ -5654,15 +5567,15 @@ msgstr "变量浏览器" #: spyder/plugins/tours/tours.py:122 msgid "In this pane you can view and edit the variables generated during the execution of a program, or those entered directly in the IPython Console.

If you ran the code in the previous step, the Variable Explorer will show the list and dictionary objects it generated. By double-clicking any variable, a new window will be opened where you can inspect and modify their contents." -msgstr "在这个面板中你可以查看和编辑在程序执行期间所生成的变量,或者是在 IPython 控制台直接输入的变量。

如果你在之前操作中运行了代码,变量浏览器将显示它所生成的列表和字典。 通过双击任意变量,将打开一个新窗口,你可以在其中检查和修改它们的内容。" +msgstr "在这个窗格中你可以查看和编辑在程序执行期间所生成的变量,或者是在 IPython 控制台直接输入的变量。

如果你在之前操作中运行了代码,变量浏览器将显示它所生成的列表和字典。 通过双击任意变量,将打开一个新窗口,你可以在其中检查和修改它们的内容。" #: spyder/plugins/tours/tours.py:137 msgid "This pane displays documentation of the functions, classes, methods or modules you are currently using in the Editor or the IPython Console.

To use it, press Ctrl+I (Cmd-I on macOS) with the text cursor in or next to the object you want help on." -msgstr "这个面板会显示你当前在编辑器或 IPython 控制台中使用的函数、类、方法或模块的文档。

要使用它,请在文本光标位于你想获取帮助的对象之上或之后时按下 Ctrl+I (在 mac OS 上为 Cmd-I)。" +msgstr "这个窗格会显示你当前在编辑器或 IPython 控制台中使用的函数、类、方法或模块的文档。

要使用它,请在文本光标位于你想获取帮助的对象之上或之后时按下 Ctrl+I (在 mac OS 上为 Cmd-I)。" #: spyder/plugins/tours/tours.py:149 msgid "This pane shows the figures and images created during your code execution. It allows you to browse, zoom, copy, and save the generated plots." -msgstr "这个面板会显示在你的代码执行期间所创建的图形和图像。 它允许你浏览、缩放、拷贝和保存所生成的图形。" +msgstr "这个窗格会显示在你的代码执行期间所创建的图形和图像。 它允许你浏览、缩放、拷贝和保存所生成的图形。" #: spyder/plugins/tours/tours.py:157 msgid "This pane lets you browse the files and directories on your computer.

You can open any file in its corresponding application by double-clicking it, and supported file types will be opened right inside of Spyder.

The Files pane also allows you to copy one or many absolute or relative paths, automatically formatted as Python strings or lists, and perform a variety of other file operations." @@ -5674,11 +5587,11 @@ msgstr "历史日志" #: spyder/plugins/tours/tours.py:172 msgid "This pane records all the commands and code run in any IPython console, allowing you to easily retrace your steps for reproducible research." -msgstr "这个面板会记录在任何 IPython 控制台中运行过的所有命令和代码,允许你方便地回顾你的操作步骤用于可复现的研究。" +msgstr "这个窗格会记录在任何 IPython 控制台中运行过的所有命令和代码,允许你方便地回顾你的操作步骤用于可复现的研究。" #: spyder/plugins/tours/tours.py:180 msgid "The Find pane allows you to search for text in a given directory and navigate through all the found occurrences." -msgstr "查找面板允许你在给定的目录中搜索文本并浏览所有已找到的结果。" +msgstr "查找窗格允许你在给定的目录中搜索文本并浏览所有已找到的结果。" #: spyder/plugins/tours/tours.py:188 msgid "The Profiler helps you optimize your code by determining the run time and number of calls for every function and method used in a file. It also allows you to save and compare your results between runs." @@ -5726,7 +5639,7 @@ msgstr "要运行导览,请按下 Spyder 窗口标题栏左侧的绿色按钮 #: spyder/plugins/tours/widgets.py:1091 msgid "Check out our interactive tour to explore some of Spyder's panes and features." -msgstr "请查看我们的交互式导览来探索 Spyder 的各种面板和特性。" +msgstr "请查看我们的交互式导览来探索 Spyder 的各种窗格和特性。" #: spyder/plugins/tours/widgets.py:1107 msgid "Start tour" @@ -5865,13 +5778,9 @@ msgid "Save and Close" msgstr "保存并关闭" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:101 spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:439 -msgid "" -"Opening this variable can be slow\n" -"\n" +msgid "Opening this variable can be slow\n\n" "Do you want to continue anyway?" -msgstr "" -"打开这个变量可能很慢\n" -"\n" +msgstr "打开这个变量可能很慢\n\n" "您想要继续吗?" #: spyder/plugins/variableexplorer/widgets/collectionsdelegate.py:119 @@ -5911,8 +5820,7 @@ msgid "Spyder was unable to retrieve the value of this variable from the console msgstr "Spyder 无法从控制台获取该变量的值。

错误信息为:
%s" #: spyder/plugins/variableexplorer/widgets/dataframeeditor.py:378 -msgid "" -"It is not possible to display this value because\n" +msgid "It is not possible to display this value because\n" "an error ocurred while trying to do it" msgstr "发生错误,无法显示该值" @@ -6330,7 +6238,7 @@ msgstr "文本编辑器" #: spyder/plugins/workingdirectory/confpage.py:28 msgid "This is the directory that will be set as the default for the IPython console and Files panes." -msgstr "这将被设置为 IPython 控制台和文件面板的默认目录。" +msgstr "这将被设置为 IPython 控制台和文件窗格的默认目录。" #: spyder/plugins/workingdirectory/confpage.py:37 msgid "At startup, the working directory is:" @@ -6342,7 +6250,7 @@ msgstr "项目 (如已打开) 或用户主目录" #: spyder/plugins/workingdirectory/confpage.py:43 msgid "The startup working dir will be root of the current project if one is open, otherwise the user home directory" -msgstr "" +msgstr "启动工作目录将为当前已打开项目的根目录,否则将为用户主目录" #: spyder/plugins/workingdirectory/confpage.py:51 msgid "At startup, the current working directory will be the specified path" @@ -6493,8 +6401,7 @@ msgid "Legal" msgstr "法律条款" #: spyder/widgets/arraybuilder.py:200 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Type an array in Matlab : [1 2;3 4]
\n" " or Spyder simplified syntax : 1 2;3 4\n" @@ -6504,8 +6411,7 @@ msgid "" " Hint:
\n" " Use two spaces or two tabs to generate a ';'.\n" " " -msgstr "" -"\n" +msgstr "\n" " Numpy 数组/矩阵 助手
\n" " 在MATLAB中输入一个数组 : [1 2;3 4]
\n" " 或Spyder简化的语法 : 1 2;3 4\n" @@ -6517,8 +6423,7 @@ msgstr "" " " #: spyder/widgets/arraybuilder.py:211 -msgid "" -"\n" +msgid "\n" " Numpy Array/Matrix Helper
\n" " Enter an array in the table.
\n" " Use Tab to move between cells.\n" @@ -6528,8 +6433,7 @@ msgid "" " Hint:
\n" " Use two tabs at the end of a row to move to the next row.\n" " " -msgstr "" -"\n" +msgstr "\n" " Numpy 数组/矩阵 助手
\n" " 在表格中输入一个数组。
\n" " 使用Tab在单元格之间切换。\n" @@ -6614,7 +6518,7 @@ msgstr "您确定要删除所有选中的条目吗?" #: spyder/widgets/collectionseditor.py:1042 msgid "You can only rename keys that are strings" -msgstr "" +msgstr "你只能重命名字符串类型的密钥" #: spyder/widgets/collectionseditor.py:1047 msgid "New variable name:" @@ -6734,12 +6638,11 @@ msgstr "依赖" #: spyder/widgets/dock.py:113 msgid "Drag and drop pane to a different position" -msgstr "" +msgstr "拖放窗格到其他位置" #: spyder/widgets/dock.py:143 -#, fuzzy msgid "Lock pane" -msgstr "停靠窗格" +msgstr "锁定窗格" #: spyder/widgets/findreplace.py:51 msgid "No matches" @@ -6887,7 +6790,7 @@ msgstr "展开片段" #: spyder/widgets/pathmanager.py:79 msgid "The paths listed below will be passed to IPython consoles and the language server as additional locations to search for Python modules.

Any paths in your system PYTHONPATH environment variable can be imported here if you'd like to use them." -msgstr "" +msgstr "下面列出的路径家被传递到 IPython 控制台和语言服务器作为搜索 Python 模块的附加位置。

在你的系统 PYTHONPATH 环境变量中的任何路径如果你需要使用它们都可在这里被导入。" #: spyder/widgets/pathmanager.py:123 msgid "Move to top" @@ -6914,37 +6817,32 @@ msgid "Remove path" msgstr "移除路径" #: spyder/widgets/pathmanager.py:167 -#, fuzzy msgid "Import" -msgstr "导入为" +msgstr "导入" #: spyder/widgets/pathmanager.py:170 -#, fuzzy msgid "Import from PYTHONPATH environment variable" -msgstr "同步Spyder的路径列表和PYTHONPATH环境变量" +msgstr "从 PYTHONPATH 环境变量导入" #: spyder/widgets/pathmanager.py:174 spyder/widgets/pathmanager.py:275 msgid "Export" -msgstr "" +msgstr "导出" #: spyder/widgets/pathmanager.py:177 -#, fuzzy msgid "Export to PYTHONPATH environment variable" -msgstr "同步Spyder的路径列表和PYTHONPATH环境变量" +msgstr "导出到 PYTHONPATH 环境变量" #: spyder/widgets/pathmanager.py:226 spyder/widgets/pathmanager.py:261 -#, fuzzy msgid "PYTHONPATH" -msgstr "PYTHONPATH 管理器" +msgstr "PYTHONPATH" #: spyder/widgets/pathmanager.py:262 msgid "Your PYTHONPATH environment variable is empty, so there is nothing to import." -msgstr "" +msgstr "你的 PYTHONPATH 环境变量为空,所有没有可导入的模块。" #: spyder/widgets/pathmanager.py:276 -#, fuzzy msgid "This will export Spyder's path list to the PYTHONPATH environment variable for the current user, allowing you to run your Python modules outside Spyder without having to configure sys.path.

Do you want to clear the contents of PYTHONPATH before adding Spyder's path list?" -msgstr "该操作会将 Spyder 的路径列表与当前用户的 PYTHONPATH 环境变量同步,同步之后您可以在 Spyder 之外运行 Python 模块而无需配置 sys.path.
您想要在添加 Sypyder的路径列表前清除 PYTHONPATH 的内容吗?" +msgstr "这会将 Spyder 的路径列表导出到当前用户的 PYTHONPATH 环境变量,让你可以在 Spyder 之外运行你的 Python 模块而无需配置 sys.path。

你想要在添加 Spyder 的路径列表之前清空 PYTHONPATH 的内容吗?" #: spyder/widgets/pathmanager.py:394 msgid "This directory is already included in the list.
Do you want to move it to the top of it?" @@ -6995,9 +6893,8 @@ msgid "Hide all future errors during this session" msgstr "在此会话期间隐藏所有错误" #: spyder/widgets/reporterror.py:215 -#, fuzzy msgid "Include IPython console environment" -msgstr "在此打开IPython控制台" +msgstr "包括 IPython 控制台环境" #: spyder/widgets/reporterror.py:219 msgid "Submit to Github" @@ -7107,20 +7004,3 @@ msgstr "无法连接到互联网。

请确保您的网络工作正常。" msgid "Unable to check for updates." msgstr "检查更新失败。" -#~ msgid "Run Python script" -#~ msgstr "运行Python脚本" - -#~ msgid "Python scripts" -#~ msgstr "Python脚本" - -#~ msgid "Select Python script" -#~ msgstr "选择Python脚本" - -#~ msgid "Synchronize..." -#~ msgstr "同步..." - -#~ msgid "Synchronize" -#~ msgstr "同步" - -#~ msgid "You are using Python 2 and the selected path has Unicode characters.
Therefore, this path will not be added." -#~ msgstr "您正在使用Python 2,然而所选路径包含 Unicode 字符。
因此,该路径将不会被添加。" From 49f209d5039c173841590f87cc1cd6f4721663ba Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Fri, 8 Jul 2022 19:13:59 -0700 Subject: [PATCH 41/83] Fix issue where macOS_group was referenced before assignment. --- spyder/plugins/application/confpage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/application/confpage.py b/spyder/plugins/application/confpage.py index 05096bcc6b8..a287e32766b 100644 --- a/spyder/plugins/application/confpage.py +++ b/spyder/plugins/application/confpage.py @@ -224,7 +224,7 @@ def set_open_file(state): screen_resolution_layout.addLayout(screen_resolution_inner_layout) screen_resolution_group.setLayout(screen_resolution_layout) - if sys.platform == "darwin": + if sys.platform == "darwin" and not running_in_mac_app(): interface_tab = self.create_tab(screen_resolution_group, interface_group, macOS_group) else: From 75d2dffbcfc4f5245c44d1d170894c28e21d5ba0 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sun, 10 Jul 2022 11:14:11 -0500 Subject: [PATCH 42/83] Console: Fix error when closing error dialog --- spyder/plugins/console/widgets/main_widget.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/console/widgets/main_widget.py b/spyder/plugins/console/widgets/main_widget.py index d47ae46bf98..b7d55c69e03 100644 --- a/spyder/plugins/console/widgets/main_widget.py +++ b/spyder/plugins/console/widgets/main_widget.py @@ -429,7 +429,6 @@ def handle_exception(self, error_data, sender=None): self.error_dlg = SpyderErrorDialog(self) self.error_dlg.set_color_scheme( self.get_conf('selected', section='appearance')) - self.error_dlg.close_btn.clicked.connect(self.close_error_dlg) self.error_dlg.rejected.connect(self.remove_error_dlg) self.error_dlg.details.sig_go_to_error_requested.connect( self.go_to_error) @@ -458,15 +457,16 @@ def close_error_dlg(self): """ Close error dialog. """ - if self.error_dlg.dismiss_box.isChecked(): - self.dismiss_error = True - - self.error_dlg.reject() + if self.error_dlg: + self.error_dlg.reject() def remove_error_dlg(self): """ Remove error dialog. """ + if self.error_dlg.dismiss_box.isChecked(): + self.dismiss_error = True + self.error_dlg.disconnect() self.error_dlg = None From 07e312e448e54b3c4aab5069b0e662ec262a4ee6 Mon Sep 17 00:00:00 2001 From: dalthviz Date: Mon, 11 Jul 2022 13:26:24 -0500 Subject: [PATCH 43/83] Registry: Don't double validate if plugins can be deleted/closed --- spyder/api/plugin_registration/registry.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/spyder/api/plugin_registration/registry.py b/spyder/api/plugin_registration/registry.py index 0eaf949dbe7..f876c63cd13 100644 --- a/spyder/api/plugin_registration/registry.py +++ b/spyder/api/plugin_registration/registry.py @@ -446,7 +446,8 @@ def dock_undocked_plugin( # Close undocked plugins. plugin_instance._close_window() - def delete_plugin(self, plugin_name: str, teardown: bool = True) -> bool: + def delete_plugin(self, plugin_name: str, teardown: bool = True, + check_can_delete: bool = True) -> bool: """ Remove and delete a plugin from the registry by its name. @@ -457,6 +458,9 @@ def delete_plugin(self, plugin_name: str, teardown: bool = True) -> bool: teardown: bool True if the teardown notification to other plugins should be sent when deleting the plugin, False otherwise. + check_can_delete: bool + True if the plugin should validate if can be closed in the moment, + False otherwise. Returns ------- @@ -468,9 +472,10 @@ def delete_plugin(self, plugin_name: str, teardown: bool = True) -> bool: plugin_instance = self.plugin_registry[plugin_name] # Determine if plugin can be closed - can_delete = self.can_delete_plugin(plugin_name) - if not can_delete: - return False + if check_can_delete: + can_delete = self.can_delete_plugin(plugin_name) + if not can_delete: + return False if isinstance(plugin_instance, SpyderPluginV2): # Cleanly delete plugin widgets. This avoids segfautls with @@ -637,7 +642,7 @@ def delete_all_plugins(self, excluding: Optional[Set[str]] = None, plugin_instance = self.plugin_registry[plugin_name] if isinstance(plugin_instance, SpyderPlugin): can_close &= self.delete_plugin( - plugin_name, teardown=False) + plugin_name, teardown=False, check_can_delete=False) if not can_close and not close_immediately: break @@ -650,7 +655,7 @@ def delete_all_plugins(self, excluding: Optional[Set[str]] = None, plugin_instance = self.plugin_registry[plugin_name] if isinstance(plugin_instance, SpyderPlugin): can_close &= self.delete_plugin( - plugin_name, teardown=False) + plugin_name, teardown=False, check_can_delete=False) if not can_close and not close_immediately: break @@ -663,7 +668,7 @@ def delete_all_plugins(self, excluding: Optional[Set[str]] = None, plugin_instance = self.plugin_registry[plugin_name] if isinstance(plugin_instance, SpyderPluginV2): can_close &= self.delete_plugin( - plugin_name, teardown=False) + plugin_name, teardown=False, check_can_delete=False) if not can_close and not close_immediately: break @@ -676,7 +681,7 @@ def delete_all_plugins(self, excluding: Optional[Set[str]] = None, plugin_instance = self.plugin_registry[plugin_name] if isinstance(plugin_instance, SpyderPluginV2): can_close &= self.delete_plugin( - plugin_name, teardown=False) + plugin_name, teardown=False, check_can_delete=False) if not can_close and not close_immediately: break From 0799e2fa48adf28ddf5656e7776a816b5624c383 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 11 Jul 2022 11:20:16 -0500 Subject: [PATCH 44/83] Update minimal version required for PyLSP --- .github/scripts/install.sh | 4 ---- binder/environment.yml | 2 +- requirements/main.yml | 2 +- setup.py | 4 ++-- spyder/dependencies.py | 2 +- 5 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh index 1b623cc124b..294fed81e8c 100755 --- a/.github/scripts/install.sh +++ b/.github/scripts/install.sh @@ -65,10 +65,6 @@ else fi -# Install whatthepatch for PyLSP 1.5. -# NOTE: This won't be necessary when that version is released -pip install whatthepatch - # Install subrepos from source python -bb -X dev -W error install_dev_repos.py --not-editable --no-install spyder diff --git a/binder/environment.yml b/binder/environment.yml index dfd7780751f..be8961bf2f5 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -30,7 +30,7 @@ dependencies: - pyqt <5.16 - pyqtwebengine <5.16 - python-lsp-black >=1.2.0 -- python-lsp-server >=1.4.1,<1.5.0 +- python-lsp-server >=1.5.0,<1.6.0 - pyxdg >=0.26 - pyzmq >=22.1.0 - qdarkstyle >=3.0.2,<3.1.0 diff --git a/requirements/main.yml b/requirements/main.yml index 96cbd68a2ce..a6cf6648a7b 100644 --- a/requirements/main.yml +++ b/requirements/main.yml @@ -28,7 +28,7 @@ dependencies: - pyqt <5.16 - pyqtwebengine <5.16 - python-lsp-black >=1.2.0 - - python-lsp-server >=1.4.1,<1.5.0 + - python-lsp-server >=1.5.0,<1.6.0 - pyzmq >=22.1.0 - qdarkstyle >=3.0.2,<3.1.0 - qstylizer >=0.1.10 diff --git a/setup.py b/setup.py index 9f175ded889..ca16d8191e9 100644 --- a/setup.py +++ b/setup.py @@ -228,7 +228,7 @@ def run(self): 'pyls-spyder>=0.4.0', 'pyqt5<5.16', 'pyqtwebengine<5.16', - 'python-lsp-server[all]>=1.4.1,<1.5.0', + 'python-lsp-server[all]>=1.5.0,<1.6.0', 'pyxdg>=0.26;platform_system=="Linux"', 'pyzmq>=22.1.0', 'qdarkstyle>=3.0.2,<3.1.0', @@ -250,7 +250,7 @@ def run(self): reqs_to_loosen = {'python-lsp-server[all]', 'qtconsole', 'spyder-kernels'} install_requires = [req for req in install_requires if req.split(">")[0] not in reqs_to_loosen] - install_requires.append('python-lsp-server[all]>=1.4.1,<1.6.0') + install_requires.append('python-lsp-server[all]>=1.5.0,<1.7.0') install_requires.append('qtconsole>=5.3.0,<5.5.0') install_requires.append('spyder-kernels>=2.3.1,<2.5.0') diff --git a/spyder/dependencies.py b/spyder/dependencies.py index 0c6ff7de3b3..ca17495830d 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -52,7 +52,7 @@ PSUTIL_REQVER = '>=5.3' PYGMENTS_REQVER = '>=2.0' PYLINT_REQVER = '>=2.5.0;<3.0' -PYLSP_REQVER = '>=1.4.1;<1.5.0' +PYLSP_REQVER = '>=1.5.0;<1.6.0' PYLSP_BLACK_REQVER = '>=1.2.0' PYLS_SPYDER_REQVER = '>=0.4.0' PYXDG_REQVER = '>=0.26' From ba5eeec870fbca800cb495677135a776b7fa0d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Althviz=20Mor=C3=A9?= Date: Mon, 11 Jul 2022 14:06:19 -0500 Subject: [PATCH 45/83] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- spyder/api/plugin_registration/registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spyder/api/plugin_registration/registry.py b/spyder/api/plugin_registration/registry.py index f876c63cd13..8699b735951 100644 --- a/spyder/api/plugin_registration/registry.py +++ b/spyder/api/plugin_registration/registry.py @@ -459,8 +459,8 @@ def delete_plugin(self, plugin_name: str, teardown: bool = True, True if the teardown notification to other plugins should be sent when deleting the plugin, False otherwise. check_can_delete: bool - True if the plugin should validate if can be closed in the moment, - False otherwise. + True if the plugin should validate if it can be closed when this + method is called, False otherwise. Returns ------- From c51381d1396a4aaeb6c0dd4ad50c428e051a1ca1 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 12 Jul 2022 21:13:47 +0200 Subject: [PATCH 46/83] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- spyder/plugins/variableexplorer/widgets/namespacebrowser.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index a4afccf8277..91a0f6c639c 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -42,6 +42,7 @@ # Constants VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w" + # Max time before giving up when making a blocking call to the kernel CALL_KERNEL_TIMEOUT = 30 From 3987e53972ac90d1d4d517d3136ddacf29dffd83 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 12 Jul 2022 21:14:08 +0200 Subject: [PATCH 47/83] Update spyder/plugins/framesexplorer/plugin.py Co-authored-by: Carlos Cordoba --- spyder/plugins/framesexplorer/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spyder/plugins/framesexplorer/plugin.py b/spyder/plugins/framesexplorer/plugin.py index b75346dd213..89a008d3298 100644 --- a/spyder/plugins/framesexplorer/plugin.py +++ b/spyder/plugins/framesexplorer/plugin.py @@ -75,6 +75,8 @@ def on_variable_explorer_teardown(self): self.get_widget().sig_show_namespace.disconnect( self.show_namespace_in_variable_explorer) + # ---- Public API + # ------------------------------------------------------------------------ def show_namespace_in_variable_explorer(self, namespace, shellwidget): """ Find the right variable explorer widget and show the namespace. From 87a307a77a15dd2168645fc52160e3b0cf8b82fa Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 12 Jul 2022 21:27:42 +0200 Subject: [PATCH 48/83] Move NameSpaceBrowser --- .../ipythonconsole/widgets/__init__.py | 1 + .../widgets/namespacebrowser.py | 84 +++++++++++++++++++ .../plugins/ipythonconsole/widgets/shell.py | 64 +------------- 3 files changed, 88 insertions(+), 61 deletions(-) create mode 100644 spyder/plugins/ipythonconsole/widgets/namespacebrowser.py diff --git a/spyder/plugins/ipythonconsole/widgets/__init__.py b/spyder/plugins/ipythonconsole/widgets/__init__.py index c54a9c93de5..f6bc62a94fe 100644 --- a/spyder/plugins/ipythonconsole/widgets/__init__.py +++ b/spyder/plugins/ipythonconsole/widgets/__init__.py @@ -14,6 +14,7 @@ from .control import ControlWidget, PageControlWidget from .debugging import DebuggingWidget from .help import HelpWidget +from .namespacebrowser import NamepaceBrowserWidget from .figurebrowser import FigureBrowserWidget from .kernelconnect import KernelConnectionDialog from .restartdialog import ConsoleRestartDialog diff --git a/spyder/plugins/ipythonconsole/widgets/namespacebrowser.py b/spyder/plugins/ipythonconsole/widgets/namespacebrowser.py new file mode 100644 index 00000000000..2ee141686ff --- /dev/null +++ b/spyder/plugins/ipythonconsole/widgets/namespacebrowser.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Widget that handle communications between the IPython Console and +the Variable Explorer +""" + +import logging + +from pickle import PicklingError, UnpicklingError + +from qtconsole.rich_jupyter_widget import RichJupyterWidget + +from spyder.config.base import _ +from spyder_kernels.comms.commbase import CommError + + +logger = logging.getLogger(__name__) + +# Max time before giving up when making a blocking call to the kernel +CALL_KERNEL_TIMEOUT = 30 + + +class NamepaceBrowserWidget(RichJupyterWidget): + """ + Widget with the necessary attributes and methods to handle communications + between the IPython Console and the kernel namespace + """ + # --- Public API -------------------------------------------------- + def get_value(self, name): + """Ask kernel for a value""" + reason_big = _("The variable is too big to be retrieved") + reason_not_picklable = _("The variable is not picklable") + reason_dead = _("The kernel is dead") + reason_other = _("An error occured, see the console.") + reason_comm = _("The comm channel is not working.") + msg = _("%s.

" + "Note: Please don't report this problem on Github, " + "there's nothing to do about it.") + try: + return self.call_kernel( + blocking=True, + display_error=True, + timeout=CALL_KERNEL_TIMEOUT).get_value(name) + except TimeoutError: + raise ValueError(msg % reason_big) + except (PicklingError, UnpicklingError, TypeError): + raise ValueError(msg % reason_not_picklable) + except RuntimeError: + raise ValueError(msg % reason_dead) + except KeyError: + raise + except CommError: + raise ValueError(msg % reason_comm) + except Exception: + raise ValueError(msg % reason_other) + + def set_value(self, name, value): + """Set value for a variable""" + self.call_kernel( + interrupt=True, + blocking=False, + display_error=True, + ).set_value(name, value) + + def remove_value(self, name): + """Remove a variable""" + self.call_kernel( + interrupt=True, + blocking=False, + display_error=True, + ).remove_value(name) + + def copy_value(self, orig_name, new_name): + """Copy a variable""" + self.call_kernel( + interrupt=True, + blocking=False, + display_error=True, + ).copy_value(orig_name, new_name) diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 8ff8fd92772..679ffcbb857 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -11,18 +11,15 @@ # Standard library imports import os import os.path as osp -import time import uuid from textwrap import dedent from threading import Lock -from pickle import PicklingError, UnpicklingError # Third party imports from qtpy.QtCore import Signal, QThread from qtpy.QtWidgets import QMessageBox from qtpy import QtCore, QtWidgets, QtGui from traitlets import observe -from spyder_kernels.comms.commbase import CommError # Local imports from spyder.config.base import ( @@ -38,17 +35,15 @@ from spyder.plugins.ipythonconsole.comms.kernelcomm import KernelComm from spyder.plugins.ipythonconsole.widgets import ( ControlWidget, DebuggingWidget, FigureBrowserWidget, HelpWidget, - PageControlWidget) + NamepaceBrowserWidget, PageControlWidget) MODULES_FAQ_URL = ( "https://docs.spyder-ide.org/5/faq.html#using-packages-installer") -# Max time before giving up when making a blocking call to the kernel -CALL_KERNEL_TIMEOUT = 30 - -class ShellWidget(HelpWidget, DebuggingWidget, FigureBrowserWidget): +class ShellWidget(NamepaceBrowserWidget, HelpWidget, DebuggingWidget, + FigureBrowserWidget): """ Shell widget for the IPython Console @@ -1016,56 +1011,3 @@ def focusOutEvent(self, event): """Reimplement Qt method to send focus change notification""" self.sig_focus_changed.emit() return super(ShellWidget, self).focusOutEvent(event) - - # --- value access -------------------------------------------------------- - def get_value(self, name): - """Ask kernel for a value""" - reason_big = _("The variable is too big to be retrieved") - reason_not_picklable = _("The variable is not picklable") - reason_dead = _("The kernel is dead") - reason_other = _("An error occured, see the console.") - reason_comm = _("The comm channel is not working.") - msg = _("%s.

" - "Note: Please don't report this problem on Github, " - "there's nothing to do about it.") - try: - return self.call_kernel( - blocking=True, - display_error=True, - timeout=CALL_KERNEL_TIMEOUT).get_value(name) - except TimeoutError: - raise ValueError(msg % reason_big) - except (PicklingError, UnpicklingError, TypeError): - raise ValueError(msg % reason_not_picklable) - except RuntimeError: - raise ValueError(msg % reason_dead) - except KeyError: - raise - except CommError: - raise ValueError(msg % reason_comm) - except Exception: - raise ValueError(msg % reason_other) - - def set_value(self, name, value): - """Set value for a variable""" - self.call_kernel( - interrupt=True, - blocking=False, - display_error=True, - ).set_value(name, value) - - def remove_value(self, name): - """Remove a variable""" - self.call_kernel( - interrupt=True, - blocking=False, - display_error=True, - ).remove_value(name) - - def copy_value(self, orig_name, new_name): - """Copy a variable""" - self.call_kernel( - interrupt=True, - blocking=False, - display_error=True, - ).copy_value(orig_name, new_name) From d35d8cce559c7a612b7c8acdf7c6f65e649caece Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 12 Jul 2022 21:30:32 +0200 Subject: [PATCH 49/83] _mute_inline_plotting --- spyder/plugins/ipythonconsole/widgets/figurebrowser.py | 8 ++++++-- spyder/plugins/plots/widgets/figurebrowser.py | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/spyder/plugins/ipythonconsole/widgets/figurebrowser.py b/spyder/plugins/ipythonconsole/widgets/figurebrowser.py index ba1c5e5c935..cb2e9eae12e 100644 --- a/spyder/plugins/ipythonconsole/widgets/figurebrowser.py +++ b/spyder/plugins/ipythonconsole/widgets/figurebrowser.py @@ -24,9 +24,13 @@ class FigureBrowserWidget(RichJupyterWidget): This widget can also block the plotting of inline figures in the IPython Console so that figures are only plotted in the plots plugin. """ - mute_inline_plotting = None + _mute_inline_plotting = None sended_render_message = False + def set_mute_inline_plotting(self, mute_inline_plotting): + """Set mute_inline_plotting""" + self._mute_inline_plotting = mute_inline_plotting + # ---- Private API (overrode by us) def _handle_display_data(self, msg): """ @@ -49,7 +53,7 @@ def _handle_display_data(self, msg): if img is not None: self.sig_new_inline_figure.emit(img, fmt) - if self.mute_inline_plotting: + if self._mute_inline_plotting: if not self.sended_render_message: self._append_html("
", before_prompt=True) self.append_html_message( diff --git a/spyder/plugins/plots/widgets/figurebrowser.py b/spyder/plugins/plots/widgets/figurebrowser.py index e6cbe674011..c619feb193e 100644 --- a/spyder/plugins/plots/widgets/figurebrowser.py +++ b/spyder/plugins/plots/widgets/figurebrowser.py @@ -203,7 +203,7 @@ def setup(self, options): elif option == 'mute_inline_plotting': self.mute_inline_plotting = value if self.shellwidget: - self.shellwidget.mute_inline_plotting = value + self.shellwidget.set_mute_inline_plotting(value) elif option == 'show_plot_outline': self.show_fig_outline_in_viewer(value) @@ -241,7 +241,7 @@ def change_auto_fit_plotting(self, state): def set_shellwidget(self, shellwidget): """Bind the shellwidget instance to the figure browser""" self.shellwidget = shellwidget - self.shellwidget.mute_inline_plotting = self.mute_inline_plotting + self.shellwidget.set_mute_inline_plotting(self.mute_inline_plotting) shellwidget.sig_new_inline_figure.connect(self._handle_new_figure) def _handle_new_figure(self, fig, fmt): From b83153237eed57538c9b3ba6ba5656644dcef19b Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Tue, 12 Jul 2022 22:19:57 +0200 Subject: [PATCH 50/83] put time back --- spyder/plugins/ipythonconsole/widgets/shell.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 679ffcbb857..cefe7962036 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -11,6 +11,7 @@ # Standard library imports import os import os.path as osp +import time import uuid from textwrap import dedent from threading import Lock From 9a6f92f802134278d6ef375d97e964bf970039ca Mon Sep 17 00:00:00 2001 From: dalthviz Date: Wed, 13 Jul 2022 11:24:45 -0500 Subject: [PATCH 51/83] Update core dependencies --- binder/environment.yml | 2 +- requirements/main.yml | 2 +- setup.py | 4 ++-- spyder/dependencies.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/binder/environment.yml b/binder/environment.yml index be8961bf2f5..072467b457b 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -41,7 +41,7 @@ dependencies: - rtree >=0.9.7 - setuptools >=49.6.0 - sphinx >=0.6.6 -- spyder-kernels >=2.3.1,<2.4.0 +- spyder-kernels >=2.3.2,<2.4.0 - textdistance >=4.2.0 - three-merge >=0.1.1 - watchdog >=0.10.3 diff --git a/requirements/main.yml b/requirements/main.yml index a6cf6648a7b..d36eeb63272 100644 --- a/requirements/main.yml +++ b/requirements/main.yml @@ -38,7 +38,7 @@ dependencies: - rtree >=0.9.7 - setuptools >=49.6.0 - sphinx >=0.6.6 - - spyder-kernels >=2.3.1,<2.4.0 + - spyder-kernels >=2.3.2,<2.4.0 - textdistance >=4.2.0 - three-merge >=0.1.1 - watchdog >=0.10.3 diff --git a/setup.py b/setup.py index ca16d8191e9..469e63d162f 100644 --- a/setup.py +++ b/setup.py @@ -239,7 +239,7 @@ def run(self): 'rtree>=0.9.7', 'setuptools>=49.6.0', 'sphinx>=0.6.6', - 'spyder-kernels>=2.3.1,<2.4.0', + 'spyder-kernels>=2.3.2,<2.4.0', 'textdistance>=4.2.0', 'three-merge>=0.1.1', 'watchdog>=0.10.3' @@ -252,7 +252,7 @@ def run(self): if req.split(">")[0] not in reqs_to_loosen] install_requires.append('python-lsp-server[all]>=1.5.0,<1.7.0') install_requires.append('qtconsole>=5.3.0,<5.5.0') - install_requires.append('spyder-kernels>=2.3.1,<2.5.0') + install_requires.append('spyder-kernels>=2.3.2,<2.5.0') extras_require = { 'test:platform_system == "Windows"': ['pywin32'], diff --git a/spyder/dependencies.py b/spyder/dependencies.py index ca17495830d..b3a6fdf5413 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -65,7 +65,7 @@ RTREE_REQVER = '>=0.9.7' SETUPTOOLS_REQVER = '>=49.6.0' SPHINX_REQVER = '>=0.6.6' -SPYDER_KERNELS_REQVER = '>=2.3.1;<2.4.0' +SPYDER_KERNELS_REQVER = '>=2.3.2;<2.4.0' TEXTDISTANCE_REQVER = '>=4.2.0' THREE_MERGE_REQVER = '>=0.1.1' # None for pynsist install for now From c944b20899b9636b863207dd58e0eac12ee4b30f Mon Sep 17 00:00:00 2001 From: dalthviz Date: Wed, 13 Jul 2022 11:25:11 -0500 Subject: [PATCH 52/83] git subrepo pull external-deps/spyder-kernels subrepo: subdir: "external-deps/spyder-kernels" merged: "28a06758e" upstream: origin: "https://github.com/spyder-ide/spyder-kernels.git" branch: "2.x" commit: "28a06758e" git-subrepo: version: "0.4.3" origin: "https://github.com/ingydotnet/git-subrepo" commit: "2f68596" --- external-deps/spyder-kernels/.gitrepo | 6 ++--- external-deps/spyder-kernels/CHANGELOG.md | 25 +++++++++++++++++++ .../spyder-kernels/requirements/posix.txt | 2 +- .../spyder-kernels/requirements/windows.txt | 2 +- external-deps/spyder-kernels/setup.py | 2 +- .../customize/namespace_manager.py | 24 +++++++++++++++--- 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/external-deps/spyder-kernels/.gitrepo b/external-deps/spyder-kernels/.gitrepo index c8c85c9b487..c5832ced7e7 100644 --- a/external-deps/spyder-kernels/.gitrepo +++ b/external-deps/spyder-kernels/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = https://github.com/spyder-ide/spyder-kernels.git branch = 2.x - commit = d789aab94dfd38ab5a7343a2b8cd81002ce749ae - parent = fa851f62b9a64b75d52f59cd1845eb3e0b684021 + commit = 28a06758e4c970b86f542ae075b52726948a5c2d + parent = 9a6f92f802134278d6ef375d97e964bf970039ca method = merge - cmdver = 0.4.1 + cmdver = 0.4.3 diff --git a/external-deps/spyder-kernels/CHANGELOG.md b/external-deps/spyder-kernels/CHANGELOG.md index 5001dfa5f53..678e330aa61 100644 --- a/external-deps/spyder-kernels/CHANGELOG.md +++ b/external-deps/spyder-kernels/CHANGELOG.md @@ -1,5 +1,30 @@ # History of changes +## Version 2.3.2 (2022-07-06) + +### Issues Closed + +* [Issue 394](https://github.com/spyder-ide/spyder-kernels/issues/394) - The variable explorer is broken while debugging ([PR 395](https://github.com/spyder-ide/spyder-kernels/pull/395) by [@impact27](https://github.com/impact27)) + +In this release 1 issue was closed. + +### Pull Requests Merged + +* [PR 399](https://github.com/spyder-ide/spyder-kernels/pull/399) - PR: Increase minimal required version of jupyter_client to 7.3.4, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 398](https://github.com/spyder-ide/spyder-kernels/pull/398) - PR: Fix module namespace, by [@impact27](https://github.com/impact27) +* [PR 395](https://github.com/spyder-ide/spyder-kernels/pull/395) - PR: Fix running namespace and improve eventloop integration while debugging, by [@impact27](https://github.com/impact27) ([394](https://github.com/spyder-ide/spyder-kernels/issues/394)) +* [PR 389](https://github.com/spyder-ide/spyder-kernels/pull/389) - PR: Fix debug filename path for remote debugging, by [@impact27](https://github.com/impact27) ([18330](https://github.com/spyder-ide/spyder/issues/18330)) +* [PR 388](https://github.com/spyder-ide/spyder-kernels/pull/388) - PR: Fix getting args from functions or methods, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 386](https://github.com/spyder-ide/spyder-kernels/pull/386) - PR: Fix flaky test, by [@impact27](https://github.com/impact27) +* [PR 381](https://github.com/spyder-ide/spyder-kernels/pull/381) - PR: Eliminate unnecessary updates of the Variable Explorer while debugging, by [@rear1019](https://github.com/rear1019) +* [PR 378](https://github.com/spyder-ide/spyder-kernels/pull/378) - PR: Append paths that come from Spyder's Python path manager to the end of `sys.path`, by [@mrclary](https://github.com/mrclary) + +In this release 8 pull requests were closed. + + +---- + + ## Version 2.3.1 (2022-05-21) ### Pull Requests Merged diff --git a/external-deps/spyder-kernels/requirements/posix.txt b/external-deps/spyder-kernels/requirements/posix.txt index 06200fe10b2..2cfa71bc14d 100644 --- a/external-deps/spyder-kernels/requirements/posix.txt +++ b/external-deps/spyder-kernels/requirements/posix.txt @@ -1,6 +1,6 @@ cloudpickle ipykernel>=6.9.2,<7 ipython>=7.31.1,<8 -jupyter_client>=7.3.1,<8 +jupyter_client>=7.3.4,<8 pyzmq>=22.1.0 wurlitzer>=1.0.3 diff --git a/external-deps/spyder-kernels/requirements/windows.txt b/external-deps/spyder-kernels/requirements/windows.txt index 47bf384de89..c86642865a2 100644 --- a/external-deps/spyder-kernels/requirements/windows.txt +++ b/external-deps/spyder-kernels/requirements/windows.txt @@ -1,5 +1,5 @@ cloudpickle ipykernel>=6.9.2,<7 ipython>=7.31.1,<8 -jupyter_client>=7.3.1,<8 +jupyter_client>=7.3.4,<8 pyzmq>=22.1.0 diff --git a/external-deps/spyder-kernels/setup.py b/external-deps/spyder-kernels/setup.py index 9dbb24e9724..7ee83652966 100644 --- a/external-deps/spyder-kernels/setup.py +++ b/external-deps/spyder-kernels/setup.py @@ -44,7 +44,7 @@ def get_version(module='spyder_kernels'): 'ipython<6; python_version<"3"', 'ipython>=7.31.1,<8; python_version>="3"', 'jupyter-client>=5.3.4,<6; python_version<"3"', - 'jupyter-client>=7.3.1,<8; python_version>="3"', + 'jupyter-client>=7.3.4,<8; python_version>="3"', 'pyzmq>=17,<20; python_version<"3"', 'pyzmq>=22.1.0; python_version>="3"', 'wurlitzer>=1.0.3;platform_system!="Windows"', diff --git a/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py b/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py index b2c8aadd4d0..e92214e8ca9 100755 --- a/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py +++ b/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py @@ -6,6 +6,7 @@ import linecache import os.path +import types import sys from IPython.core.getipython import get_ipython @@ -13,6 +14,25 @@ from spyder_kernels.py3compat import PY2 +def new_main_mod(filename, modname): + """ + Reimplemented from IPython/core/interactiveshell.py to avoid caching + and clearing recursive namespace. + """ + filename = os.path.abspath(filename) + + main_mod = types.ModuleType( + modname, + doc="Module created for script run in IPython") + + main_mod.__file__ = filename + # It seems pydoc (and perhaps others) needs any module instance to + # implement a __nonzero__ method + main_mod.__nonzero__ = lambda : True + + return main_mod + + class NamespaceManager(object): """ Get a namespace and set __file__ to filename for this namespace. @@ -50,9 +70,7 @@ def __enter__(self): self._previous_filename = self.ns_globals['__file__'] self.ns_globals['__file__'] = self.filename else: - - main_mod = ipython_shell.new_main_mod( - self.filename, '__main__') + main_mod = new_main_mod(self.filename, '__main__') self.ns_globals = main_mod.__dict__ self.ns_locals = None # Needed to allow pickle to reference main From ba6ce0b0d0621ae39005bff7bdf2a42a5b8b7dde Mon Sep 17 00:00:00 2001 From: dalthviz Date: Wed, 13 Jul 2022 13:29:48 -0500 Subject: [PATCH 53/83] git subrepo pull (merge) external-deps/spyder-kernels subrepo: subdir: "external-deps/spyder-kernels" merged: "774789e32" upstream: origin: "https://github.com/spyder-ide/spyder-kernels.git" branch: "master" commit: "774789e32" git-subrepo: version: "0.4.3" origin: "???" commit: "???" --- external-deps/spyder-kernels/.gitrepo | 4 +- external-deps/spyder-kernels/CHANGELOG.md | 25 ++++ .../spyder-kernels/requirements/posix.txt | 2 +- .../spyder-kernels/requirements/windows.txt | 2 +- external-deps/spyder-kernels/setup.py | 2 +- .../spyder_kernels/console/shell.py | 16 ++- .../console/tests/test_console_kernel.py | 134 ++++++++---------- .../customize/namespace_manager.py | 24 +++- 8 files changed, 126 insertions(+), 83 deletions(-) diff --git a/external-deps/spyder-kernels/.gitrepo b/external-deps/spyder-kernels/.gitrepo index a77fa22e538..f88eb704bf1 100644 --- a/external-deps/spyder-kernels/.gitrepo +++ b/external-deps/spyder-kernels/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = https://github.com/spyder-ide/spyder-kernels.git branch = master - commit = 4c43bd35fadd1d082f28a537a927e87a2d6d5902 - parent = ca8e91dc2a8681f461aeab872d848a30fb6190ae + commit = 774789e3259bcfaef749a9e96f0074857c3409f1 + parent = c0fc2a2671d8ae0ea377730b2d2f2f575eb8a9b7 method = merge cmdver = 0.4.3 diff --git a/external-deps/spyder-kernels/CHANGELOG.md b/external-deps/spyder-kernels/CHANGELOG.md index 5001dfa5f53..678e330aa61 100644 --- a/external-deps/spyder-kernels/CHANGELOG.md +++ b/external-deps/spyder-kernels/CHANGELOG.md @@ -1,5 +1,30 @@ # History of changes +## Version 2.3.2 (2022-07-06) + +### Issues Closed + +* [Issue 394](https://github.com/spyder-ide/spyder-kernels/issues/394) - The variable explorer is broken while debugging ([PR 395](https://github.com/spyder-ide/spyder-kernels/pull/395) by [@impact27](https://github.com/impact27)) + +In this release 1 issue was closed. + +### Pull Requests Merged + +* [PR 399](https://github.com/spyder-ide/spyder-kernels/pull/399) - PR: Increase minimal required version of jupyter_client to 7.3.4, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 398](https://github.com/spyder-ide/spyder-kernels/pull/398) - PR: Fix module namespace, by [@impact27](https://github.com/impact27) +* [PR 395](https://github.com/spyder-ide/spyder-kernels/pull/395) - PR: Fix running namespace and improve eventloop integration while debugging, by [@impact27](https://github.com/impact27) ([394](https://github.com/spyder-ide/spyder-kernels/issues/394)) +* [PR 389](https://github.com/spyder-ide/spyder-kernels/pull/389) - PR: Fix debug filename path for remote debugging, by [@impact27](https://github.com/impact27) ([18330](https://github.com/spyder-ide/spyder/issues/18330)) +* [PR 388](https://github.com/spyder-ide/spyder-kernels/pull/388) - PR: Fix getting args from functions or methods, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 386](https://github.com/spyder-ide/spyder-kernels/pull/386) - PR: Fix flaky test, by [@impact27](https://github.com/impact27) +* [PR 381](https://github.com/spyder-ide/spyder-kernels/pull/381) - PR: Eliminate unnecessary updates of the Variable Explorer while debugging, by [@rear1019](https://github.com/rear1019) +* [PR 378](https://github.com/spyder-ide/spyder-kernels/pull/378) - PR: Append paths that come from Spyder's Python path manager to the end of `sys.path`, by [@mrclary](https://github.com/mrclary) + +In this release 8 pull requests were closed. + + +---- + + ## Version 2.3.1 (2022-05-21) ### Pull Requests Merged diff --git a/external-deps/spyder-kernels/requirements/posix.txt b/external-deps/spyder-kernels/requirements/posix.txt index 06200fe10b2..2cfa71bc14d 100644 --- a/external-deps/spyder-kernels/requirements/posix.txt +++ b/external-deps/spyder-kernels/requirements/posix.txt @@ -1,6 +1,6 @@ cloudpickle ipykernel>=6.9.2,<7 ipython>=7.31.1,<8 -jupyter_client>=7.3.1,<8 +jupyter_client>=7.3.4,<8 pyzmq>=22.1.0 wurlitzer>=1.0.3 diff --git a/external-deps/spyder-kernels/requirements/windows.txt b/external-deps/spyder-kernels/requirements/windows.txt index 47bf384de89..c86642865a2 100644 --- a/external-deps/spyder-kernels/requirements/windows.txt +++ b/external-deps/spyder-kernels/requirements/windows.txt @@ -1,5 +1,5 @@ cloudpickle ipykernel>=6.9.2,<7 ipython>=7.31.1,<8 -jupyter_client>=7.3.1,<8 +jupyter_client>=7.3.4,<8 pyzmq>=22.1.0 diff --git a/external-deps/spyder-kernels/setup.py b/external-deps/spyder-kernels/setup.py index ff3d025a781..a0eef4c6704 100644 --- a/external-deps/spyder-kernels/setup.py +++ b/external-deps/spyder-kernels/setup.py @@ -39,7 +39,7 @@ def get_version(module='spyder_kernels'): 'cloudpickle', 'ipykernel>=6.9.2,<7', 'ipython>=7.31.1,<8', - 'jupyter-client>=7.3.1,<8', + 'jupyter-client>=7.3.4,<8', 'packaging', 'pyzmq>=22.1.0', 'wurlitzer>=1.0.3;platform_system!="Windows"', diff --git a/external-deps/spyder-kernels/spyder_kernels/console/shell.py b/external-deps/spyder-kernels/spyder_kernels/console/shell.py index fa69dd38579..86963455e26 100644 --- a/external-deps/spyder-kernels/spyder_kernels/console/shell.py +++ b/external-deps/spyder-kernels/spyder_kernels/console/shell.py @@ -18,6 +18,9 @@ # Third-party imports from ipykernel.zmqshell import ZMQInteractiveShell +# Local imports +from spyder_kernels.utils.mpl import automatic_backend + class SpyderShell(ZMQInteractiveShell): """Spyder shell.""" @@ -39,7 +42,18 @@ def _showtraceback(self, etype, evalue, stb): stb = [''] super(SpyderShell, self)._showtraceback(etype, evalue, stb) - # ---- For Pdb namespace integration + def enable_matplotlib(self, gui=None): + """Enable matplotlib.""" + if gui is None or gui.lower() == "auto": + gui = automatic_backend() + gui, backend = super(SpyderShell, self).enable_matplotlib(gui) + try: + self.kernel.frontend_call(blocking=False).update_matplotlib_gui(gui) + except Exception: + pass + return gui, backend + + # --- For Pdb namespace integration def get_local_scope(self, stack_depth): """Get local scope at given frame depth.""" frame = sys._getframe(stack_depth + 1) diff --git a/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py b/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py index fdc07f7571d..76d9142085a 100644 --- a/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py +++ b/external-deps/spyder-kernels/spyder_kernels/console/tests/test_console_kernel.py @@ -415,11 +415,9 @@ def test_cwd_in_sys_path(): cmd = "from spyder_kernels.console import start; start.main()" with setup_kernel(cmd) as client: - msg_id = client.execute("import sys; sys_path = sys.path", - user_expressions={'output':'sys_path'}) - reply = client.get_shell_msg(timeout=TIMEOUT) - while 'user_expressions' not in reply['content']: - reply = client.get_shell_msg(timeout=TIMEOUT) + reply = client.execute_interactive( + "import sys; sys_path = sys.path", + user_expressions={'output':'sys_path'}, timeout=TIMEOUT) # Transform value obtained through user_expressions user_expressions = reply['content']['user_expressions'] @@ -440,8 +438,7 @@ def test_multiprocessing(tmpdir): with setup_kernel(cmd) as client: # Remove all variables - client.execute("%reset -f") - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("%reset -f", timeout=TIMEOUT) # Write multiprocessing code to a file code = """ @@ -458,8 +455,8 @@ def f(x): p.write(code) # Run code - client.execute("runfile(r'{}')".format(str(p))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive( + "runfile(r'{}')".format(str(p)), timeout=TIMEOUT) # Verify that the `result` variable is defined client.inspect('result') @@ -480,8 +477,7 @@ def test_multiprocessing_2(tmpdir): with setup_kernel(cmd) as client: # Remove all variables - client.execute("%reset -f") - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("%reset -f", timeout=TIMEOUT) # Write multiprocessing code to a file code = """ @@ -503,8 +499,8 @@ def myFunc(i): p.write(code) # Run code - client.execute("runfile(r'{}')".format(str(p))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive( + "runfile(r'{}')".format(str(p)), timeout=TIMEOUT) # Verify that the `result` variable is defined client.inspect('result') @@ -526,8 +522,7 @@ def test_dask_multiprocessing(tmpdir): with setup_kernel(cmd) as client: # Remove all variables - client.execute("%reset -f") - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("%reset -f") # Write multiprocessing code to a file # Runs two times to verify that in the second case it doesn't break @@ -543,11 +538,11 @@ def test_dask_multiprocessing(tmpdir): p.write(code) # Run code two times - client.execute("runfile(r'{}')".format(str(p))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive( + "runfile(r'{}')".format(str(p)), timeout=TIMEOUT) - client.execute("runfile(r'{}')".format(str(p))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive( + "runfile(r'{}')".format(str(p)), timeout=TIMEOUT) # Verify that the `x` variable is defined client.inspect('x') @@ -568,8 +563,7 @@ def test_runfile(tmpdir): with setup_kernel(cmd) as client: # Remove all variables - client.execute("%reset -f") - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("%reset -f", timeout=TIMEOUT) # Write defined variable code to a file code = "result = 'hello world'; error # make an error" @@ -587,9 +581,8 @@ def test_runfile(tmpdir): u.write(code) # Run code file `d` to define `result` even after an error - client.execute("runfile(r'{}', current_namespace=False)" - .format(str(d))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("runfile(r'{}', current_namespace=False)" + .format(str(d)), timeout=TIMEOUT) # Verify that `result` is defined in the current namespace client.inspect('result') @@ -600,9 +593,8 @@ def test_runfile(tmpdir): assert content['found'] # Run code file `u` without current namespace - client.execute("runfile(r'{}', current_namespace=False)" - .format(str(u))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("runfile(r'{}', current_namespace=False)" + .format(str(u)), timeout=TIMEOUT) # Verify that the variable `result2` is defined client.inspect('result2') @@ -613,9 +605,8 @@ def test_runfile(tmpdir): assert content['found'] # Run code file `u` with current namespace - client.execute("runfile(r'{}', current_namespace=True)" - .format(str(u))) - msg = client.get_shell_msg(timeout=TIMEOUT) + msg = client.execute_interactive("runfile(r'{}', current_namespace=True)" + .format(str(u)), timeout=TIMEOUT) content = msg['content'] # Verify that the variable `result3` is defined @@ -644,30 +635,27 @@ def test_np_threshold(kernel): with setup_kernel(cmd) as client: # Set Numpy threshold, suppress and formatter - client.execute(""" + client.execute_interactive(""" import numpy as np; np.set_printoptions( threshold=np.inf, suppress=True, formatter={'float_kind':'{:0.2f}'.format}) - """) - client.get_shell_msg(timeout=TIMEOUT) + """, timeout=TIMEOUT) # Create a big Numpy array and an array to check decimal format - client.execute(""" + client.execute_interactive(""" x = np.random.rand(75000,5); a = np.array([123412341234.123412341234]) -""") - client.get_shell_msg(timeout=TIMEOUT) +""", timeout=TIMEOUT) # Assert that NumPy threshold, suppress and formatter # are the same as the ones set by the user - client.execute(""" + client.execute_interactive(""" t = np.get_printoptions()['threshold']; s = np.get_printoptions()['suppress']; f = np.get_printoptions()['formatter'] -""") - client.get_shell_msg(timeout=TIMEOUT) +""", timeout=TIMEOUT) # Check correct decimal format client.inspect('a') @@ -724,8 +712,7 @@ def test_turtle_launch(tmpdir): with setup_kernel(cmd) as client: # Remove all variables - client.execute("%reset -f") - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("%reset -f", timeout=TIMEOUT) # Write turtle code to a file code = """ @@ -749,8 +736,8 @@ def test_turtle_launch(tmpdir): p.write(code) # Run code - client.execute("runfile(r'{}')".format(str(p))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive( + "runfile(r'{}')".format(str(p)), timeout=TIMEOUT) # Verify that the `tess` variable is defined client.inspect('tess') @@ -767,8 +754,8 @@ def test_turtle_launch(tmpdir): p.write(code) # Run code again - client.execute("runfile(r'{}')".format(str(p))) - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive( + "runfile(r'{}')".format(str(p)), timeout=TIMEOUT) # Verify that the `a` variable is defined client.inspect('a') @@ -788,10 +775,8 @@ def test_matplotlib_inline(kernel): with setup_kernel(cmd) as client: # Get current backend code = "import matplotlib; backend = matplotlib.get_backend()" - client.execute(code, user_expressions={'output': 'backend'}) - reply = client.get_shell_msg(timeout=TIMEOUT) - while 'user_expressions' not in reply['content']: - reply = client.get_shell_msg(timeout=TIMEOUT) + reply = client.execute_interactive( + code, user_expressions={'output': 'backend'}, timeout=TIMEOUT) # Transform value obtained through user_expressions user_expressions = reply['content']['user_expressions'] @@ -1086,8 +1071,8 @@ def test_locals_globals_in_pdb(kernel): not bool(os.environ.get('USE_CONDA')), reason="Doesn't work with pip packages") @pytest.mark.skipif( - sys.version_info[:2] < (3, 8), - reason="Too flaky in Python 3.7 and doesn't work in older versions") + sys.version_info[:2] < (3, 9), + reason="Too flaky in Python 3.7/8 and doesn't work in older versions") def test_get_interactive_backend(backend): """ Test that we correctly get the interactive backend set in the kernel. @@ -1097,17 +1082,15 @@ def test_get_interactive_backend(backend): with setup_kernel(cmd) as client: # Set backend if backend is not None: - client.execute("%matplotlib {}".format(backend)) - client.get_shell_msg(timeout=TIMEOUT) - client.execute("import time; time.sleep(.1)") - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive( + "%matplotlib {}".format(backend), timeout=TIMEOUT) + client.execute_interactive( + "import time; time.sleep(.1)", timeout=TIMEOUT) # Get backend code = "backend = get_ipython().kernel.get_mpl_interactive_backend()" - client.execute(code, user_expressions={'output': 'backend'}) - reply = client.get_shell_msg(timeout=TIMEOUT) - while 'user_expressions' not in reply['content']: - reply = client.get_shell_msg(timeout=TIMEOUT) + reply = client.execute_interactive( + code, user_expressions={'output': 'backend'}, timeout=TIMEOUT) # Get value obtained through user_expressions user_expressions = reply['content']['user_expressions'] @@ -1129,8 +1112,7 @@ def test_global_message(tmpdir): with setup_kernel(cmd) as client: # Remove all variables - client.execute("%reset -f") - client.get_shell_msg(timeout=TIMEOUT) + client.execute_interactive("%reset -f", timeout=TIMEOUT) # Write code with a global to a file code = ( @@ -1143,23 +1125,27 @@ def test_global_message(tmpdir): p = tmpdir.join("test.py") p.write(code) + global found + found = False + + def check_found(msg): + if "text" in msg["content"]: + if ("WARNING: This file contains a global statement" in + msg["content"]["text"]): + global found + found = True # Run code in current namespace - client.execute("runfile(r'{}', current_namespace=True)".format( - str(p))) - msg = client.get_iopub_msg(timeout=TIMEOUT) - while "text" not in msg["content"]: - msg = client.get_iopub_msg(timeout=TIMEOUT) - assert "WARNING: This file contains a global statement" not in ( - msg["content"]["text"]) + client.execute_interactive("runfile(r'{}', current_namespace=True)".format( + str(p)), timeout=TIMEOUT, output_hook=check_found) + assert not found # Run code in empty namespace - client.execute("runfile(r'{}')".format(str(p))) - msg = client.get_iopub_msg(timeout=TIMEOUT) - while "text" not in msg["content"]: - msg = client.get_iopub_msg(timeout=TIMEOUT) - assert "WARNING: This file contains a global statement" in ( - msg["content"]["text"]) + client.execute_interactive( + "runfile(r'{}')".format(str(p)), timeout=TIMEOUT, + output_hook=check_found) + + assert found if __name__ == "__main__": diff --git a/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py b/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py index 7e84f2187ad..6dc0471acc2 100755 --- a/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py +++ b/external-deps/spyder-kernels/spyder_kernels/customize/namespace_manager.py @@ -6,11 +6,31 @@ import linecache import os.path +import types import sys from IPython.core.getipython import get_ipython +def new_main_mod(filename, modname): + """ + Reimplemented from IPython/core/interactiveshell.py to avoid caching + and clearing recursive namespace. + """ + filename = os.path.abspath(filename) + + main_mod = types.ModuleType( + modname, + doc="Module created for script run in IPython") + + main_mod.__file__ = filename + # It seems pydoc (and perhaps others) needs any module instance to + # implement a __nonzero__ method + main_mod.__nonzero__ = lambda : True + + return main_mod + + class NamespaceManager: """ Get a namespace and set __file__ to filename for this namespace. @@ -48,9 +68,7 @@ def __enter__(self): self._previous_filename = self.ns_globals['__file__'] self.ns_globals['__file__'] = self.filename else: - - main_mod = ipython_shell.new_main_mod( - self.filename, '__main__') + main_mod = new_main_mod(self.filename, '__main__') self.ns_globals = main_mod.__dict__ self.ns_locals = None # Needed to allow pickle to reference main From 387f396a6a7af2ffd5dc3a23cf1c4df80a815c73 Mon Sep 17 00:00:00 2001 From: dalthviz Date: Wed, 13 Jul 2022 13:54:10 -0500 Subject: [PATCH 54/83] Update Changelog --- changelogs/Spyder-5.md | 121 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/changelogs/Spyder-5.md b/changelogs/Spyder-5.md index 36c50d59258..581d3712018 100644 --- a/changelogs/Spyder-5.md +++ b/changelogs/Spyder-5.md @@ -1,5 +1,126 @@ # History of changes for Spyder 5 +## Version 5.3.2 (2022-07-13) + +### New features + +* Add code signing to the standalone macOS installer. +* Add `openpyxml` and `defusedxml` to the packages bundled with the standalone Windows and macOS installers. +* New entry from the Editor context menu to `Show help for current object`. +* Improve UX/UI for the repositioning panes functionality. + +### Important fixes + +* Fix several bugs related to the debugging functionality (remote kernels usage and Pdb history). +* Fix incompatibility with Pylint 2.14.0+. +* Fix Windows Python environment activation script with micromamba. +* Fix several bugs related with the Plots pane. + +### New API features + +* Add `create_client_for_kernel` and `rename_client_tab` to the Ipython Console plugin so that other plugins can access to console creation like [Spyder-notebook](https://github.com/spyder-ide/spyder-notebook/pull/369). + +### Issues Closed + +* [Issue 18624](https://github.com/spyder-ide/spyder/issues/18624) - Asking to save modified file twice ([PR 18625](https://github.com/spyder-ide/spyder/pull/18625) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 18599](https://github.com/spyder-ide/spyder/issues/18599) - Issue reporter raises AttributeError upon dismissing ([PR 18613](https://github.com/spyder-ide/spyder/pull/18613) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 18597](https://github.com/spyder-ide/spyder/issues/18597) - Opening preferences results in UnboundLocalError in macOS app ([PR 18598](https://github.com/spyder-ide/spyder/pull/18598) by [@mrclary](https://github.com/mrclary)) +* [Issue 18531](https://github.com/spyder-ide/spyder/issues/18531) - Error when creating Pdb history file ([PR 18533](https://github.com/spyder-ide/spyder/pull/18533) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 18479](https://github.com/spyder-ide/spyder/issues/18479) - Release Spyder 5.3.2 ([PR 18655](https://github.com/spyder-ide/spyder/pull/18655) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 18407](https://github.com/spyder-ide/spyder/issues/18407) - Numpy 1.23.0 breaks autocompletion and makes the tests fail ([PR 18413](https://github.com/spyder-ide/spyder/pull/18413) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 18330](https://github.com/spyder-ide/spyder/issues/18330) - Errors when debugging with remote kernel ([PR 18512](https://github.com/spyder-ide/spyder/pull/18512) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 18290](https://github.com/spyder-ide/spyder/issues/18290) - Error when enabling `Underline errors and warnings` linting option ([PR 18303](https://github.com/spyder-ide/spyder/pull/18303) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 18262](https://github.com/spyder-ide/spyder/issues/18262) - OSError when trying to start files server ([PR 18437](https://github.com/spyder-ide/spyder/pull/18437) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 18175](https://github.com/spyder-ide/spyder/issues/18175) - Pylint 2.14.0 code analysis won't work on miniconda conda-forge install on Windows ([PR 18106](https://github.com/spyder-ide/spyder/pull/18106) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 18071](https://github.com/spyder-ide/spyder/issues/18071) - Missing Pandas optional dependency `openpyxl` to read excel files on installers +* [Issue 18010](https://github.com/spyder-ide/spyder/issues/18010) - Fatal Python error when running profiler in macOS app with external environment ([PR 18031](https://github.com/spyder-ide/spyder/pull/18031) by [@mrclary](https://github.com/mrclary)) +* [Issue 18005](https://github.com/spyder-ide/spyder/issues/18005) - TypeError when getting background color - DataFrameEditor ([PR 18007](https://github.com/spyder-ide/spyder/pull/18007) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 18003](https://github.com/spyder-ide/spyder/issues/18003) - Matplotlib not installed or didn't load correctly in Spyder 5.3.1 ([PR 18387](https://github.com/spyder-ide/spyder/pull/18387) by [@mrclary](https://github.com/mrclary)) +* [Issue 17945](https://github.com/spyder-ide/spyder/issues/17945) - IPython console widget size changes on startup if vertical panes are combined ([PR 18332](https://github.com/spyder-ide/spyder/pull/18332) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 17915](https://github.com/spyder-ide/spyder/issues/17915) - Startup run code of IPython is not working when using projects ([PR 17997](https://github.com/spyder-ide/spyder/pull/17997) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 17872](https://github.com/spyder-ide/spyder/issues/17872) - Python interpreter file browser resolves symlinks ([PR 17874](https://github.com/spyder-ide/spyder/pull/17874) by [@mrclary](https://github.com/mrclary)) +* [Issue 17835](https://github.com/spyder-ide/spyder/issues/17835) - Problems with Spyder on Mac with Conda-forge and `applaunchservices` ([PR 18530](https://github.com/spyder-ide/spyder/pull/18530) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 17815](https://github.com/spyder-ide/spyder/issues/17815) - Check usage of `pytest-timeout` to prevent some tests from hanging the CI ([PR 17990](https://github.com/spyder-ide/spyder/pull/17990) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 17753](https://github.com/spyder-ide/spyder/issues/17753) - Another ZeroDivisionError in the Plots pane ([PR 18504](https://github.com/spyder-ide/spyder/pull/18504) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 17701](https://github.com/spyder-ide/spyder/issues/17701) - Disable pyls-flake8 in PyLSP configuration ([PR 18438](https://github.com/spyder-ide/spyder/pull/18438) by [@ccordoba12](https://github.com/ccordoba12)) +* [Issue 17511](https://github.com/spyder-ide/spyder/issues/17511) - Inconsistent use of system PYTHONPATH in completions and IPython Console ([PR 17512](https://github.com/spyder-ide/spyder/pull/17512) by [@mrclary](https://github.com/mrclary)) +* [Issue 17425](https://github.com/spyder-ide/spyder/issues/17425) - Small bugs in kernel update error screen ([PR 18471](https://github.com/spyder-ide/spyder/pull/18471) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 17406](https://github.com/spyder-ide/spyder/issues/17406) - Questions about development environment ([PR 17408](https://github.com/spyder-ide/spyder/pull/17408) by [@mrclary](https://github.com/mrclary)) +* [Issue 16414](https://github.com/spyder-ide/spyder/issues/16414) - Add code signing to macOS installer ([PR 16490](https://github.com/spyder-ide/spyder/pull/16490) by [@mrclary](https://github.com/mrclary)) +* [Issue 15223](https://github.com/spyder-ide/spyder/issues/15223) - ZeroDivisionError when generating thumbnail in Plots ([PR 18504](https://github.com/spyder-ide/spyder/pull/18504) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 15074](https://github.com/spyder-ide/spyder/issues/15074) - Screen flickering the first time I open Spyder (MacOS) ([PR 18332](https://github.com/spyder-ide/spyder/pull/18332) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 14883](https://github.com/spyder-ide/spyder/issues/14883) - `A monitor scale changed was detected` message blocks the window ([PR 18323](https://github.com/spyder-ide/spyder/pull/18323) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 14806](https://github.com/spyder-ide/spyder/issues/14806) - CTRL-S does not save file, if pop-up menu is open ([PR 18414](https://github.com/spyder-ide/spyder/pull/18414) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 13812](https://github.com/spyder-ide/spyder/issues/13812) - Error in tutorial ([PR 18194](https://github.com/spyder-ide/spyder/pull/18194) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 13043](https://github.com/spyder-ide/spyder/issues/13043) - Beginners Tutorial is wrong ([PR 18194](https://github.com/spyder-ide/spyder/pull/18194) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 10603](https://github.com/spyder-ide/spyder/issues/10603) - Segmentation fault when accesing void object of numpy module ([PR 10617](https://github.com/spyder-ide/spyder/pull/10617) by [@impact27](https://github.com/impact27)) +* [Issue 978](https://github.com/spyder-ide/spyder/issues/978) - Add "Show help for current object" option to Editor context menu ([PR 18180](https://github.com/spyder-ide/spyder/pull/18180) by [@jsbautista](https://github.com/jsbautista)) + +In this release 33 issues were closed. + +### Pull Requests Merged + +* [PR 18655](https://github.com/spyder-ide/spyder/pull/18655) - PR: Update core dependencies for 5.3.2, by [@dalthviz](https://github.com/dalthviz) ([18479](https://github.com/spyder-ide/spyder/issues/18479)) +* [PR 18625](https://github.com/spyder-ide/spyder/pull/18625) - PR: Don't double validate if plugins can be deleted/closed (Registry), by [@dalthviz](https://github.com/dalthviz) ([18624](https://github.com/spyder-ide/spyder/issues/18624)) +* [PR 18613](https://github.com/spyder-ide/spyder/pull/18613) - PR: Fix error when closing error dialog (Console), by [@ccordoba12](https://github.com/ccordoba12) ([18599](https://github.com/spyder-ide/spyder/issues/18599)) +* [PR 18598](https://github.com/spyder-ide/spyder/pull/18598) - PR: Fix issue where macOS_group was referenced before assignment (Preferences), by [@mrclary](https://github.com/mrclary) ([18597](https://github.com/spyder-ide/spyder/issues/18597)) +* [PR 18573](https://github.com/spyder-ide/spyder/pull/18573) - PR: Update minimal required version for PyLSP to 1.5.0 and its subrepo, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 18548](https://github.com/spyder-ide/spyder/pull/18548) - PR: Change default installers assets download URL (Windows), by [@dalthviz](https://github.com/dalthviz) +* [PR 18544](https://github.com/spyder-ide/spyder/pull/18544) - PR: Add new API methods to the IPython console, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 18533](https://github.com/spyder-ide/spyder/pull/18533) - PR: Catch errors when creating or accessing Pdb history (IPython console), by [@ccordoba12](https://github.com/ccordoba12) ([18531](https://github.com/spyder-ide/spyder/issues/18531)) +* [PR 18530](https://github.com/spyder-ide/spyder/pull/18530) - PR: Require `applaunchservices` 0.3.0+, by [@ccordoba12](https://github.com/ccordoba12) ([17835](https://github.com/spyder-ide/spyder/issues/17835)) +* [PR 18519](https://github.com/spyder-ide/spyder/pull/18519) - PR: Update translations from Crowdin, by [@spyder-bot](https://github.com/spyder-bot) +* [PR 18518](https://github.com/spyder-ide/spyder/pull/18518) - PR: Update translations for 5.3.2, by [@dalthviz](https://github.com/dalthviz) +* [PR 18513](https://github.com/spyder-ide/spyder/pull/18513) - PR: Add tests for running namespace, by [@impact27](https://github.com/impact27) +* [PR 18512](https://github.com/spyder-ide/spyder/pull/18512) - PR: Fix filenames used for remote kernels while debugging, by [@ccordoba12](https://github.com/ccordoba12) ([18330](https://github.com/spyder-ide/spyder/issues/18330)) +* [PR 18506](https://github.com/spyder-ide/spyder/pull/18506) - PR: Restrict unnecessary workflows when only installer files are changed., by [@mrclary](https://github.com/mrclary) +* [PR 18504](https://github.com/spyder-ide/spyder/pull/18504) - PR: Prevent ZeroDivisionError when calculating plots scales and sizes (Plots), by [@dalthviz](https://github.com/dalthviz) ([17753](https://github.com/spyder-ide/spyder/issues/17753), [15223](https://github.com/spyder-ide/spyder/issues/15223)) +* [PR 18485](https://github.com/spyder-ide/spyder/pull/18485) - PR: Remove `applaunchservices` dependency for macOS app, by [@mrclary](https://github.com/mrclary) +* [PR 18481](https://github.com/spyder-ide/spyder/pull/18481) - PR: Copy all NSIS plugins into discoverable path for the Windows installer build, by [@jsbautista](https://github.com/jsbautista) +* [PR 18471](https://github.com/spyder-ide/spyder/pull/18471) - PR: Escape hyphen from update instructions for spyder-kernels (IPython Console), by [@dalthviz](https://github.com/dalthviz) ([17425](https://github.com/spyder-ide/spyder/issues/17425)) +* [PR 18438](https://github.com/spyder-ide/spyder/pull/18438) - PR: Disable pyls-flake8 plugin (Completions), by [@ccordoba12](https://github.com/ccordoba12) ([17701](https://github.com/spyder-ide/spyder/issues/17701)) +* [PR 18437](https://github.com/spyder-ide/spyder/pull/18437) - PR: Catch error when it's not possible to bind a port to `open_files_server` (Main Window), by [@ccordoba12](https://github.com/ccordoba12) ([18262](https://github.com/spyder-ide/spyder/issues/18262)) +* [PR 18414](https://github.com/spyder-ide/spyder/pull/18414) - PR: Hide completion widget when keypress has modifiers (Editor), by [@dalthviz](https://github.com/dalthviz) ([14806](https://github.com/spyder-ide/spyder/issues/14806)) +* [PR 18413](https://github.com/spyder-ide/spyder/pull/18413) - PR: Use Numpy 1.22 because 1.23 is not giving completions, by [@ccordoba12](https://github.com/ccordoba12) ([18407](https://github.com/spyder-ide/spyder/issues/18407)) +* [PR 18387](https://github.com/spyder-ide/spyder/pull/18387) - PR: Fix issue where micromamba activation script was not properly executed., by [@mrclary](https://github.com/mrclary) ([18003](https://github.com/spyder-ide/spyder/issues/18003)) +* [PR 18377](https://github.com/spyder-ide/spyder/pull/18377) - PR: Don't connect Qt signals to the `emit` method of others to avoid segfaults, by [@impact27](https://github.com/impact27) +* [PR 18332](https://github.com/spyder-ide/spyder/pull/18332) - PR: Ensure setting up last dockwidgets size distributions (Layout and Registry), by [@dalthviz](https://github.com/dalthviz) ([17945](https://github.com/spyder-ide/spyder/issues/17945), [15074](https://github.com/spyder-ide/spyder/issues/15074)) +* [PR 18323](https://github.com/spyder-ide/spyder/pull/18323) - PR: Prevent showing monitor scale change message if auto high DPI is selected and some other fixes, by [@dalthviz](https://github.com/dalthviz) ([14883](https://github.com/spyder-ide/spyder/issues/14883)) +* [PR 18310](https://github.com/spyder-ide/spyder/pull/18310) - PR: Make validation of blocks with Outline explorer data faster, by [@impact27](https://github.com/impact27) +* [PR 18306](https://github.com/spyder-ide/spyder/pull/18306) - PR: Improve the test suite reliability on CIs, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 18304](https://github.com/spyder-ide/spyder/pull/18304) - PR: Fix syntax issues in macOS installer workflow file, by [@mrclary](https://github.com/mrclary) +* [PR 18303](https://github.com/spyder-ide/spyder/pull/18303) - PR: Fix error when enabling `Underline errors and warnings` in Preferences (Editor), by [@ccordoba12](https://github.com/ccordoba12) ([18290](https://github.com/spyder-ide/spyder/issues/18290)) +* [PR 18297](https://github.com/spyder-ide/spyder/pull/18297) - PR: Cache validation of local Kite installation on Mac, by [@impact27](https://github.com/impact27) +* [PR 18278](https://github.com/spyder-ide/spyder/pull/18278) - PR: Fix issue where nbconvert was not importable in macOS application, by [@mrclary](https://github.com/mrclary) +* [PR 18275](https://github.com/spyder-ide/spyder/pull/18275) - PR: Add flag to prevent downloading assets for the Windows installer script, by [@dalthviz](https://github.com/dalthviz) +* [PR 18233](https://github.com/spyder-ide/spyder/pull/18233) - PR: Update instructions to build the Windows installer, by [@jsbautista](https://github.com/jsbautista) +* [PR 18194](https://github.com/spyder-ide/spyder/pull/18194) - PR: Update Spyder tutorial (Help), by [@dalthviz](https://github.com/dalthviz) ([13812](https://github.com/spyder-ide/spyder/issues/13812), [13043](https://github.com/spyder-ide/spyder/issues/13043)) +* [PR 18180](https://github.com/spyder-ide/spyder/pull/18180) - PR: Add `Show help for current object` option to Editor context menu, by [@jsbautista](https://github.com/jsbautista) ([978](https://github.com/spyder-ide/spyder/issues/978)) +* [PR 18172](https://github.com/spyder-ide/spyder/pull/18172) - PR: Change all strings displayed to the user from `Python script` to `Python file`, by [@jsbautista](https://github.com/jsbautista) ([29](https://github.com/spyder-ide/ux-improvements/issues/29)) +* [PR 18121](https://github.com/spyder-ide/spyder/pull/18121) - PR: Really add `defusedxml` and `openpyxl` to the Mac app build, by [@mrclary](https://github.com/mrclary) +* [PR 18120](https://github.com/spyder-ide/spyder/pull/18120) - PR: Only build Full macOS app on pull requests, by [@mrclary](https://github.com/mrclary) +* [PR 18108](https://github.com/spyder-ide/spyder/pull/18108) - PR: `test_sympy_client` xpasses with sympy version 1.10.1, by [@juliangilbey](https://github.com/juliangilbey) +* [PR 18107](https://github.com/spyder-ide/spyder/pull/18107) - PR: Add openpyxl and defusedxml packages to full macOS app version, by [@mrclary](https://github.com/mrclary) +* [PR 18106](https://github.com/spyder-ide/spyder/pull/18106) - PR: Fix tests due to Pylint 2.14.0 and some test stalling the CI or falling due to leak validations, by [@dalthviz](https://github.com/dalthviz) ([18175](https://github.com/spyder-ide/spyder/issues/18175)) +* [PR 18105](https://github.com/spyder-ide/spyder/pull/18105) - PR: Add `openpyxl` and `defusedxml` to the full version Windows installers, by [@dalthviz](https://github.com/dalthviz) +* [PR 18074](https://github.com/spyder-ide/spyder/pull/18074) - PR: Add installer and environment info to issue reporter, by [@mrclary](https://github.com/mrclary) +* [PR 18031](https://github.com/spyder-ide/spyder/pull/18031) - PR: Remove PYTHONHOME from QProcess.processEnvironment in profiler, by [@mrclary](https://github.com/mrclary) ([18010](https://github.com/spyder-ide/spyder/issues/18010)) +* [PR 18007](https://github.com/spyder-ide/spyder/pull/18007) - PR: Catch error when computing the background color of a dataframe column (Variable Explorer), by [@ccordoba12](https://github.com/ccordoba12) ([18005](https://github.com/spyder-ide/spyder/issues/18005)) +* [PR 17997](https://github.com/spyder-ide/spyder/pull/17997) - PR: Don't use cached kernel for a full console restart, by [@dalthviz](https://github.com/dalthviz) ([17915](https://github.com/spyder-ide/spyder/issues/17915)) +* [PR 17990](https://github.com/spyder-ide/spyder/pull/17990) - PR: Use `pytest-timeout` and set timeout to 120 secs, by [@dalthviz](https://github.com/dalthviz) ([17815](https://github.com/spyder-ide/spyder/issues/17815)) +* [PR 17874](https://github.com/spyder-ide/spyder/pull/17874) - PR: Do not resolve symlink on Python interpreter file selection, by [@mrclary](https://github.com/mrclary) ([17872](https://github.com/spyder-ide/spyder/issues/17872)) +* [PR 17813](https://github.com/spyder-ide/spyder/pull/17813) - PR: Improve UI/UX of how repositioning panes work, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 17512](https://github.com/spyder-ide/spyder/pull/17512) - PR: Consistently handle PYTHONPATH and add Import feature to PYTHONPATH Manager, by [@mrclary](https://github.com/mrclary) ([17511](https://github.com/spyder-ide/spyder/issues/17511)) +* [PR 17408](https://github.com/spyder-ide/spyder/pull/17408) - PR: Modernize bootstrap script, by [@mrclary](https://github.com/mrclary) ([17406](https://github.com/spyder-ide/spyder/issues/17406)) +* [PR 16490](https://github.com/spyder-ide/spyder/pull/16490) - PR: Code sign macOS app, by [@mrclary](https://github.com/mrclary) ([16414](https://github.com/spyder-ide/spyder/issues/16414)) +* [PR 10617](https://github.com/spyder-ide/spyder/pull/10617) - PR: Don't edit Numpy void objects in the Variable Explorer, by [@impact27](https://github.com/impact27) ([10603](https://github.com/spyder-ide/spyder/issues/10603)) + +In this release 54 pull requests were closed. + + +---- + + ## Version 5.3.1 (2022-05-23) ### New features From ccf9337f73b686727803e279c1e99744431755df Mon Sep 17 00:00:00 2001 From: dalthviz Date: Wed, 13 Jul 2022 14:02:03 -0500 Subject: [PATCH 55/83] Update Announcements --- Announcements.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/Announcements.md b/Announcements.md index c1a37474263..530d84ca516 100644 --- a/Announcements.md +++ b/Announcements.md @@ -1,31 +1,29 @@ # Minor release to list -**Subject**: [ANN] Spyder 5.3.1 is released! +**Subject**: [ANN] Spyder 5.3.2 is released! Hi all, On the behalf of the [Spyder Project Contributors](https://github.com/spyder-ide/spyder/graphs/contributors), -I'm pleased to announce that Spyder **5.3.1** has been released and is available for +I'm pleased to announce that Spyder **5.3.2** has been released and is available for Windows, GNU/Linux and MacOS X: https://github.com/spyder-ide/spyder/releases -This release comes seven weeks after version 5.3.0 and it contains the +This release comes seven weeks after version 5.3.1 and it contains the following new features, important fixes and new API features: -* Add a toolbar to the Variable Explorer viewer for dictionaries, lists and sets to easily access the - functionality available through its context menu. -* Add navigation with extra buttons in the editor for mouses that support them. -* Add `--no-web-widgets` command line option to disable plugins/widgets that use Qt Webengine widgets. -* Fix several important bugs related to the `Autoformat on save` functionality. -* Fix options related to the `Working directory` entry in Preferences. -* Make code completion widget entries accessible to screen readers. -* Add `get_command_line_options` to `SpyderPluginV2` so that plugins can access the command line options - passed to Spyder. -* The current interpreter used by all Spyder plugins can be accessed now through the `executable` option - of the Main interpreter plugin. - -In this release we fixed 37 issues and merged 52 pull requests that amount -to more than 230 commits. For a full list of fixes, please see our +* Add code signing to the standalone macOS installer. +* Add `openpyxml` and `defusedxml` to the packages bundled with the standalone Windows and macOS installers. +* New entry from the Editor context menu to `Show help for current object`. +* Improve UX/UI for the repositioning panes functionality. +* Fix several bugs related to the debugging functionality (remote kernels usage and Pdb history). +* Fix incompatibility with Pylint 2.14.0+. +* Fix Windows Python environment activation script with micromamba. +* Fix several bugs related with the Plots pane. +* Add `create_client_for_kernel` and `rename_client_tab` to the Ipython Console plugin so that other plugins can access to console creation like [Spyder-notebook](https://github.com/spyder-ide/spyder-notebook/pull/369). + +In this release we fixed 33 issues and merged 54 pull requests that amount +to more than 249 commits. For a full list of fixes, please see our [Changelog](https://github.com/spyder-ide/spyder/blob/5.x/CHANGELOG.md). Don't forget to follow Spyder updates/news on the project's @@ -37,7 +35,7 @@ creating your favorite environment! Enjoy! -Carlos +Daniel ---- From 1172bb4678970250c8e8f46a70a151c00d32d69f Mon Sep 17 00:00:00 2001 From: dalthviz Date: Wed, 13 Jul 2022 14:33:38 -0500 Subject: [PATCH 56/83] Release 5.3.2 --- spyder/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/__init__.py b/spyder/__init__.py index 417eda10d80..90321eff0f9 100644 --- a/spyder/__init__.py +++ b/spyder/__init__.py @@ -29,7 +29,7 @@ OTHER DEALINGS IN THE SOFTWARE. """ -version_info = (5, 4, 0, "dev0") +version_info = (5, 3, 2) __version__ = '.'.join(map(str, version_info)) __installer_version__ = __version__ From dbf1fb9e097d8f0ee0ac6cd39de7d8df831e9af4 Mon Sep 17 00:00:00 2001 From: dalthviz Date: Wed, 13 Jul 2022 14:44:21 -0500 Subject: [PATCH 57/83] Back to work [ci skip] --- spyder/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/__init__.py b/spyder/__init__.py index 90321eff0f9..417eda10d80 100644 --- a/spyder/__init__.py +++ b/spyder/__init__.py @@ -29,7 +29,7 @@ OTHER DEALINGS IN THE SOFTWARE. """ -version_info = (5, 3, 2) +version_info = (5, 4, 0, "dev0") __version__ = '.'.join(map(str, version_info)) __installer_version__ = __version__ From 164fed3507b27df9208fc36cc8d1497d90d2b9b3 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Fri, 29 Apr 2022 00:45:46 +0200 Subject: [PATCH 58/83] add .spydata file extension if no extension is given - removed failing filename option --- .../widgets/namespacebrowser.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index 475adcbb757..a0e647248fc 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -240,19 +240,21 @@ def reset_namespace(self): self.shellwidget.reset_namespace(warning=warning, message=True) self.editor.automatic_column_width = True - def save_data(self, filename=None): + def save_data(self): """Save data""" + filename = self.filename if filename is None: - filename = self.filename - if filename is None: - filename = getcwd_or_home() - filename, _selfilter = getsavefilename(self, _("Save data"), - filename, - iofunctions.save_filters) - if filename: - self.filename = filename - else: - return False + filename = getcwd_or_home() + ext = osp.splitext(filename)[1].lower() + if not ext: + filename = filename + '.spydata' + filename, _selfilter = getsavefilename(self, _("Save data"), + filename, + iofunctions.save_filters) + if filename: + self.filename = filename + else: + return False QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) QApplication.processEvents() From a9a3fb49a4fe364b2329087eecdf8ccd82057e94 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Thu, 14 Jul 2022 21:09:02 +0200 Subject: [PATCH 59/83] fix changes according to review - rename ext variable to more explicit extension - rename ext in iimport_data to extension too for consistency - add comment for related issue #7196 --- .../widgets/namespacebrowser.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index a0e647248fc..f7d2968438e 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -181,15 +181,15 @@ def import_data(self, filenames=None): self.filename = str(filename) if os.name == "nt": self.filename = remove_backslashes(self.filename) - ext = osp.splitext(self.filename)[1].lower() + extension = osp.splitext(self.filename)[1].lower() - if ext not in iofunctions.load_funcs: + if extension not in iofunctions.load_funcs: buttons = QMessageBox.Yes | QMessageBox.Cancel answer = QMessageBox.question(self, title, _("Unsupported file extension '%s'

" "Would you like to import it anyway " "(by selecting a known file format)?" - ) % ext, buttons) + ) % extension, buttons) if answer == QMessageBox.Cancel: return formats = list(iofunctions.load_extensions.keys()) @@ -197,11 +197,11 @@ def import_data(self, filenames=None): _('Open file as:'), formats, 0, False) if ok: - ext = iofunctions.load_extensions[str(item)] + extension = iofunctions.load_extensions[str(item)] else: return - load_func = iofunctions.load_funcs[ext] + load_func = iofunctions.load_funcs[extension] # 'import_wizard' (self.setup_io) if isinstance(load_func, str): @@ -220,7 +220,7 @@ def import_data(self, filenames=None): else: QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) QApplication.processEvents() - error_message = self.shellwidget.load_data(self.filename, ext) + error_message = self.shellwidget.load_data(self.filename, extension) QApplication.restoreOverrideCursor() QApplication.processEvents() @@ -245,8 +245,10 @@ def save_data(self): filename = self.filename if filename is None: filename = getcwd_or_home() - ext = osp.splitext(filename)[1].lower() - if not ext: + extension = osp.splitext(filename)[1].lower() + if not extension: + # Needed to prevent trying to save a data file without extension + # See spyder-ide/spyder#7196 filename = filename + '.spydata' filename, _selfilter = getsavefilename(self, _("Save data"), filename, From ce83f66c061f8b2705318f83746b4d46b27d4cc1 Mon Sep 17 00:00:00 2001 From: Florian Maurer Date: Thu, 14 Jul 2022 23:29:45 +0200 Subject: [PATCH 60/83] fix pep8 linting --- spyder/plugins/variableexplorer/widgets/namespacebrowser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index f7d2968438e..ecfae98a9c5 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -220,7 +220,8 @@ def import_data(self, filenames=None): else: QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) QApplication.processEvents() - error_message = self.shellwidget.load_data(self.filename, extension) + error_message = self.shellwidget.load_data(self.filename, + extension) QApplication.restoreOverrideCursor() QApplication.processEvents() From 9fc2368be9e2d69e70df364d6933f0187f052508 Mon Sep 17 00:00:00 2001 From: "A. Reit" Date: Mon, 27 Jun 2022 06:13:57 +0200 Subject: [PATCH 61/83] pyside2: Catch correct exception type for failed signal disconnect PySide2 throws a RuntimeError if disconnect() of a signal fails (unlike PyQt, which throws a TypeError). --- spyder/plugins/editor/widgets/codeeditor.py | 2 +- spyder/plugins/ipythonconsole/widgets/client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index d18c33548ea..8dd2b69baeb 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -2039,7 +2039,7 @@ def notify_close(self): # before sending that request here. self._timer_sync_symbols_and_folding.timeout.disconnect( self.sync_symbols_and_folding) - except TypeError: + except (TypeError, RuntimeError): pass params = { diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 5c9a7d4cf61..86efdf9626c 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -412,7 +412,7 @@ def remove_std_files(self, is_last_client=True): """Remove stderr_file associated with the client.""" try: self.shellwidget.executed.disconnect(self.poll_std_file_change) - except TypeError: + except (TypeError, ValueError): pass if self.std_poll_timer is not None: self.std_poll_timer.stop() From 31b295ff5a970b3af05e7a423b5c8c05832ec836 Mon Sep 17 00:00:00 2001 From: Ryan Clary Date: Thu, 14 Jul 2022 22:04:59 -0700 Subject: [PATCH 62/83] Patch rpath in micromamba so that code signing doesn't break it. --- .github/workflows/installer-macos.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index d879e5b34dd..2ebebaeaa32 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -79,6 +79,7 @@ jobs: working-directory: ${{ github.workspace }}/spyder run: | curl -Ls https://micro.mamba.pm/api/micromamba/osx-64/latest | tar -xvj bin/micromamba + install_name_tool -change @rpath/libc++.1.dylib /usr/lib/libc++.1.dylib bin/micromamba - name: Build Application Bundle run: ${pythonLocation}/bin/python setup.py ${LITE_FLAG} --dist-dir ${DISTDIR} - name: Create Keychain From 9f18ec44a44e53228f17b08d719d88d062861ea6 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Fri, 15 Jul 2022 20:33:52 +0200 Subject: [PATCH 63/83] check block safety --- spyder/plugins/editor/panels/scrollflag.py | 7 ++++--- spyder/plugins/editor/utils/editor.py | 17 +++++++++++++++++ spyder/plugins/editor/widgets/codeeditor.py | 12 ++++++++++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/editor/panels/scrollflag.py b/spyder/plugins/editor/panels/scrollflag.py index 5a761f24935..2310b576859 100644 --- a/spyder/plugins/editor/panels/scrollflag.py +++ b/spyder/plugins/editor/panels/scrollflag.py @@ -20,6 +20,7 @@ # Local imports from spyder.api.panel import Panel from spyder.plugins.completion.api import DiagnosticSeverity +from spyder.plugins.editor.utils.editor import block_safe REFRESH_RATE = 1000 @@ -221,7 +222,7 @@ def paintEvent(self, event): if editor.verticalScrollBar().maximum() == 0: # No scroll for block in dict_flag_lists[flag_type]: - if not block.isValid(): + if not block_safe(block): continue geometry = editor.blockBoundingGeometry(block) rect_y = ceil( @@ -233,7 +234,7 @@ def paintEvent(self, event): elif last_line == 0: # Only one line for block in dict_flag_lists[flag_type]: - if not block.isValid(): + if not block_safe(block): continue rect_y = ceil(first_y_pos) painter.drawRect(rect_x, rect_y, rect_w, rect_h) @@ -243,7 +244,7 @@ def paintEvent(self, event): # If the file is too long, do not freeze the editor next_line = 0 for block in dict_flag_lists[flag_type]: - if not block.isValid(): + if not block_safe(block): continue block_line = block.firstLineNumber() # block_line = -1 if invalid diff --git a/spyder/plugins/editor/utils/editor.py b/spyder/plugins/editor/utils/editor.py index 1b6b127d54d..b1b83b35b33 100644 --- a/spyder/plugins/editor/utils/editor.py +++ b/spyder/plugins/editor/utils/editor.py @@ -51,6 +51,23 @@ def drift_color(base_color, factor=110): return base_color.lighter(factor + 10) +def block_safe(block): + """ + Check if the block is safe to work with. + + A BlockUserData must have been set on the block while it was known safe. + + If an editor is cleared by editor.clear() or editor.set_text() for example, + all the old blocks will continue to report block.isValid() == True + but will raise a Segmentation Fault on almost all methods. + + One way to check is that the userData is reset to None or + QTextBlockUserData. So if a block is known to have setUserData to + BlockUserData, this fact can be used to check the block. + """ + return block.isValid() and isinstance(block.userData(), BlockUserData) + + class BlockUserData(QTextBlockUserData): def __init__(self, editor, color=None, selection_start=None, selection_end=None): diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index d18c33548ea..5c959d8e6d0 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -2579,7 +2579,11 @@ def __mark_occurrences(self): extra_selections = self.get_extra_selections('occurrences') first_occurrence = None while cursor: - self.occurrences.append(cursor.block()) + block = cursor.block() + if not block.userData(): + # Add user data to check block validity + block.setUserData(BlockUserData(self)) + self.occurrences.append(block) selection = self.get_selection(cursor) if len(selection.cursor.selectedText()) > 0: extra_selections.append(selection) @@ -2623,7 +2627,11 @@ def highlight_found_results(self, pattern, word=False, regexp=False, selection = TextDecoration(self.textCursor()) selection.format.setBackground(self.found_results_color) selection.cursor.setPosition(pos1) - self.found_results.append(selection.cursor.block()) + block = selection.cursor.block() + if not block.userData(): + # Add user data to check block validity + block.setUserData(BlockUserData(self)) + self.found_results.append(block) selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor) extra_selections.append(selection) self.set_extra_selections('find', extra_selections) From b269a868e4252e68e7690eea4108c8aa1dff4b4c Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Fri, 15 Jul 2022 23:28:16 +1000 Subject: [PATCH 64/83] docs: Fix a few typos There are small typos in: - changelogs/Spyder-4.md - changelogs/Spyder-5.md - spyder/plugins/help/utils/js/jquery.js - spyder/plugins/ipythonconsole/widgets/debugging.py Fixes: - Should read `synchronously` rather than `syncronously`. - Should read `independent` rather than `independant`. - Should read `encoding` rather than `enconding`. Signed-off-by: Tim Gates --- changelogs/Spyder-4.md | 2 +- changelogs/Spyder-5.md | 2 +- spyder/plugins/ipythonconsole/widgets/debugging.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changelogs/Spyder-4.md b/changelogs/Spyder-4.md index 0fe74ebdcef..cfa6f3b31c1 100644 --- a/changelogs/Spyder-4.md +++ b/changelogs/Spyder-4.md @@ -1365,7 +1365,7 @@ In this release 32 issues were closed. * [PR 11159](https://github.com/spyder-ide/spyder/pull/11159) - PR: Don't register keyring as a dependency on Linux and Python 2 * [PR 11141](https://github.com/spyder-ide/spyder/pull/11141) - PR: Fix simple typo in docstring ([11140](https://github.com/spyder-ide/spyder/issues/11140)) * [PR 11114](https://github.com/spyder-ide/spyder/pull/11114) - PR: Handle CompletionWidget position when undocking editor ([11076](https://github.com/spyder-ide/spyder/issues/11076)) -* [PR 11104](https://github.com/spyder-ide/spyder/pull/11104) - PR: Handle enconding error when copying dataframes in Python 2 ([4833](https://github.com/spyder-ide/spyder/issues/4833)) +* [PR 11104](https://github.com/spyder-ide/spyder/pull/11104) - PR: Handle encoding error when copying dataframes in Python 2 ([4833](https://github.com/spyder-ide/spyder/issues/4833)) * [PR 11102](https://github.com/spyder-ide/spyder/pull/11102) - PR: Set time limit to calculate columns size hint for Dataframe Editor ([11060](https://github.com/spyder-ide/spyder/issues/11060)) * [PR 11100](https://github.com/spyder-ide/spyder/pull/11100) - PR: Copy index and headers of dataframe ([11096](https://github.com/spyder-ide/spyder/issues/11096)) * [PR 11091](https://github.com/spyder-ide/spyder/pull/11091) - PR: Workaround to avoid a glitch when duplicating current line or text selection diff --git a/changelogs/Spyder-5.md b/changelogs/Spyder-5.md index 581d3712018..d8ef68a1284 100644 --- a/changelogs/Spyder-5.md +++ b/changelogs/Spyder-5.md @@ -149,7 +149,7 @@ In this release 54 pull requests were closed. * [Issue 17861](https://github.com/spyder-ide/spyder/issues/17861) - Python interpreter path overwritten in console w/external interpreter on the MacOS standalone version if the `Spyder.app` name is changed ([PR 17868](https://github.com/spyder-ide/spyder/pull/17868) by [@mrclary](https://github.com/mrclary)) * [Issue 17836](https://github.com/spyder-ide/spyder/issues/17836) - Autoformat file on save prevents saving files ([PR 17854](https://github.com/spyder-ide/spyder/pull/17854) by [@dalthviz](https://github.com/dalthviz)) * [Issue 17814](https://github.com/spyder-ide/spyder/issues/17814) - Release Spyder 5.3.1 ([PR 17972](https://github.com/spyder-ide/spyder/pull/17972) by [@ccordoba12](https://github.com/ccordoba12)) -* [Issue 17803](https://github.com/spyder-ide/spyder/issues/17803) - Editor "New Window" no longer independant of main window ([PR 17826](https://github.com/spyder-ide/spyder/pull/17826) by [@dalthviz](https://github.com/dalthviz)) +* [Issue 17803](https://github.com/spyder-ide/spyder/issues/17803) - Editor "New Window" no longer independent of main window ([PR 17826](https://github.com/spyder-ide/spyder/pull/17826) by [@dalthviz](https://github.com/dalthviz)) * [Issue 17784](https://github.com/spyder-ide/spyder/issues/17784) - Spyder fails to quit if Preferences dialog is open ([PR 17824](https://github.com/spyder-ide/spyder/pull/17824) by [@ccordoba12](https://github.com/ccordoba12)) * [Issue 17778](https://github.com/spyder-ide/spyder/issues/17778) - TypeError when processing completion signatures ([PR 17833](https://github.com/spyder-ide/spyder/pull/17833) by [@ccordoba12](https://github.com/ccordoba12)) * [Issue 17776](https://github.com/spyder-ide/spyder/issues/17776) - jupyter_client 7.3.0 error in `__del__` ([PR 17844](https://github.com/spyder-ide/spyder/pull/17844) by [@ccordoba12](https://github.com/ccordoba12)) diff --git a/spyder/plugins/ipythonconsole/widgets/debugging.py b/spyder/plugins/ipythonconsole/widgets/debugging.py index 82692757a34..695b9ce3287 100644 --- a/spyder/plugins/ipythonconsole/widgets/debugging.py +++ b/spyder/plugins/ipythonconsole/widgets/debugging.py @@ -687,7 +687,7 @@ def _register_is_complete_callback(self, source, callback): """Call the callback with the result of is_complete.""" # Add a continuation prompt if not complete if self.is_waiting_pdb_input(): - # As the work is done on this side, check syncronously. + # As the work is done on this side, check synchronously. complete, indent = self._is_pdb_complete(source) callback(complete, indent) else: From c49763ceda615a7b31e7aead20e917c261ab1471 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Sat, 16 Jul 2022 02:08:10 +0200 Subject: [PATCH 65/83] pep8 --- spyder/plugins/editor/utils/editor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/editor/utils/editor.py b/spyder/plugins/editor/utils/editor.py index b1b83b35b33..afcf3296a74 100644 --- a/spyder/plugins/editor/utils/editor.py +++ b/spyder/plugins/editor/utils/editor.py @@ -61,8 +61,8 @@ def block_safe(block): all the old blocks will continue to report block.isValid() == True but will raise a Segmentation Fault on almost all methods. - One way to check is that the userData is reset to None or - QTextBlockUserData. So if a block is known to have setUserData to + One way to check is that the userData is reset to None or + QTextBlockUserData. So if a block is known to have setUserData to BlockUserData, this fact can be used to check the block. """ return block.isValid() and isinstance(block.userData(), BlockUserData) From 204bc696451f8e0f653714fd0ad750fba1745e5e Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 20 Jul 2022 19:01:00 +0200 Subject: [PATCH 66/83] Apply suggestions from code review Co-authored-by: Carlos Cordoba --- spyder/plugins/editor/utils/editor.py | 11 ++++++----- spyder/plugins/editor/widgets/codeeditor.py | 3 +++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/editor/utils/editor.py b/spyder/plugins/editor/utils/editor.py index afcf3296a74..bddaa85874d 100644 --- a/spyder/plugins/editor/utils/editor.py +++ b/spyder/plugins/editor/utils/editor.py @@ -55,14 +55,15 @@ def block_safe(block): """ Check if the block is safe to work with. - A BlockUserData must have been set on the block while it was known safe. + A BlockUserData must have been set on the block while it was known to be + safe. If an editor is cleared by editor.clear() or editor.set_text() for example, - all the old blocks will continue to report block.isValid() == True - but will raise a Segmentation Fault on almost all methods. + all old blocks will continue to report block.isValid() == True, but will + raise a segmentation fault on almost all methods. - One way to check is that the userData is reset to None or - QTextBlockUserData. So if a block is known to have setUserData to + One way to check if a block is valid is that the userData is reset to None + or QTextBlockUserData. So if a block is known to have setUserData to BlockUserData, this fact can be used to check the block. """ return block.isValid() and isinstance(block.userData(), BlockUserData) diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index 5c959d8e6d0..73c3b23d486 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -2584,6 +2584,7 @@ def __mark_occurrences(self): # Add user data to check block validity block.setUserData(BlockUserData(self)) self.occurrences.append(block) + selection = self.get_selection(cursor) if len(selection.cursor.selectedText()) > 0: extra_selections.append(selection) @@ -2627,11 +2628,13 @@ def highlight_found_results(self, pattern, word=False, regexp=False, selection = TextDecoration(self.textCursor()) selection.format.setBackground(self.found_results_color) selection.cursor.setPosition(pos1) + block = selection.cursor.block() if not block.userData(): # Add user data to check block validity block.setUserData(BlockUserData(self)) self.found_results.append(block) + selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor) extra_selections.append(selection) self.set_extra_selections('find', extra_selections) From 6f0fa053afd43b0731e9918c5db280e3c275a14e Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 20 Jul 2022 19:03:50 +0200 Subject: [PATCH 67/83] rename block_safe --- spyder/plugins/editor/panels/scrollflag.py | 8 ++++---- spyder/plugins/editor/utils/editor.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spyder/plugins/editor/panels/scrollflag.py b/spyder/plugins/editor/panels/scrollflag.py index 2310b576859..1ae496f039d 100644 --- a/spyder/plugins/editor/panels/scrollflag.py +++ b/spyder/plugins/editor/panels/scrollflag.py @@ -20,7 +20,7 @@ # Local imports from spyder.api.panel import Panel from spyder.plugins.completion.api import DiagnosticSeverity -from spyder.plugins.editor.utils.editor import block_safe +from spyder.plugins.editor.utils.editor import is_block_safe REFRESH_RATE = 1000 @@ -222,7 +222,7 @@ def paintEvent(self, event): if editor.verticalScrollBar().maximum() == 0: # No scroll for block in dict_flag_lists[flag_type]: - if not block_safe(block): + if not is_block_safe(block): continue geometry = editor.blockBoundingGeometry(block) rect_y = ceil( @@ -234,7 +234,7 @@ def paintEvent(self, event): elif last_line == 0: # Only one line for block in dict_flag_lists[flag_type]: - if not block_safe(block): + if not is_block_safe(block): continue rect_y = ceil(first_y_pos) painter.drawRect(rect_x, rect_y, rect_w, rect_h) @@ -244,7 +244,7 @@ def paintEvent(self, event): # If the file is too long, do not freeze the editor next_line = 0 for block in dict_flag_lists[flag_type]: - if not block_safe(block): + if not is_block_safe(block): continue block_line = block.firstLineNumber() # block_line = -1 if invalid diff --git a/spyder/plugins/editor/utils/editor.py b/spyder/plugins/editor/utils/editor.py index bddaa85874d..a978490f025 100644 --- a/spyder/plugins/editor/utils/editor.py +++ b/spyder/plugins/editor/utils/editor.py @@ -51,7 +51,7 @@ def drift_color(base_color, factor=110): return base_color.lighter(factor + 10) -def block_safe(block): +def is_block_safe(block): """ Check if the block is safe to work with. From d59aed3a37585a2c471346a26ba0da6b48f2a206 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 20 Jul 2022 19:04:40 +0200 Subject: [PATCH 68/83] finish removing after https://github.com/spyder-ide/spyder/pull/16396 --- spyder/plugins/editor/utils/editor.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/spyder/plugins/editor/utils/editor.py b/spyder/plugins/editor/utils/editor.py index a978490f025..28096710399 100644 --- a/spyder/plugins/editor/utils/editor.py +++ b/spyder/plugins/editor/utils/editor.py @@ -85,15 +85,6 @@ def __init__(self, editor, color=None, selection_start=None, self.selection_start = selection_start self.selection_end = selection_end - # Add a reference to the user data in the editor as the block won't. - # The list should /not/ be used to list BlockUserData as the blocks - # they refer to might not exist anymore. - # This prevents a segmentation fault. - if editor is None: - # Won't be destroyed - self.refloop = self - return - def _selection(self): """ Function to compute the selection. From 628383d49297601e28fcde550af6c7671f71ff1a Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Wed, 20 Jul 2022 22:53:11 -0500 Subject: [PATCH 69/83] Testing: Use run_tests script to run tests on Windows --- .github/workflows/test-win.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-win.yml b/.github/workflows/test-win.yml index 76ec2678075..2df0e5193bf 100644 --- a/.github/workflows/test-win.yml +++ b/.github/workflows/test-win.yml @@ -113,7 +113,16 @@ jobs: - name: Run tests if: env.RUN_BUILD == 'true' shell: bash -l {0} - run: python runtests.py || python runtests.py || python runtests.py || python runtests.py || python runtests.py + run: | + touch log.txt # This is necessary to run the script below + bash -l .github/scripts/run_tests.sh || \ + bash -l .github/scripts/run_tests.sh || \ + bash -l .github/scripts/run_tests.sh || \ + bash -l .github/scripts/run_tests.sh || \ + bash -l .github/scripts/run_tests.sh || \ + bash -l .github/scripts/run_tests.sh || \ + bash -l .github/scripts/run_tests.sh || \ + bash -l .github/scripts/run_tests.sh - name: Coverage if: env.RUN_BUILD == 'true' shell: bash -l {0} From f6026afc55ddef15b73ca5236348be450b160620 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Wed, 20 Jul 2022 23:19:59 -0500 Subject: [PATCH 70/83] Editor: Fix mixed line endings on codeeditor.py --- spyder/plugins/editor/widgets/codeeditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index bf717b79dfe..510cb72e017 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -2036,7 +2036,7 @@ def notify_close(self): # before sending that request here. self._timer_sync_symbols_and_folding.timeout.disconnect( self.sync_symbols_and_folding) - except (TypeError, RuntimeError): + except (TypeError, RuntimeError): pass params = { From aed930196735aa05eafbcb466c9c523f8e24fbda Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Thu, 21 Jul 2022 10:39:19 +0200 Subject: [PATCH 71/83] fix merge --- spyder/plugins/editor/widgets/codeeditor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index 510cb72e017..18e2f6eb0ae 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -1323,7 +1323,6 @@ def underline_errors(self): # after some time. self.clear_extra_selections('code_analysis_underline') self._process_code_analysis(underline=True) - self.update_extra_selections() except RuntimeError: # This is triggered when a codeeditor instance was removed # before the response can be processed. From 4927bf3e2f7f7ad1a8ac3830b713d2d19cd8d49b Mon Sep 17 00:00:00 2001 From: Julian Gilbey Date: Thu, 21 Jul 2022 17:48:04 +0100 Subject: [PATCH 72/83] Catch all passed tests, including parametrized tests with spaces in parameters --- conftest.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/conftest.py b/conftest.py index 909d01dc33a..86bbb05d775 100644 --- a/conftest.py +++ b/conftest.py @@ -13,6 +13,7 @@ import os import os.path as osp +import re # ---- To activate/deactivate certain things for pytest's only # NOTE: Please leave this before any other import here!! @@ -43,13 +44,14 @@ def get_passed_tests(): # All lines that start with 'spyder' are tests. The rest are # informative messages. + test_re = re.compile(r'(spyder.*) (SKIPPED|PASSED|XFAIL) ') tests = [] - for line in logfile: - if line.startswith('spyder'): - tests.append(line.split()[0]) + for line in logfile: + match = test_re.match(line) + if match: + tests.append(match.group(1)) - # Don't include the last test to repeat it again. - return tests[:-1] + return tests else: return [] From 4f2123af9b3075af2894937d10bd564b56233d1e Mon Sep 17 00:00:00 2001 From: Julian Gilbey Date: Thu, 21 Jul 2022 17:54:55 +0100 Subject: [PATCH 73/83] Prefer a run-slow skip reason over a already passed one --- conftest.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/conftest.py b/conftest.py index 909d01dc33a..2e8c7fab3aa 100644 --- a/conftest.py +++ b/conftest.py @@ -66,15 +66,16 @@ def pytest_collection_modifyitems(config, items): skip_passed = pytest.mark.skip(reason="Test passed in previous runs") for item in items: - if item.nodeid in passed_tests: - item.add_marker(skip_passed) - elif slow_option: + if slow_option: if "slow" not in item.keywords: item.add_marker(skip_fast) else: if "slow" in item.keywords: item.add_marker(skip_slow) + if item.nodeid in passed_tests: + item.add_marker(skip_passed) + @pytest.fixture(autouse=True) def reset_conf_before_test(): From ec456f63e32b079ea3aea83afabf3aa586a76d70 Mon Sep 17 00:00:00 2001 From: Julian Gilbey Date: Thu, 21 Jul 2022 21:16:11 +0100 Subject: [PATCH 74/83] Fix PEP 8 issues --- conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 86bbb05d775..919e9191304 100644 --- a/conftest.py +++ b/conftest.py @@ -46,7 +46,7 @@ def get_passed_tests(): # informative messages. test_re = re.compile(r'(spyder.*) (SKIPPED|PASSED|XFAIL) ') tests = [] - for line in logfile: + for line in logfile: match = test_re.match(line) if match: tests.append(match.group(1)) From 32895d75e53737e66c1e2a4d2a477083e10c76dd Mon Sep 17 00:00:00 2001 From: Julian Gilbey Date: Fri, 22 Jul 2022 07:43:16 +0100 Subject: [PATCH 75/83] Modify code comment to better reflect new behaviour Co-authored-by: Carlos Cordoba --- conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/conftest.py b/conftest.py index 919e9191304..0c5197609c9 100644 --- a/conftest.py +++ b/conftest.py @@ -42,8 +42,7 @@ def get_passed_tests(): with open('pytest_log.txt') as f: logfile = f.readlines() - # All lines that start with 'spyder' are tests. The rest are - # informative messages. + # Detect all tests that passed before. test_re = re.compile(r'(spyder.*) (SKIPPED|PASSED|XFAIL) ') tests = [] for line in logfile: From 4c1c3c531cd92f7e8861eb57c54fe5b992a7196f Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Fri, 22 Jul 2022 14:28:22 -0700 Subject: [PATCH 76/83] Add entitlements for QtWebEngineCore.app --- installers/macOS/codesign.sh | 3 ++- installers/macOS/qt_webengine.xml | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 installers/macOS/qt_webengine.xml diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh index 956ecd63f27..ecf79969ccb 100755 --- a/installers/macOS/codesign.sh +++ b/installers/macOS/codesign.sh @@ -31,6 +31,7 @@ log(){ # Resolve full path; works for both .app and .dmg FILE=$(cd $(dirname $1) && pwd -P)/$(basename $1) +qt_ent_file=$(cd $(dirname $BASH_SOURCE) && pwd -P)/qt_webengine.xml # --- Get certificate id CNAME=$(security find-identity -p codesigning -v | pcregrep -o1 "\(([0-9A-Z]+)\)") @@ -70,7 +71,7 @@ if [[ "$FILE" = *".app" ]]; then for fwk in "$pydir"/PyQt5/Qt5/lib/*.framework; do if [[ "$fwk" = *"QtWebEngineCore"* ]]; then subapp="$fwk/Helpers/QtWebEngineProcess.app" - code-sign ${csopts[@]} -o runtime "$subapp" + code-sign ${csopts[@]} -o runtime --entitlements $qt_ent_file "$subapp" fi sign-dir "$fwk" -type f -perm +111 -not -path *QtWebEngineProcess.app* code-sign ${csopts[@]} "$fwk" diff --git a/installers/macOS/qt_webengine.xml b/installers/macOS/qt_webengine.xml new file mode 100644 index 00000000000..680154697c8 --- /dev/null +++ b/installers/macOS/qt_webengine.xml @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.disable-executable-page-protection + + + From 0df033353657bd641c70f8b03fd6a65677703a01 Mon Sep 17 00:00:00 2001 From: Ryan Clary <9618975+mrclary@users.noreply.github.com> Date: Fri, 22 Jul 2022 14:43:30 -0700 Subject: [PATCH 77/83] Add unsign option to codesign.sh --- .github/workflows/installer-macos.yml | 2 +- installers/macOS/codesign.sh | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/installer-macos.yml b/.github/workflows/installer-macos.yml index 2ebebaeaa32..8c47ad24a88 100644 --- a/.github/workflows/installer-macos.yml +++ b/.github/workflows/installer-macos.yml @@ -91,7 +91,7 @@ jobs: pil=$(${pythonLocation} -c "import PIL, os; print(os.path.dirname(PIL.__file__))") rm -v ${DISTDIR}/Spyder.app/Contents/Frameworks/liblzma.5.dylib cp -v ${pil}/.dylibs/liblzma.5.dylib ${DISTDIR}/Spyder.app/Contents/Frameworks/ - ./codesign.sh -a "${DISTDIR}/Spyder.app" + ./codesign.sh "${DISTDIR}/Spyder.app" - name: Test Application Bundle run: ./test_app.sh -t 60 -d 10 ${DISTDIR} - name: Build Disk Image diff --git a/installers/macOS/codesign.sh b/installers/macOS/codesign.sh index ecf79969ccb..e2efd96b3e2 100755 --- a/installers/macOS/codesign.sh +++ b/installers/macOS/codesign.sh @@ -10,13 +10,15 @@ Required: Options: -h Display this help + -u Unsign code EOF } -while getopts ":h" option; do +while getopts ":hu" option; do case $option in (h) help; exit ;; + (u) unsign=0 ;; esac done shift $(($OPTIND - 1)) @@ -37,7 +39,11 @@ qt_ent_file=$(cd $(dirname $BASH_SOURCE) && pwd -P)/qt_webengine.xml CNAME=$(security find-identity -p codesigning -v | pcregrep -o1 "\(([0-9A-Z]+)\)") log "Certificate ID: $CNAME" -csopts=("--force" "--verify" "--verbose" "--timestamp" "--sign" "$CNAME") +if [[ -n "${unsign}" ]]; then + csopts=("--remove-signature") +else + csopts=("--force" "--verify" "--verbose" "--timestamp" "--sign" "$CNAME") +fi # --- Helper functions code-sign(){ From bb9c7d2be83c16557e6339bbb8038c8bd2fd87b7 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sun, 24 Jul 2022 11:24:34 -0500 Subject: [PATCH 78/83] Editor: Use an instance of SimpleCodeEditor to print files This allows us to always use a light syntax highlighting theme when printing. --- spyder/plugins/editor/plugin.py | 70 ++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index d2d5f99eca3..80142e6054a 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -22,7 +22,9 @@ # Third party imports from qtpy.compat import from_qvariant, getopenfilenames, to_qvariant from qtpy.QtCore import QByteArray, Qt, Signal, Slot, QDir -from qtpy.QtPrintSupport import QAbstractPrintDialog, QPrintDialog, QPrinter +from qtpy.QtGui import QTextCursor +from qtpy.QtPrintSupport import (QAbstractPrintDialog, QPrintDialog, QPrinter, + QPrintPreviewDialog) from qtpy.QtWidgets import (QAction, QActionGroup, QApplication, QDialog, QFileDialog, QInputDialog, QMenu, QSplitter, QToolBar, QVBoxLayout, QWidget) @@ -61,6 +63,7 @@ get_run_configuration, RunConfigDialog, RunConfiguration, RunConfigOneDialog) from spyder.plugins.mainmenu.api import ApplicationMenus +from spyder.widgets.simplecodeeditor import SimpleCodeEditor logger = logging.getLogger(__name__) @@ -246,10 +249,15 @@ def __init__(self, parent, ignore_last_opened_files=False): # (needs to be done before EditorSplitter) self.autosave = AutosaveForPlugin(self) self.autosave.try_recover_from_autosave() + # Multiply by 1000 to convert seconds to milliseconds self.autosave.interval = self.get_option('autosave_interval') * 1000 self.autosave.enabled = self.get_option('autosave_enabled') + # SimpleCodeEditor instance used to print file contents + self._print_editor = self._create_print_editor() + self._print_editor.hide() + # Tabbed editor widget + Find/Replace widget editor_widgets = QWidget(self) editor_layout = QVBoxLayout() @@ -259,6 +267,7 @@ def __init__(self, parent, ignore_last_opened_files=False): self.stack_menu_actions, first=True) editor_layout.addWidget(self.editorsplitter) editor_layout.addWidget(self.find_widget) + editor_layout.addWidget(self._print_editor) # Splitter: editor widgets (see above) + outline explorer self.splitter = QSplitter(self) @@ -2295,36 +2304,77 @@ def _convert(fname): self.__ignore_cursor_history = cursor_history_state self.add_cursor_to_history() + def _create_print_editor(self): + """Create a SimpleCodeEditor instance to print file contents.""" + editor = SimpleCodeEditor(self) + editor.setup_editor(color_scheme="idle", highlight_current_line=False) + return editor + @Slot() def print_file(self): - """Print current file""" + """Print current file.""" editor = self.get_current_editor() filename = self.get_current_filename() + + # Set print editor + self._print_editor.set_text(editor.toPlainText()) + self._print_editor.set_language(editor.language) + self._print_editor.set_font(self.get_font()) + + # Create printer printer = Printer(mode=QPrinter.HighResolution, header_font=self.get_font()) - printDialog = QPrintDialog(printer, editor) + print_dialog = QPrintDialog(printer, self._print_editor) + + # Adjust print options when user has selected text if editor.has_selected_text(): - printDialog.setOption(QAbstractPrintDialog.PrintSelection, True) + print_dialog.setOption(QAbstractPrintDialog.PrintSelection, True) + + # Copy selection from current editor to print editor + cursor_1 = editor.textCursor() + start, end = cursor_1.selectionStart(), cursor_1.selectionEnd() + + cursor_2 = self._print_editor.textCursor() + cursor_2.setPosition(start) + cursor_2.setPosition(end, QTextCursor.KeepAnchor) + self._print_editor.setTextCursor(cursor_2) + + # Print self.redirect_stdio.emit(False) - answer = printDialog.exec_() + answer = print_dialog.exec_() self.redirect_stdio.emit(True) + if answer == QDialog.Accepted: self.starting_long_process(_("Printing...")) printer.setDocName(filename) - editor.print_(printer) + self._print_editor.print_(printer) self.ending_long_process() + # Clear selection + self._print_editor.textCursor().removeSelectedText() + @Slot() def print_preview(self): - """Print preview for current file""" - from qtpy.QtPrintSupport import QPrintPreviewDialog - + """Print preview for current file.""" editor = self.get_current_editor() + + # Set print editor + self._print_editor.set_text(editor.toPlainText()) + self._print_editor.set_language(editor.language) + self._print_editor.set_font(self.get_font()) + + # Create printer printer = Printer(mode=QPrinter.HighResolution, header_font=self.get_font()) + + # Create preview preview = QPrintPreviewDialog(printer, self) preview.setWindowFlags(Qt.Window) - preview.paintRequested.connect(lambda printer: editor.print_(printer)) + preview.paintRequested.connect( + lambda printer: self._print_editor.print_(printer) + ) + + # Show preview self.redirect_stdio.emit(False) preview.exec_() self.redirect_stdio.emit(True) From d10e9fe8d449a89d3e979b01735457cfdecdfc9f Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Tue, 26 Jul 2022 11:19:13 -0500 Subject: [PATCH 79/83] Editor: Use Scintilla for printing because it has better constrast --- spyder/plugins/editor/plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index 80142e6054a..ffb8d3cf72f 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -2307,7 +2307,9 @@ def _convert(fname): def _create_print_editor(self): """Create a SimpleCodeEditor instance to print file contents.""" editor = SimpleCodeEditor(self) - editor.setup_editor(color_scheme="idle", highlight_current_line=False) + editor.setup_editor( + color_scheme="scintilla", highlight_current_line=False + ) return editor @Slot() From 03bc8d549f6e7b30516be8678403fc8c7714685d Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 27 Jul 2022 07:11:23 +0200 Subject: [PATCH 80/83] Add .gitattributes --- .gitattributes | 308 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 305 insertions(+), 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index b0cde0b8d34..3307e5ebcfd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,307 @@ +# Standard gitattributes config + +# Set the default behavior, in case people don't have core.autocrlf set. +* text eol=lf + +# Explicitly declare text files you want to always be normalized and converted +# to native line endings on checkout. + +*.txt text + +# Source code +*.bash text eol=lf +*.c text +*.cpp text +*.csh text eol=lf +*.fish text eol=lf +*.inc text +*.ipynb text +*.h text +*.ksh text eol=lf +*.ps1 text +*.pxd text diff=python +*.py text diff=python +*.py3 text diff=python +*.pyi text diff=python +*.pyw text diff=python +*.pyx text diff=python +*.qss text +*.r text +*.R text +*.rb text +*.rmd text +*.Rmd text +*.rnw text +*.Rnw text +*.sh text eol=lf +*.zsh text eol=lf + +# Documentation +*.adoc text +*.latex text +*.LaTeX text +*.markdown text +*.md text +*.po text +*.pot text +*.rd text +*.Rd text +*.rst text +*.tex text +*.TeX text +*.tmpl text +*.tpl text + +# Web +*.atom text +*.css text +*.htm text +*.html text +*.js text +*.jsx text +*.json text +*.php text +*.pl text +*.rss text +*.sass text +*.scss text +*.xht text +*.xhtml text + +# Configuration +*.cfg text +*.cnf text +*.conf text +*.config text +*.desktop text +*.inf text +*.ini text +*.plist text +*.toml text +*.xml text +*.yml text +*.yaml text + +# Plain text data +*.cdl text +*.csv text +*.dif text +*.geojson text +*.gml text +*.kml text +*.sql text +*.tab text +*.tsv text +*.wkt text + +# Other text files +*.diff -text +*.patch -text + +# Special files +.*rc text +.checkignore text +.ciocheck text +.ciocopyright text +.editorconfig text +.gitattributes export-ignore +.gitconfig export-ignore +.gitignore export-ignore +.gitmodules export-ignore +.gitkeep export-ignore +*.lektorproject text +.nojekyll text +.project text + +AUTHORS text +CHANGELOG text +CHANGES text +CONTRIBUTING text +INSTALL text +license text +LICENSE text +NEWS text +NOTICES text +readme text +*README* text +RELEASE text +TODO text + +browserslist text +contents.lr text +makefile text +Makefile text +MANIFEST.in text + + +# Declare files that will always have CRLF line endings on checkout. +*.bat text eol=crlf +*.cmd text eol=crlf +*.vbs text eol=crlf +*.vb text eol=crlf + + +# Denote all files that are truly binary and should not be modified. + +# Executable +*.app binary +*.bin binary +*.deb binary +*.dll binary +*.dylib binary +*.elf binary +*.exe binary +*.ko binary +*.lib binary +*.msi binary +*.o binary +*.obj binary +*.pyc binary +*.pyd binary +*.pyo binary +*.rdb binary +*.Rdb binary +*.rdx binary +*.Rdx binary +*.rpm binary +*.so binary +*.sys binary + +# Data +*.cdf binary +*.db binary +*.dta binary +*.feather binary +*.fit binary +*.fits binary +*.fts binary +*.fods binary +*.geotiff binary +*.gpkg binary +*.h4 binary +*.h5 binary +*.hdf binary +*.hdf4 binary +*.hdf5 binary +*.mat binary +*.nc binary +*.npy binary +*.npz binary +*.odb binary +*.ods binary +*.p binary +*.parquet binary +*.pickle binary +*.pkl binary +*.rdata binary +*.Rdata binary +*.RData binary +*.rda binary +*.Rda binary +*.rds binary +*.Rds binary +*.sav binary +*.sqlite binary +*.wkb binary +*.xls binary +*.XLS binary +*.xlsx binary +*.XLSX binary + +# Documents +*.doc binary +*.DOC binary +*.docx binary +*.DOCX binary +*.epub binary +*.fodp binary +*.fodt binary +*.odp binary +*.odt binary +*.pdf binary +*.PDF binary +*.ppt binary +*.PPT binary +*.pptx binary +*.PPTX binary +*.rtf binary +*.RTF binary + +# Graphics +*.ai binary +*.bmp binary +*.eps binary +*.fodg binary +*.gif binary +*.icns binary +*.ico binary +*.jp2 binary +*.jpeg binary +*.jpg binary +*.mo binary +*.pdn binary +*.png binary +*.PNG binary +*.psd binary +*.odg binary +*.svg binary +*.svgz binary +*.tif binary +*.tiff binary +*.webp binary +*.xcf binary + +# Fonts +*.eot binary +*.otc binary +*.otf binary +*.ttc binary +*.ttf binary +*.woff binary +*.woff2 binary + +# Audio/Video +*.aac binary +*.flac binary +*.mka binary +*.mkv binary +*.mp3 binary +*.mp4 binary +*.oga binary +*.ogg binary +*.ogv binary +*.opus binary +*.wav binary +*.webm binary + + +# Archives +*.7z binary +*.bz2 binary +*.dmg binary +*.gz binary +*.lz binary +*.lzma binary +*.pyz binary +*.rar binary +*.sz binary +*.tar binary +*.tbz2 binary +*.tgz binary +*.tlz binary +*.txz binary +*.xz binary +*.zip binary + +# Spyder-related +*.results binary +*.spydata binary + +# Other +*.bak binary +*.lnk binary +*.temp binary +*.tmp binary + # Github helper pieces to make some files not show up in diffs automatically external-deps/**/* linguist-generated=true - -# Get better diffs for Python files -*.py diff=python From bdfe0b59821951584de3f72768693a151ca8350a Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 27 Jul 2022 11:55:28 +0200 Subject: [PATCH 81/83] remove CR from .py files --- spyder/app/mainwindow.py | 4098 +++--- spyder/app/restart.py | 602 +- spyder/app/start.py | 540 +- spyder/config/base.py | 1260 +- spyder/config/main.py | 1286 +- spyder/config/manager.py | 1338 +- spyder/config/user.py | 2042 +-- spyder/dependencies.py | 896 +- spyder/otherplugins.py | 256 +- spyder/pil_patch.py | 122 +- spyder/plugins/breakpoints/api.py | 28 +- spyder/plugins/breakpoints/plugin.py | 418 +- .../breakpoints/widgets/main_widget.py | 860 +- spyder/plugins/console/api.py | 36 +- spyder/plugins/console/plugin.py | 538 +- spyder/plugins/console/utils/ansihandler.py | 230 +- spyder/plugins/console/utils/interpreter.py | 670 +- spyder/plugins/console/widgets/__init__.py | 20 +- .../plugins/console/widgets/internalshell.py | 988 +- spyder/plugins/console/widgets/shell.py | 2128 +-- spyder/plugins/editor/extensions/docstring.py | 2068 +-- spyder/plugins/editor/plugin.py | 7168 +++++----- spyder/plugins/editor/utils/findtasks.py | 66 +- spyder/plugins/editor/widgets/base.py | 2314 ++-- spyder/plugins/editor/widgets/codeeditor.py | 11190 ++++++++-------- spyder/plugins/editor/widgets/editor.py | 7222 +++++----- spyder/plugins/explorer/plugin.py | 542 +- spyder/plugins/explorer/widgets/explorer.py | 3876 +++--- spyder/plugins/findinfiles/api.py | 28 +- spyder/plugins/findinfiles/plugin.py | 426 +- .../findinfiles/widgets/results_browser.py | 674 +- spyder/plugins/help/api.py | 30 +- spyder/plugins/help/plugin.py | 750 +- spyder/plugins/help/utils/__init__.py | 40 +- spyder/plugins/history/plugin.py | 274 +- spyder/plugins/io_dcm/plugin.py | 72 +- spyder/plugins/io_hdf5/plugin.py | 164 +- spyder/plugins/ipythonconsole/plugin.py | 1774 +-- .../plugins/ipythonconsole/utils/manager.py | 240 +- .../plugins/ipythonconsole/widgets/client.py | 1872 +-- spyder/plugins/layout/container.py | 880 +- spyder/plugins/layout/layouts.py | 524 +- spyder/plugins/layout/plugin.py | 1658 +-- spyder/plugins/layout/widgets/dialog.py | 790 +- spyder/plugins/maininterpreter/confpage.py | 552 +- spyder/plugins/maininterpreter/plugin.py | 244 +- spyder/plugins/onlinehelp/api.py | 18 +- spyder/plugins/onlinehelp/plugin.py | 190 +- spyder/plugins/onlinehelp/widgets.py | 1026 +- spyder/plugins/outlineexplorer/plugin.py | 216 +- spyder/plugins/outlineexplorer/widgets.py | 1774 +-- spyder/plugins/preferences/api.py | 1784 +-- .../plugins/profiler/widgets/main_widget.py | 2120 +-- spyder/plugins/projects/api.py | 360 +- spyder/plugins/projects/plugin.py | 2044 +-- spyder/plugins/projects/utils/config.py | 222 +- spyder/plugins/projects/widgets/__init__.py | 14 +- .../plugins/projects/widgets/projectdialog.py | 538 +- .../projects/widgets/projectexplorer.py | 698 +- spyder/plugins/pylint/main_widget.py | 1970 +-- spyder/plugins/pylint/plugin.py | 472 +- spyder/plugins/run/confpage.py | 284 +- spyder/plugins/run/plugin.py | 130 +- spyder/plugins/run/widgets.py | 1044 +- spyder/plugins/shortcuts/__init__.py | 24 +- spyder/plugins/shortcuts/api.py | 14 +- spyder/plugins/shortcuts/confpage.py | 190 +- spyder/plugins/shortcuts/plugin.py | 496 +- spyder/plugins/shortcuts/widgets/table.py | 1880 +-- spyder/plugins/statusbar/container.py | 130 +- spyder/plugins/statusbar/plugin.py | 494 +- spyder/plugins/toolbar/container.py | 792 +- spyder/plugins/toolbar/plugin.py | 532 +- spyder/plugins/tours/container.py | 228 +- spyder/plugins/tours/plugin.py | 242 +- spyder/plugins/tours/tours.py | 468 +- spyder/plugins/tours/widgets.py | 2572 ++-- spyder/plugins/variableexplorer/api.py | 28 +- spyder/plugins/variableexplorer/plugin.py | 164 +- .../variableexplorer/widgets/arrayeditor.py | 1878 +-- .../widgets/dataframeeditor.py | 2860 ++-- .../variableexplorer/widgets/importwizard.py | 1284 +- .../variableexplorer/widgets/main_widget.py | 1284 +- .../widgets/namespacebrowser.py | 642 +- .../variableexplorer/widgets/objecteditor.py | 350 +- .../variableexplorer/widgets/texteditor.py | 294 +- spyder/plugins/workingdirectory/confpage.py | 246 +- spyder/plugins/workingdirectory/container.py | 664 +- spyder/plugins/workingdirectory/plugin.py | 524 +- spyder/py3compat.py | 638 +- spyder/requirements.py | 126 +- spyder/utils/bsdsocket.py | 366 +- spyder/utils/conda.py | 328 +- spyder/utils/debug.py | 292 +- spyder/utils/encoding.py | 654 +- spyder/utils/environ.py | 372 +- spyder/utils/external/lockfile.py | 502 +- .../utils/introspection/module_completion.py | 150 +- spyder/utils/introspection/rope_patch.py | 422 +- spyder/utils/misc.py | 584 +- spyder/utils/programs.py | 2138 +-- spyder/utils/qthelpers.py | 1612 +-- spyder/utils/sourcecode.py | 480 +- spyder/utils/syntaxhighlighters.py | 2876 ++-- spyder/utils/system.py | 130 +- spyder/utils/vcs.py | 488 +- spyder/utils/windows.py | 96 +- spyder/widgets/arraybuilder.py | 846 +- spyder/widgets/browser.py | 1232 +- spyder/widgets/collectionseditor.py | 3824 +++--- spyder/widgets/colors.py | 190 +- spyder/widgets/comboboxes.py | 836 +- spyder/widgets/dependencies.py | 312 +- spyder/widgets/findreplace.py | 1320 +- spyder/widgets/mixins.py | 3292 ++--- spyder/widgets/onecolumntree.py | 614 +- spyder/widgets/pathmanager.py | 1012 +- spyder/widgets/simplecodeeditor.py | 1156 +- spyder/widgets/tabs.py | 1012 +- 119 files changed, 62439 insertions(+), 62439 deletions(-) diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index 890237af110..bda93a8c2ed 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -1,2049 +1,2049 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder, the Scientific Python Development Environment -===================================================== - -Developed and maintained by the Spyder Project -Contributors - -Copyright © Spyder Project Contributors -Licensed under the terms of the MIT License -(see spyder/__init__.py for details) -""" - -# ============================================================================= -# Stdlib imports -# ============================================================================= -from collections import OrderedDict -from enum import Enum -import errno -import gc -import logging -import os -import os.path as osp -import shutil -import signal -import socket -import sys -import threading -import traceback - -#============================================================================== -# Check requirements before proceeding -#============================================================================== -from spyder import requirements -requirements.check_path() -requirements.check_qt() - -#============================================================================== -# Third-party imports -#============================================================================== -from qtpy.compat import from_qvariant -from qtpy.QtCore import (QCoreApplication, Qt, QTimer, Signal, Slot, - qInstallMessageHandler) -from qtpy.QtGui import QColor, QKeySequence -from qtpy.QtWidgets import (QApplication, QMainWindow, QMenu, QMessageBox, - QShortcut, QStyleFactory) - -# Avoid a "Cannot mix incompatible Qt library" error on Windows platforms -from qtpy import QtSvg # analysis:ignore - -# Avoid a bug in Qt: https://bugreports.qt.io/browse/QTBUG-46720 -from qtpy import QtWebEngineWidgets # analysis:ignore - -from qtawesome.iconic_font import FontError - -#============================================================================== -# Local imports -# NOTE: Move (if possible) import's of widgets and plugins exactly where they -# are needed in MainWindow to speed up perceived startup time (i.e. the time -# from clicking the Spyder icon to showing the splash screen). -#============================================================================== -from spyder import __version__ -from spyder import dependencies -from spyder.app.find_plugins import ( - find_external_plugins, find_internal_plugins) -from spyder.app.utils import ( - create_application, create_splash_screen, create_window, ORIGINAL_SYS_EXIT, - delete_debug_log_files, qt_message_handler, set_links_color, setup_logging, - set_opengl_implementation) -from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY -from spyder.config.base import (_, DEV, get_conf_path, get_debug_level, - get_home_dir, get_module_source_path, - is_pynsist, running_in_mac_app, - running_under_pytest, STDERR) -from spyder.config.gui import is_dark_font_color -from spyder.config.main import OPEN_FILES_PORT -from spyder.config.manager import CONF -from spyder.config.utils import IMPORT_EXT, is_gtk_desktop -from spyder.otherplugins import get_spyderplugins_mods -from spyder.py3compat import configparser as cp, PY3, to_text_string -from spyder.utils import encoding, programs -from spyder.utils.icon_manager import ima -from spyder.utils.misc import (select_port, getcwd_or_home, - get_python_executable) -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import (create_action, add_actions, file_uri, - qapplication, start_file) -from spyder.utils.stylesheet import APP_STYLESHEET - -# Spyder API Imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import ( - Plugins, SpyderPlugin, SpyderPluginV2, SpyderDockablePlugin, - SpyderPluginWidget) - -#============================================================================== -# Windows only local imports -#============================================================================== -set_attached_console_visible = None -is_attached_console_visible = None -set_windows_appusermodelid = None -if os.name == 'nt': - from spyder.utils.windows import (set_attached_console_visible, - set_windows_appusermodelid) - -#============================================================================== -# Constants -#============================================================================== -# Module logger -logger = logging.getLogger(__name__) - -#============================================================================== -# Install Qt messaage handler -#============================================================================== -qInstallMessageHandler(qt_message_handler) - -#============================================================================== -# Main Window -#============================================================================== -class MainWindow(QMainWindow): - """Spyder main window""" - DOCKOPTIONS = ( - QMainWindow.AllowTabbedDocks | QMainWindow.AllowNestedDocks | - QMainWindow.AnimatedDocks - ) - SPYDER_PATH = get_conf_path('path') - SPYDER_NOT_ACTIVE_PATH = get_conf_path('not_active_path') - DEFAULT_LAYOUTS = 4 - INITIAL_CWD = getcwd_or_home() - - # Signals - restore_scrollbar_position = Signal() - sig_setup_finished = Signal() - all_actions_defined = Signal() - # type: (OrderedDict, OrderedDict) - sig_pythonpath_changed = Signal(object, object) - sig_open_external_file = Signal(str) - sig_resized = Signal("QResizeEvent") - sig_moved = Signal("QMoveEvent") - sig_layout_setup_ready = Signal(object) # Related to default layouts - - # ---- Plugin handling methods - # ------------------------------------------------------------------------ - def get_plugin(self, plugin_name, error=True): - """ - Return a plugin instance by providing the plugin class. - """ - if plugin_name in PLUGIN_REGISTRY: - return PLUGIN_REGISTRY.get_plugin(plugin_name) - - if error: - raise SpyderAPIError(f'Plugin "{plugin_name}" not found!') - - return None - - def get_dockable_plugins(self): - """Get a list of all dockable plugins.""" - dockable_plugins = [] - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - if isinstance(plugin, (SpyderDockablePlugin, SpyderPluginWidget)): - dockable_plugins.append((plugin_name, plugin)) - return dockable_plugins - - def is_plugin_enabled(self, plugin_name): - """Determine if a given plugin is going to be loaded.""" - return PLUGIN_REGISTRY.is_plugin_enabled(plugin_name) - - def is_plugin_available(self, plugin_name): - """Determine if a given plugin is available.""" - return PLUGIN_REGISTRY.is_plugin_available(plugin_name) - - def show_status_message(self, message, timeout): - """ - Show a status message in Spyder Main Window. - """ - status_bar = self.statusBar() - if status_bar.isVisible(): - status_bar.showMessage(message, timeout) - - def show_plugin_compatibility_message(self, message): - """ - Show a compatibility message. - """ - messageBox = QMessageBox(self) - messageBox.setWindowModality(Qt.NonModal) - messageBox.setAttribute(Qt.WA_DeleteOnClose) - messageBox.setWindowTitle(_('Compatibility Check')) - messageBox.setText(message) - messageBox.setStandardButtons(QMessageBox.Ok) - messageBox.show() - - def register_plugin(self, plugin_name, external=False, omit_conf=False): - """ - Register a plugin in Spyder Main Window. - """ - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - - self.set_splash(_("Loading {}...").format(plugin.get_name())) - logger.info("Loading {}...".format(plugin.NAME)) - - # Check plugin compatibility - is_compatible, message = plugin.check_compatibility() - plugin.is_compatible = is_compatible - plugin.get_description() - - if not is_compatible: - self.show_compatibility_message(message) - return - - # Connect Plugin Signals to main window methods - plugin.sig_exception_occurred.connect(self.handle_exception) - plugin.sig_free_memory_requested.connect(self.free_memory) - plugin.sig_quit_requested.connect(self.close) - plugin.sig_redirect_stdio_requested.connect( - self.redirect_internalshell_stdio) - plugin.sig_status_message_requested.connect(self.show_status_message) - - if isinstance(plugin, SpyderDockablePlugin): - plugin.sig_focus_changed.connect(self.plugin_focus_changed) - plugin.sig_switch_to_plugin_requested.connect( - self.switch_to_plugin) - plugin.sig_update_ancestor_requested.connect( - lambda: plugin.set_ancestor(self)) - - # Connect Main window Signals to plugin signals - self.sig_moved.connect(plugin.sig_mainwindow_moved) - self.sig_resized.connect(plugin.sig_mainwindow_resized) - - # Register plugin - plugin._register(omit_conf=omit_conf) - - if isinstance(plugin, SpyderDockablePlugin): - # Add dockwidget - self.add_dockwidget(plugin) - - # Update margins - margin = 0 - if CONF.get('main', 'use_custom_margin'): - margin = CONF.get('main', 'custom_margin') - plugin.update_margins(margin) - - if plugin_name == Plugins.Shortcuts: - for action, context, action_name in self.shortcut_queue: - self.register_shortcut(action, context, action_name) - self.shortcut_queue = [] - - logger.info("Registering shortcuts for {}...".format(plugin.NAME)) - for action_name, action in plugin.get_actions().items(): - context = (getattr(action, 'shortcut_context', plugin.NAME) - or plugin.NAME) - - if getattr(action, 'register_shortcut', True): - if isinstance(action_name, Enum): - action_name = action_name.value - if Plugins.Shortcuts in PLUGIN_REGISTRY: - self.register_shortcut(action, context, action_name) - else: - self.shortcut_queue.append((action, context, action_name)) - - if isinstance(plugin, SpyderDockablePlugin): - try: - context = '_' - name = 'switch to {}'.format(plugin.CONF_SECTION) - shortcut = CONF.get_shortcut(context, name, - plugin_name=plugin.CONF_SECTION) - except (cp.NoSectionError, cp.NoOptionError): - shortcut = None - - sc = QShortcut(QKeySequence(), self, - lambda: self.switch_to_plugin(plugin)) - sc.setContext(Qt.ApplicationShortcut) - plugin._shortcut = sc - - if Plugins.Shortcuts in PLUGIN_REGISTRY: - self.register_shortcut(sc, context, name) - self.register_shortcut( - plugin.toggle_view_action, context, name) - else: - self.shortcut_queue.append((sc, context, name)) - self.shortcut_queue.append( - (plugin.toggle_view_action, context, name)) - - def unregister_plugin(self, plugin): - """ - Unregister a plugin from the Spyder Main Window. - """ - logger.info("Unloading {}...".format(plugin.NAME)) - - # Disconnect all slots - signals = [ - plugin.sig_quit_requested, - plugin.sig_redirect_stdio_requested, - plugin.sig_status_message_requested, - ] - - for sig in signals: - try: - sig.disconnect() - except TypeError: - pass - - # Unregister shortcuts for actions - logger.info("Unregistering shortcuts for {}...".format(plugin.NAME)) - for action_name, action in plugin.get_actions().items(): - context = (getattr(action, 'shortcut_context', plugin.NAME) - or plugin.NAME) - self.shortcuts.unregister_shortcut(action, context, action_name) - - # Unregister switch to shortcut - shortcut = None - try: - context = '_' - name = 'switch to {}'.format(plugin.CONF_SECTION) - shortcut = CONF.get_shortcut(context, name, - plugin_name=plugin.CONF_SECTION) - except Exception: - pass - - if shortcut is not None: - self.shortcuts.unregister_shortcut( - plugin._shortcut, - context, - "Switch to {}".format(plugin.CONF_SECTION), - ) - - # Remove dockwidget - logger.info("Removing {} dockwidget...".format(plugin.NAME)) - self.remove_dockwidget(plugin) - - plugin._unregister() - - def create_plugin_conf_widget(self, plugin): - """ - Create configuration dialog box page widget. - """ - config_dialog = self.prefs_dialog_instance - if plugin.CONF_WIDGET_CLASS is not None and config_dialog is not None: - conf_widget = plugin.CONF_WIDGET_CLASS(plugin, config_dialog) - conf_widget.initialize() - return conf_widget - - @property - def last_plugin(self): - """ - Get last plugin with focus if it is a dockable widget. - - If a non-dockable plugin has the focus this will return by default - the Editor plugin. - """ - # Needed to prevent errors with the old API at - # spyder/plugins/base::_switch_to_plugin - return self.layouts.get_last_plugin() - - def maximize_dockwidget(self, restore=False): - """ - This is needed to prevent errors with the old API at - spyder/plugins/base::_switch_to_plugin. - - See spyder-ide/spyder#15164 - - Parameters - ---------- - restore : bool, optional - If the current dockwidget needs to be restored to its unmaximized - state. The default is False. - """ - self.layouts.maximize_dockwidget(restore=restore) - - def switch_to_plugin(self, plugin, force_focus=None): - """ - Switch to this plugin. - - Notes - ----- - This operation unmaximizes the current plugin (if any), raises - this plugin to view (if it's hidden) and gives it focus (if - possible). - """ - last_plugin = self.last_plugin - try: - # New API - if (last_plugin is not None - and last_plugin.get_widget().is_maximized - and last_plugin is not plugin): - self.layouts.maximize_dockwidget() - except AttributeError: - # Old API - if (last_plugin is not None and self.last_plugin._ismaximized - and last_plugin is not plugin): - self.layouts.maximize_dockwidget() - - try: - # New API - if not plugin.toggle_view_action.isChecked(): - plugin.toggle_view_action.setChecked(True) - plugin.get_widget().is_visible = False - except AttributeError: - # Old API - if not plugin._toggle_view_action.isChecked(): - plugin._toggle_view_action.setChecked(True) - plugin._widget._is_visible = False - - plugin.change_visibility(True, force_focus=force_focus) - - def remove_dockwidget(self, plugin): - """ - Remove a plugin QDockWidget from the main window. - """ - self.removeDockWidget(plugin.dockwidget) - try: - self.widgetlist.remove(plugin) - except ValueError: - pass - - def tabify_plugins(self, first, second): - """Tabify plugin dockwigdets.""" - self.tabifyDockWidget(first.dockwidget, second.dockwidget) - - def tabify_plugin(self, plugin, default=None): - """ - Tabify the plugin using the list of possible TABIFY options. - - Only do this if the dockwidget does not have more dockwidgets - in the same position and if the plugin is using the New API. - """ - def tabify_helper(plugin, next_to_plugins): - for next_to_plugin in next_to_plugins: - try: - self.tabify_plugins(next_to_plugin, plugin) - break - except SpyderAPIError as err: - logger.error(err) - - # If TABIFY not defined use the [default] - tabify = getattr(plugin, 'TABIFY', [default]) - if not isinstance(tabify, list): - next_to_plugins = [tabify] - else: - next_to_plugins = tabify - - # Check if TABIFY is not a list with None as unique value or a default - # list - if tabify in [[None], []]: - return False - - # Get the actual plugins from the names - next_to_plugins = [self.get_plugin(p) for p in next_to_plugins] - - # First time plugin starts - if plugin.get_conf('first_time', True): - if (isinstance(plugin, SpyderDockablePlugin) - and plugin.NAME != Plugins.Console): - logger.info( - "Tabify {} dockwidget for the first time...".format( - plugin.NAME)) - tabify_helper(plugin, next_to_plugins) - - # Show external plugins - if plugin.NAME in PLUGIN_REGISTRY.external_plugins: - plugin.get_widget().toggle_view(True) - - plugin.set_conf('enable', True) - plugin.set_conf('first_time', False) - else: - # This is needed to ensure plugins are placed correctly when - # switching layouts. - logger.info("Tabify {} dockwidget...".format(plugin.NAME)) - # Check if plugin has no other dockwidgets in the same position - if not bool(self.tabifiedDockWidgets(plugin.dockwidget)): - tabify_helper(plugin, next_to_plugins) - - return True - - def handle_exception(self, error_data): - """ - This method will call the handle exception method of the Console - plugin. It is provided as a signal on the Plugin API for convenience, - so that plugin do not need to explicitly call the Console plugin. - - Parameters - ---------- - error_data: dict - The dictionary containing error data. The expected keys are: - >>> error_data= { - "text": str, - "is_traceback": bool, - "repo": str, - "title": str, - "label": str, - "steps": str, - } - - Notes - ----- - The `is_traceback` key indicates if `text` contains plain text or a - Python error traceback. - - The `title` and `repo` keys indicate how the error data should - customize the report dialog and Github error submission. - - The `label` and `steps` keys allow customizing the content of the - error dialog. - """ - console = self.get_plugin(Plugins.Console, error=False) - if console: - console.handle_exception(error_data) - - def __init__(self, splash=None, options=None): - QMainWindow.__init__(self) - qapp = QApplication.instance() - - if running_under_pytest(): - self._proxy_style = None - else: - from spyder.utils.qthelpers import SpyderProxyStyle - # None is needed, see: https://bugreports.qt.io/browse/PYSIDE-922 - self._proxy_style = SpyderProxyStyle(None) - - # Enabling scaling for high dpi - qapp.setAttribute(Qt.AA_UseHighDpiPixmaps) - - # Set Windows app icon to use .ico file - if os.name == "nt": - qapp.setWindowIcon(ima.get_icon("windows_app_icon")) - - # Set default style - self.default_style = str(qapp.style().objectName()) - - # Save command line options for plugins to access them - self._cli_options = options - - logger.info("Start of MainWindow constructor") - - def signal_handler(signum, frame=None): - """Handler for signals.""" - sys.stdout.write('Handling signal: %s\n' % signum) - sys.stdout.flush() - QApplication.quit() - - if os.name == "nt": - try: - import win32api - win32api.SetConsoleCtrlHandler(signal_handler, True) - except ImportError: - pass - else: - signal.signal(signal.SIGTERM, signal_handler) - if not DEV: - # Make spyder quit when presing ctrl+C in the console - # In DEV Ctrl+C doesn't quit, because it helps to - # capture the traceback when spyder freezes - signal.signal(signal.SIGINT, signal_handler) - - # Use a custom Qt stylesheet - if sys.platform == 'darwin': - spy_path = get_module_source_path('spyder') - img_path = osp.join(spy_path, 'images') - mac_style = open(osp.join(spy_path, 'app', 'mac_stylesheet.qss')).read() - mac_style = mac_style.replace('$IMAGE_PATH', img_path) - self.setStyleSheet(mac_style) - - # Shortcut management data - self.shortcut_data = [] - self.shortcut_queue = [] - - # Handle Spyder path - self.path = () - self.not_active_path = () - self.project_path = () - self._path_manager = None - - # New API - self._APPLICATION_TOOLBARS = OrderedDict() - self._STATUS_WIDGETS = OrderedDict() - # Mapping of new plugin identifiers vs old attributtes - # names given for plugins or to prevent collisions with other - # attributes, i.e layout (Qt) vs layout (SpyderPluginV2) - self._INTERNAL_PLUGINS_MAPPING = { - 'console': Plugins.Console, - 'maininterpreter': Plugins.MainInterpreter, - 'outlineexplorer': Plugins.OutlineExplorer, - 'variableexplorer': Plugins.VariableExplorer, - 'ipyconsole': Plugins.IPythonConsole, - 'workingdirectory': Plugins.WorkingDirectory, - 'projects': Plugins.Projects, - 'findinfiles': Plugins.Find, - 'layouts': Plugins.Layout, - } - - self.thirdparty_plugins = [] - - # File switcher - self.switcher = None - - # Preferences - self.prefs_dialog_size = None - self.prefs_dialog_instance = None - - # Actions - self.undo_action = None - self.redo_action = None - self.copy_action = None - self.cut_action = None - self.paste_action = None - self.selectall_action = None - - # Menu bars - self.edit_menu = None - self.edit_menu_actions = [] - self.search_menu = None - self.search_menu_actions = [] - self.source_menu = None - self.source_menu_actions = [] - self.run_menu = None - self.run_menu_actions = [] - self.debug_menu = None - self.debug_menu_actions = [] - - # TODO: Move to corresponding Plugins - self.main_toolbar = None - self.main_toolbar_actions = [] - self.file_toolbar = None - self.file_toolbar_actions = [] - self.run_toolbar = None - self.run_toolbar_actions = [] - self.debug_toolbar = None - self.debug_toolbar_actions = [] - - self.menus = [] - - if running_under_pytest(): - # Show errors in internal console when testing. - CONF.set('main', 'show_internal_errors', False) - - self.CURSORBLINK_OSDEFAULT = QApplication.cursorFlashTime() - - if set_windows_appusermodelid != None: - res = set_windows_appusermodelid() - logger.info("appusermodelid: %s", res) - - # Setting QTimer if running in travis - test_app = os.environ.get('TEST_CI_APP') - if test_app is not None: - app = qapplication() - timer_shutdown_time = 30000 - self.timer_shutdown = QTimer(self) - self.timer_shutdown.timeout.connect(app.quit) - self.timer_shutdown.start(timer_shutdown_time) - - # Showing splash screen - self.splash = splash - if CONF.get('main', 'current_version', '') != __version__: - CONF.set('main', 'current_version', __version__) - # Execute here the actions to be performed only once after - # each update (there is nothing there for now, but it could - # be useful some day...) - - # List of satellite widgets (registered in add_dockwidget): - self.widgetlist = [] - - # Flags used if closing() is called by the exit() shell command - self.already_closed = False - self.is_starting_up = True - self.is_setting_up = True - - self.window_size = None - self.window_position = None - - # To keep track of the last focused widget - self.last_focused_widget = None - self.previous_focused_widget = None - - # Server to open external files on a single instance - # This is needed in order to handle socket creation problems. - # See spyder-ide/spyder#4132. - if os.name == 'nt': - try: - self.open_files_server = socket.socket(socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP) - except OSError: - self.open_files_server = None - QMessageBox.warning(None, "Spyder", - _("An error occurred while creating a socket needed " - "by Spyder. Please, try to run as an Administrator " - "from cmd.exe the following command and then " - "restart your computer:

netsh winsock reset " - "
").format( - color=QStylePalette.COLOR_BACKGROUND_4)) - else: - self.open_files_server = socket.socket(socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP) - - # Apply main window settings - self.apply_settings() - - # To set all dockwidgets tabs to be on top (in case we want to do it - # in the future) - # self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) - - logger.info("End of MainWindow constructor") - - # ---- Window setup - def _update_shortcuts_in_panes_menu(self, show=True): - """ - Display the shortcut for the "Switch to plugin..." on the toggle view - action of the plugins displayed in the Help/Panes menu. - - Notes - ----- - SpyderDockablePlugins provide two actions that function as a single - action. The `Switch to Plugin...` action has an assignable shortcut - via the shortcut preferences. The `Plugin toggle View` in the `View` - application menu, uses a custom `Toggle view action` that displays the - shortcut assigned to the `Switch to Plugin...` action, but is not - triggered by that shortcut. - """ - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - if isinstance(plugin, SpyderDockablePlugin): - try: - # New API - action = plugin.toggle_view_action - except AttributeError: - # Old API - action = plugin._toggle_view_action - - if show: - section = plugin.CONF_SECTION - try: - context = '_' - name = 'switch to {}'.format(section) - shortcut = CONF.get_shortcut( - context, name, plugin_name=section) - except (cp.NoSectionError, cp.NoOptionError): - shortcut = QKeySequence() - else: - shortcut = QKeySequence() - - action.setShortcut(shortcut) - - def setup(self): - """Setup main window.""" - PLUGIN_REGISTRY.sig_plugin_ready.connect( - lambda plugin_name, omit_conf: self.register_plugin( - plugin_name, omit_conf=omit_conf)) - - PLUGIN_REGISTRY.set_main(self) - - # TODO: Remove circular dependency between help and ipython console - # and remove this import. Help plugin should take care of it - from spyder.plugins.help.utils.sphinxify import CSS_PATH, DARK_CSS_PATH - logger.info("*** Start of MainWindow setup ***") - logger.info("Updating PYTHONPATH") - path_dict = self.get_spyder_pythonpath_dict() - self.update_python_path(path_dict) - - logger.info("Applying theme configuration...") - ui_theme = CONF.get('appearance', 'ui_theme') - color_scheme = CONF.get('appearance', 'selected') - - if ui_theme == 'dark': - if not running_under_pytest(): - # Set style proxy to fix combobox popup on mac and qdark - qapp = QApplication.instance() - qapp.setStyle(self._proxy_style) - dark_qss = str(APP_STYLESHEET) - self.setStyleSheet(dark_qss) - self.statusBar().setStyleSheet(dark_qss) - css_path = DARK_CSS_PATH - - elif ui_theme == 'light': - if not running_under_pytest(): - # Set style proxy to fix combobox popup on mac and qdark - qapp = QApplication.instance() - qapp.setStyle(self._proxy_style) - light_qss = str(APP_STYLESHEET) - self.setStyleSheet(light_qss) - self.statusBar().setStyleSheet(light_qss) - css_path = CSS_PATH - - elif ui_theme == 'automatic': - if not is_dark_font_color(color_scheme): - if not running_under_pytest(): - # Set style proxy to fix combobox popup on mac and qdark - qapp = QApplication.instance() - qapp.setStyle(self._proxy_style) - dark_qss = str(APP_STYLESHEET) - self.setStyleSheet(dark_qss) - self.statusBar().setStyleSheet(dark_qss) - css_path = DARK_CSS_PATH - else: - light_qss = str(APP_STYLESHEET) - self.setStyleSheet(light_qss) - self.statusBar().setStyleSheet(light_qss) - css_path = CSS_PATH - - # Set css_path as a configuration to be used by the plugins - CONF.set('appearance', 'css_path', css_path) - - # Status bar - status = self.statusBar() - status.setObjectName("StatusBar") - status.showMessage(_("Welcome to Spyder!"), 5000) - - # Switcher instance - logger.info("Loading switcher...") - self.create_switcher() - - # Load and register internal and external plugins - external_plugins = find_external_plugins() - internal_plugins = find_internal_plugins() - all_plugins = external_plugins.copy() - all_plugins.update(internal_plugins.copy()) - - # Determine 'enable' config for the plugins that have it - enabled_plugins = {} - registry_internal_plugins = {} - registry_external_plugins = {} - for plugin in all_plugins.values(): - plugin_name = plugin.NAME - # Disable panes that use web widgets (currently Help and Online - # Help) if the user asks for it. - # See spyder-ide/spyder#16518 - if self._cli_options.no_web_widgets: - if "help" in plugin_name: - continue - plugin_main_attribute_name = ( - self._INTERNAL_PLUGINS_MAPPING[plugin_name] - if plugin_name in self._INTERNAL_PLUGINS_MAPPING - else plugin_name) - if plugin_name in internal_plugins: - registry_internal_plugins[plugin_name] = ( - plugin_main_attribute_name, plugin) - else: - registry_external_plugins[plugin_name] = ( - plugin_main_attribute_name, plugin) - try: - if CONF.get(plugin_main_attribute_name, "enable"): - enabled_plugins[plugin_name] = plugin - PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) - except (cp.NoOptionError, cp.NoSectionError): - enabled_plugins[plugin_name] = plugin - PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) - - PLUGIN_REGISTRY.set_all_internal_plugins(registry_internal_plugins) - PLUGIN_REGISTRY.set_all_external_plugins(registry_external_plugins) - - # Instantiate internal Spyder 5 plugins - for plugin_name in internal_plugins: - if plugin_name in enabled_plugins: - PluginClass = internal_plugins[plugin_name] - if issubclass(PluginClass, SpyderPluginV2): - PLUGIN_REGISTRY.register_plugin(self, PluginClass, - external=False) - - # Instantiate internal Spyder 4 plugins - for plugin_name in internal_plugins: - if plugin_name in enabled_plugins: - PluginClass = internal_plugins[plugin_name] - if issubclass(PluginClass, SpyderPlugin): - plugin_instance = PLUGIN_REGISTRY.register_plugin( - self, PluginClass, external=False) - self.preferences.register_plugin_preferences( - plugin_instance) - - # Instantiate external Spyder 5 plugins - for plugin_name in external_plugins: - if plugin_name in enabled_plugins: - PluginClass = external_plugins[plugin_name] - try: - plugin_instance = PLUGIN_REGISTRY.register_plugin( - self, PluginClass, external=True) - except Exception as error: - print("%s: %s" % (PluginClass, str(error)), file=STDERR) - traceback.print_exc(file=STDERR) - - self.set_splash(_("Loading old third-party plugins...")) - for mod in get_spyderplugins_mods(): - try: - plugin = PLUGIN_REGISTRY.register_plugin(self, mod, - external=True) - if plugin.check_compatibility()[0]: - if hasattr(plugin, 'CONFIGWIDGET_CLASS'): - self.preferences.register_plugin_preferences(plugin) - - if not hasattr(plugin, 'COMPLETION_PROVIDER_NAME'): - self.thirdparty_plugins.append(plugin) - - # Add to dependencies dialog - module = mod.__name__ - name = module.replace('_', '-') - if plugin.DESCRIPTION: - description = plugin.DESCRIPTION - else: - description = plugin.get_plugin_title() - - dependencies.add(module, name, description, - '', None, kind=dependencies.PLUGIN) - except TypeError: - # Fixes spyder-ide/spyder#13977 - pass - except Exception as error: - print("%s: %s" % (mod, str(error)), file=STDERR) - traceback.print_exc(file=STDERR) - - # Set window title - self.set_window_title() - - # Menus - # TODO: Remove when all menus are migrated to use the Main Menu Plugin - logger.info("Creating Menus...") - from spyder.plugins.mainmenu.api import ( - ApplicationMenus, ToolsMenuSections, FileMenuSections) - mainmenu = self.mainmenu - self.edit_menu = mainmenu.get_application_menu("edit_menu") - self.search_menu = mainmenu.get_application_menu("search_menu") - self.source_menu = mainmenu.get_application_menu("source_menu") - self.source_menu.aboutToShow.connect(self.update_source_menu) - self.run_menu = mainmenu.get_application_menu("run_menu") - self.debug_menu = mainmenu.get_application_menu("debug_menu") - - # Switcher shortcuts - self.file_switcher_action = create_action( - self, - _('File switcher...'), - icon=ima.icon('filelist'), - tip=_('Fast switch between files'), - triggered=self.open_switcher, - context=Qt.ApplicationShortcut, - id_='file_switcher') - self.register_shortcut(self.file_switcher_action, context="_", - name="File switcher") - self.symbol_finder_action = create_action( - self, _('Symbol finder...'), - icon=ima.icon('symbol_find'), - tip=_('Fast symbol search in file'), - triggered=self.open_symbolfinder, - context=Qt.ApplicationShortcut, - id_='symbol_finder') - self.register_shortcut(self.symbol_finder_action, context="_", - name="symbol finder", add_shortcut_to_tip=True) - - def create_edit_action(text, tr_text, icon): - textseq = text.split(' ') - method_name = textseq[0].lower()+"".join(textseq[1:]) - action = create_action(self, tr_text, - icon=icon, - triggered=self.global_callback, - data=method_name, - context=Qt.WidgetShortcut) - self.register_shortcut(action, "Editor", text) - return action - - self.undo_action = create_edit_action('Undo', _('Undo'), - ima.icon('undo')) - self.redo_action = create_edit_action('Redo', _('Redo'), - ima.icon('redo')) - self.copy_action = create_edit_action('Copy', _('Copy'), - ima.icon('editcopy')) - self.cut_action = create_edit_action('Cut', _('Cut'), - ima.icon('editcut')) - self.paste_action = create_edit_action('Paste', _('Paste'), - ima.icon('editpaste')) - self.selectall_action = create_edit_action("Select All", - _("Select All"), - ima.icon('selectall')) - - self.edit_menu_actions += [self.undo_action, self.redo_action, - None, self.cut_action, self.copy_action, - self.paste_action, self.selectall_action, - None] - if self.get_plugin(Plugins.Editor, error=False): - self.edit_menu_actions += self.editor.edit_menu_actions - - switcher_actions = [ - self.file_switcher_action, - self.symbol_finder_action - ] - for switcher_action in switcher_actions: - mainmenu.add_item_to_application_menu( - switcher_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Switcher, - before_section=FileMenuSections.Restart) - self.set_splash("") - - # Toolbars - # TODO: Remove after finishing the migration - logger.info("Creating toolbars...") - toolbar = self.toolbar - self.file_toolbar = toolbar.get_application_toolbar("file_toolbar") - self.run_toolbar = toolbar.get_application_toolbar("run_toolbar") - self.debug_toolbar = toolbar.get_application_toolbar("debug_toolbar") - self.main_toolbar = toolbar.get_application_toolbar("main_toolbar") - - # Tools + External Tools (some of this depends on the Application - # plugin) - logger.info("Creating Tools menu...") - - spyder_path_action = create_action( - self, - _("PYTHONPATH manager"), - None, icon=ima.icon('pythonpath'), - triggered=self.show_path_manager, - tip=_("PYTHONPATH manager"), - id_='spyder_path_action') - from spyder.plugins.application.container import ( - ApplicationActions, WinUserEnvDialog) - winenv_action = None - if WinUserEnvDialog: - winenv_action = ApplicationActions.SpyderWindowsEnvVariables - mainmenu.add_item_to_application_menu( - spyder_path_action, - menu_id=ApplicationMenus.Tools, - section=ToolsMenuSections.Tools, - before=winenv_action, - before_section=ToolsMenuSections.External - ) - - # Main toolbar - from spyder.plugins.toolbar.api import ( - ApplicationToolbars, MainToolbarSections) - self.toolbar.add_item_to_application_toolbar( - spyder_path_action, - toolbar_id=ApplicationToolbars.Main, - section=MainToolbarSections.ApplicationSection - ) - - self.set_splash(_("Setting up main window...")) - - # TODO: Migrate to use the MainMenu Plugin instead of list of actions - # Filling out menu/toolbar entries: - add_actions(self.edit_menu, self.edit_menu_actions) - add_actions(self.search_menu, self.search_menu_actions) - add_actions(self.source_menu, self.source_menu_actions) - add_actions(self.run_menu, self.run_menu_actions) - add_actions(self.debug_menu, self.debug_menu_actions) - - # Emitting the signal notifying plugins that main window menu and - # toolbar actions are all defined: - self.all_actions_defined.emit() - - def __getattr__(self, attr): - """ - Redefinition of __getattr__ to enable access to plugins. - - Loaded plugins can be accessed as attributes of the mainwindow - as before, e.g self.console or self.main.console, preserving the - same accessor as before. - """ - # Mapping of new plugin identifiers vs old attributtes - # names given for plugins - try: - if attr in self._INTERNAL_PLUGINS_MAPPING.keys(): - return self.get_plugin( - self._INTERNAL_PLUGINS_MAPPING[attr], error=False) - return self.get_plugin(attr) - except SpyderAPIError: - pass - return super().__getattr__(attr) - - def pre_visible_setup(self): - """ - Actions to be performed before the main window is visible. - - The actions here are related with setting up the main window. - """ - logger.info("Setting up window...") - - for plugin_name in PLUGIN_REGISTRY: - plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) - try: - plugin_instance.before_mainwindow_visible() - except AttributeError: - pass - - # Tabify external plugins which were installed after Spyder was - # installed. - # Note: This is only necessary the first time a plugin is loaded. - # Afterwards, the plugin placement is recorded on the window hexstate, - # which is loaded by the layouts plugin during the next session. - for plugin_name in PLUGIN_REGISTRY.external_plugins: - plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) - if plugin_instance.get_conf('first_time', True): - self.tabify_plugin(plugin_instance, Plugins.Console) - - if self.splash is not None: - self.splash.hide() - - # Menu about to show - for child in self.menuBar().children(): - if isinstance(child, QMenu): - try: - child.aboutToShow.connect(self.update_edit_menu) - child.aboutToShow.connect(self.update_search_menu) - except TypeError: - pass - - # Register custom layouts - for plugin_name in PLUGIN_REGISTRY.external_plugins: - plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) - if hasattr(plugin_instance, 'CUSTOM_LAYOUTS'): - if isinstance(plugin_instance.CUSTOM_LAYOUTS, list): - for custom_layout in plugin_instance.CUSTOM_LAYOUTS: - self.layouts.register_layout( - self, custom_layout) - else: - logger.info( - 'Unable to load custom layouts for {}. ' - 'Expecting a list of layout classes but got {}' - .format(plugin_name, plugin_instance.CUSTOM_LAYOUTS) - ) - - # Needed to ensure dockwidgets/panes layout size distribution - # when a layout state is already present. - # See spyder-ide/spyder#17945 - if self.layouts is not None and CONF.get('main', 'window/state', None): - self.layouts.before_mainwindow_visible() - - logger.info("*** End of MainWindow setup ***") - self.is_starting_up = False - - def post_visible_setup(self): - """ - Actions to be performed only after the main window's `show` method - is triggered. - """ - # Process pending events and hide splash before loading the - # previous session. - QApplication.processEvents() - if self.splash is not None: - self.splash.hide() - - # Call on_mainwindow_visible for all plugins. - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - try: - plugin.on_mainwindow_visible() - QApplication.processEvents() - except AttributeError: - pass - - self.restore_scrollbar_position.emit() - - # Server to maintain just one Spyder instance and open files in it if - # the user tries to start other instances with - # $ spyder foo.py - if ( - CONF.get('main', 'single_instance') and - not self._cli_options.new_instance and - self.open_files_server - ): - t = threading.Thread(target=self.start_open_files_server) - t.daemon = True - t.start() - - # Connect the window to the signal emitted by the previous server - # when it gets a client connected to it - self.sig_open_external_file.connect(self.open_external_file) - - # Update plugins toggle actions to show the "Switch to" plugin shortcut - self._update_shortcuts_in_panes_menu() - - # Reopen last session if no project is active - # NOTE: This needs to be after the calls to on_mainwindow_visible - self.reopen_last_session() - - # Raise the menuBar to the top of the main window widget's stack - # Fixes spyder-ide/spyder#3887. - self.menuBar().raise_() - - # To avoid regressions. We shouldn't have loaded the modules - # below at this point. - if DEV is not None: - assert 'pandas' not in sys.modules - assert 'matplotlib' not in sys.modules - - # Restore undocked plugins - self.restore_undocked_plugins() - - # Notify that the setup of the mainwindow was finished - self.is_setting_up = False - self.sig_setup_finished.emit() - - def reopen_last_session(self): - """ - Reopen last session if no project is active. - - This can't be moved to on_mainwindow_visible in the editor because we - need to let the same method on Projects run first. - """ - projects = self.get_plugin(Plugins.Projects, error=False) - editor = self.get_plugin(Plugins.Editor, error=False) - reopen_last_session = False - - if projects: - if projects.get_active_project() is None: - reopen_last_session = True - else: - reopen_last_session = True - - if editor and reopen_last_session: - editor.setup_open_files(close_previous_files=False) - - def restore_undocked_plugins(self): - """Restore plugins that were undocked in the previous session.""" - logger.info("Restoring undocked plugins from the previous session") - - for plugin_name in PLUGIN_REGISTRY: - plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) - if isinstance(plugin, SpyderDockablePlugin): - if plugin.get_conf('undocked_on_window_close', default=False): - plugin.get_widget().create_window() - elif isinstance(plugin, SpyderPluginWidget): - if plugin.get_option('undocked_on_window_close', - default=False): - plugin._create_window() - - def set_window_title(self): - """Set window title.""" - if DEV is not None: - title = u"Spyder %s (Python %s.%s)" % (__version__, - sys.version_info[0], - sys.version_info[1]) - elif running_in_mac_app() or is_pynsist(): - title = "Spyder" - else: - title = u"Spyder (Python %s.%s)" % (sys.version_info[0], - sys.version_info[1]) - - if get_debug_level(): - title += u" [DEBUG MODE %d]" % get_debug_level() - - window_title = self._cli_options.window_title - if window_title is not None: - title += u' -- ' + to_text_string(window_title) - - # TODO: Remove self.projects reference once there's an API for setting - # window title. - projects = self.get_plugin(Plugins.Projects, error=False) - if projects: - path = projects.get_active_project_path() - if path: - path = path.replace(get_home_dir(), u'~') - title = u'{0} - {1}'.format(path, title) - - self.base_title = title - self.setWindowTitle(self.base_title) - - # TODO: To be removed after all actions are moved to their corresponding - # plugins - def register_shortcut(self, qaction_or_qshortcut, context, name, - add_shortcut_to_tip=True, plugin_name=None): - shortcuts = self.get_plugin(Plugins.Shortcuts, error=False) - if shortcuts: - shortcuts.register_shortcut( - qaction_or_qshortcut, - context, - name, - add_shortcut_to_tip=add_shortcut_to_tip, - plugin_name=plugin_name, - ) - - # --- Other - def update_source_menu(self): - """Update source menu options that vary dynamically.""" - # This is necessary to avoid an error at startup. - # Fixes spyder-ide/spyder#14901 - try: - editor = self.get_plugin(Plugins.Editor, error=False) - if editor: - editor.refresh_formatter_name() - except AttributeError: - pass - - def free_memory(self): - """Free memory after event.""" - gc.collect() - - def plugin_focus_changed(self): - """Focus has changed from one plugin to another""" - self.update_edit_menu() - self.update_search_menu() - - def show_shortcuts(self, menu): - """Show action shortcuts in menu.""" - menu_actions = menu.actions() - for action in menu_actions: - if getattr(action, '_shown_shortcut', False): - # This is a SpyderAction - if action._shown_shortcut is not None: - action.setShortcut(action._shown_shortcut) - elif action.menu() is not None: - # This is submenu, so we need to call this again - self.show_shortcuts(action.menu()) - else: - # We don't need to do anything for other elements - continue - - def hide_shortcuts(self, menu): - """Hide action shortcuts in menu.""" - menu_actions = menu.actions() - for action in menu_actions: - if getattr(action, '_shown_shortcut', False): - # This is a SpyderAction - if action._shown_shortcut is not None: - action.setShortcut(QKeySequence()) - elif action.menu() is not None: - # This is submenu, so we need to call this again - self.hide_shortcuts(action.menu()) - else: - # We don't need to do anything for other elements - continue - - def hide_options_menus(self): - """Hide options menu when menubar is pressed in macOS.""" - for plugin in self.widgetlist + self.thirdparty_plugins: - if plugin.CONF_SECTION == 'editor': - editorstack = self.editor.get_current_editorstack() - editorstack.menu.hide() - else: - try: - # New API - plugin.options_menu.hide() - except AttributeError: - # Old API - plugin._options_menu.hide() - - def get_focus_widget_properties(self): - """Get properties of focus widget - Returns tuple (widget, properties) where properties is a tuple of - booleans: (is_console, not_readonly, readwrite_editor)""" - from spyder.plugins.editor.widgets.base import TextEditBaseWidget - from spyder.plugins.ipythonconsole.widgets import ControlWidget - widget = QApplication.focusWidget() - - textedit_properties = None - if isinstance(widget, (TextEditBaseWidget, ControlWidget)): - console = isinstance(widget, ControlWidget) - not_readonly = not widget.isReadOnly() - readwrite_editor = not_readonly and not console - textedit_properties = (console, not_readonly, readwrite_editor) - return widget, textedit_properties - - def update_edit_menu(self): - """Update edit menu""" - widget, textedit_properties = self.get_focus_widget_properties() - if textedit_properties is None: # widget is not an editor/console - return - # !!! Below this line, widget is expected to be a QPlainTextEdit - # instance - console, not_readonly, readwrite_editor = textedit_properties - - if hasattr(self, 'editor'): - # Editor has focus and there is no file opened in it - if (not console and not_readonly and self.editor - and not self.editor.is_file_opened()): - return - - # Disabling all actions to begin with - for child in self.edit_menu.actions(): - child.setEnabled(False) - - self.selectall_action.setEnabled(True) - - # Undo, redo - self.undo_action.setEnabled( readwrite_editor \ - and widget.document().isUndoAvailable() ) - self.redo_action.setEnabled( readwrite_editor \ - and widget.document().isRedoAvailable() ) - - # Copy, cut, paste, delete - has_selection = widget.has_selected_text() - self.copy_action.setEnabled(has_selection) - self.cut_action.setEnabled(has_selection and not_readonly) - self.paste_action.setEnabled(not_readonly) - - # Comment, uncomment, indent, unindent... - if not console and not_readonly: - # This is the editor and current file is writable - if self.get_plugin(Plugins.Editor, error=False): - for action in self.editor.edit_menu_actions: - action.setEnabled(True) - - def update_search_menu(self): - """Update search menu""" - # Disabling all actions except the last one - # (which is Find in files) to begin with - for child in self.search_menu.actions()[:-1]: - child.setEnabled(False) - - widget, textedit_properties = self.get_focus_widget_properties() - if textedit_properties is None: # widget is not an editor/console - return - - # !!! Below this line, widget is expected to be a QPlainTextEdit - # instance - console, not_readonly, readwrite_editor = textedit_properties - - # Find actions only trigger an effect in the Editor - if not console: - for action in self.search_menu.actions(): - try: - action.setEnabled(True) - except RuntimeError: - pass - - # Disable the replace action for read-only files - if len(self.search_menu_actions) > 3: - self.search_menu_actions[3].setEnabled(readwrite_editor) - - def createPopupMenu(self): - return self.application.get_application_context_menu(parent=self) - - def set_splash(self, message): - """Set splash message""" - if self.splash is None: - return - if message: - logger.info(message) - self.splash.show() - self.splash.showMessage(message, - int(Qt.AlignBottom | Qt.AlignCenter | - Qt.AlignAbsolute), - QColor(Qt.white)) - QApplication.processEvents() - - def closeEvent(self, event): - """closeEvent reimplementation""" - if self.closing(True): - event.accept() - else: - event.ignore() - - def resizeEvent(self, event): - """Reimplement Qt method""" - if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): - self.window_size = self.size() - QMainWindow.resizeEvent(self, event) - - # To be used by the tour to be able to resize - self.sig_resized.emit(event) - - def moveEvent(self, event): - """Reimplement Qt method""" - if hasattr(self, 'layouts'): - if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): - self.window_position = self.pos() - QMainWindow.moveEvent(self, event) - # To be used by the tour to be able to move - self.sig_moved.emit(event) - - def hideEvent(self, event): - """Reimplement Qt method""" - try: - for plugin in (self.widgetlist + self.thirdparty_plugins): - # TODO: Remove old API - try: - # New API - if plugin.get_widget().isAncestorOf( - self.last_focused_widget): - plugin.change_visibility(True) - except AttributeError: - # Old API - if plugin.isAncestorOf(self.last_focused_widget): - plugin._visibility_changed(True) - - QMainWindow.hideEvent(self, event) - except RuntimeError: - QMainWindow.hideEvent(self, event) - - def change_last_focused_widget(self, old, now): - """To keep track of to the last focused widget""" - if (now is None and QApplication.activeWindow() is not None): - QApplication.activeWindow().setFocus() - self.last_focused_widget = QApplication.focusWidget() - elif now is not None: - self.last_focused_widget = now - - self.previous_focused_widget = old - - def closing(self, cancelable=False, close_immediately=False): - """Exit tasks""" - if self.already_closed or self.is_starting_up: - return True - - self.plugin_registry = PLUGIN_REGISTRY - - if cancelable and CONF.get('main', 'prompt_on_exit'): - reply = QMessageBox.critical(self, 'Spyder', - 'Do you really want to exit?', - QMessageBox.Yes, QMessageBox.No) - if reply == QMessageBox.No: - return False - - can_close = self.plugin_registry.delete_all_plugins( - excluding={Plugins.Layout}, - close_immediately=close_immediately) - - if not can_close and not close_immediately: - return False - - # Save window settings *after* closing all plugin windows, in order - # to show them in their previous locations in the next session. - # Fixes spyder-ide/spyder#12139 - prefix = 'window' + '/' - if self.layouts is not None: - self.layouts.save_current_window_settings(prefix) - try: - layouts_container = self.layouts.get_container() - if layouts_container: - layouts_container.close() - layouts_container.deleteLater() - self.layouts.deleteLater() - self.plugin_registry.delete_plugin( - Plugins.Layout, teardown=False) - except RuntimeError: - pass - - self.already_closed = True - - if CONF.get('main', 'single_instance') and self.open_files_server: - self.open_files_server.close() - - QApplication.processEvents() - - return True - - def add_dockwidget(self, plugin): - """ - Add a plugin QDockWidget to the main window. - """ - try: - # New API - if plugin.is_compatible: - dockwidget, location = plugin.create_dockwidget(self) - self.addDockWidget(location, dockwidget) - self.widgetlist.append(plugin) - except AttributeError: - # Old API - if plugin._is_compatible: - dockwidget, location = plugin._create_dockwidget() - self.addDockWidget(location, dockwidget) - self.widgetlist.append(plugin) - - def global_callback(self): - """Global callback""" - widget = QApplication.focusWidget() - action = self.sender() - callback = from_qvariant(action.data(), to_text_string) - from spyder.plugins.editor.widgets.base import TextEditBaseWidget - from spyder.plugins.ipythonconsole.widgets import ControlWidget - - if isinstance(widget, (TextEditBaseWidget, ControlWidget)): - getattr(widget, callback)() - else: - return - - def redirect_internalshell_stdio(self, state): - console = self.get_plugin(Plugins.Console, error=False) - if console: - if state: - console.redirect_stds() - else: - console.restore_stds() - - def open_external_console(self, fname, wdir, args, interact, debug, python, - python_args, systerm, post_mortem=False): - """Open external console""" - if systerm: - # Running script in an external system terminal - try: - if CONF.get('main_interpreter', 'default'): - executable = get_python_executable() - else: - executable = CONF.get('main_interpreter', 'executable') - pypath = CONF.get('main', 'spyder_pythonpath', None) - programs.run_python_script_in_terminal( - fname, wdir, args, interact, debug, python_args, - executable, pypath) - except NotImplementedError: - QMessageBox.critical(self, _("Run"), - _("Running an external system terminal " - "is not supported on platform %s." - ) % os.name) - - def open_file(self, fname, external=False): - """ - Open filename with the appropriate application - Redirect to the right widget (txt -> editor, spydata -> workspace, ...) - or open file outside Spyder (if extension is not supported) - """ - fname = to_text_string(fname) - ext = osp.splitext(fname)[1] - editor = self.get_plugin(Plugins.Editor, error=False) - variableexplorer = self.get_plugin( - Plugins.VariableExplorer, error=False) - - if encoding.is_text_file(fname): - if editor: - editor.load(fname) - elif variableexplorer is not None and ext in IMPORT_EXT: - variableexplorer.get_widget().import_data(fname) - elif not external: - fname = file_uri(fname) - start_file(fname) - - def get_initial_working_directory(self): - """Return the initial working directory.""" - return self.INITIAL_CWD - - def open_external_file(self, fname): - """ - Open external files that can be handled either by the Editor or the - variable explorer inside Spyder. - """ - # Check that file exists - fname = encoding.to_unicode_from_fs(fname) - initial_cwd = self.get_initial_working_directory() - if osp.exists(osp.join(initial_cwd, fname)): - fpath = osp.join(initial_cwd, fname) - elif osp.exists(fname): - fpath = fname - else: - return - - # Don't open script that starts Spyder at startup. - # Fixes issue spyder-ide/spyder#14483 - if sys.platform == 'darwin' and 'bin/spyder' in fname: - return - - if osp.isfile(fpath): - self.open_file(fpath, external=True) - elif osp.isdir(fpath): - QMessageBox.warning( - self, _("Error"), - _('To open {fpath} as a project with Spyder, ' - 'please use spyder -p "{fname}".') - .format(fpath=osp.normpath(fpath), fname=fname) - ) - - # --- Path Manager - # ------------------------------------------------------------------------ - def load_python_path(self): - """Load path stored in Spyder configuration folder.""" - if osp.isfile(self.SPYDER_PATH): - with open(self.SPYDER_PATH, 'r', encoding='utf-8') as f: - path = f.read().splitlines() - self.path = tuple(name for name in path if osp.isdir(name)) - - if osp.isfile(self.SPYDER_NOT_ACTIVE_PATH): - with open(self.SPYDER_NOT_ACTIVE_PATH, 'r', - encoding='utf-8') as f: - not_active_path = f.read().splitlines() - self.not_active_path = tuple(name for name in not_active_path - if osp.isdir(name)) - - def save_python_path(self, new_path_dict): - """ - Save path in Spyder configuration folder. - - `new_path_dict` is an OrderedDict that has the new paths as keys and - the state as values. The state is `True` for active and `False` for - inactive. - """ - path = [p for p in new_path_dict] - not_active_path = [p for p in new_path_dict if not new_path_dict[p]] - try: - encoding.writelines(path, self.SPYDER_PATH) - encoding.writelines(not_active_path, self.SPYDER_NOT_ACTIVE_PATH) - except EnvironmentError as e: - logger.error(str(e)) - CONF.set('main', 'spyder_pythonpath', self.get_spyder_pythonpath()) - - def get_spyder_pythonpath_dict(self): - """ - Return Spyder PYTHONPATH. - - The returned ordered dictionary has the paths as keys and the state - as values. The state is `True` for active and `False` for inactive. - - Example: - OrderedDict([('/some/path, True), ('/some/other/path, False)]) - """ - self.load_python_path() - - path_dict = OrderedDict() - for path in self.path: - path_dict[path] = path not in self.not_active_path - - for path in self.project_path: - path_dict[path] = True - - return path_dict - - def get_spyder_pythonpath(self): - """ - Return Spyder PYTHONPATH. - """ - path_dict = self.get_spyder_pythonpath_dict() - path = [k for k, v in path_dict.items() if v] - return path - - def update_python_path(self, new_path_dict): - """Update python path on Spyder interpreter and kernels.""" - # Load previous path - path_dict = self.get_spyder_pythonpath_dict() - - # Save path - if path_dict != new_path_dict: - # It doesn't include the project_path - self.save_python_path(new_path_dict) - - # Load new path - new_path_dict_p = self.get_spyder_pythonpath_dict() # Includes project - - # Any plugin that needs to do some work based on this signal should - # connect to it on plugin registration - self.sig_pythonpath_changed.emit(path_dict, new_path_dict_p) - - @Slot() - def show_path_manager(self): - """Show path manager dialog.""" - def _dialog_finished(result_code): - """Restore path manager dialog instance variable.""" - self._path_manager = None - - if self._path_manager is None: - from spyder.widgets.pathmanager import PathManager - projects = self.get_plugin(Plugins.Projects, error=False) - read_only_path = () - if projects: - read_only_path = tuple(projects.get_pythonpath()) - - dialog = PathManager(self, self.path, read_only_path, - self.not_active_path, sync=True) - self._path_manager = dialog - dialog.sig_path_changed.connect(self.update_python_path) - dialog.redirect_stdio.connect(self.redirect_internalshell_stdio) - dialog.finished.connect(_dialog_finished) - dialog.show() - else: - self._path_manager.show() - self._path_manager.activateWindow() - self._path_manager.raise_() - self._path_manager.setFocus() - - def pythonpath_changed(self): - """Project's PYTHONPATH contribution has changed.""" - projects = self.get_plugin(Plugins.Projects, error=False) - - self.project_path = () - if projects: - self.project_path = tuple(projects.get_pythonpath()) - path_dict = self.get_spyder_pythonpath_dict() - self.update_python_path(path_dict) - - #---- Preferences - def apply_settings(self): - """Apply main window settings.""" - qapp = QApplication.instance() - - # Set 'gtk+' as the default theme in Gtk-based desktops - # Fixes spyder-ide/spyder#2036. - if is_gtk_desktop() and ('GTK+' in QStyleFactory.keys()): - try: - qapp.setStyle('gtk+') - except: - pass - - default = self.DOCKOPTIONS - if CONF.get('main', 'vertical_tabs'): - default = default|QMainWindow.VerticalTabs - self.setDockOptions(default) - - self.apply_panes_settings() - - if CONF.get('main', 'use_custom_cursor_blinking'): - qapp.setCursorFlashTime( - CONF.get('main', 'custom_cursor_blinking')) - else: - qapp.setCursorFlashTime(self.CURSORBLINK_OSDEFAULT) - - def apply_panes_settings(self): - """Update dockwidgets features settings.""" - for plugin in (self.widgetlist + self.thirdparty_plugins): - features = plugin.dockwidget.FEATURES - - plugin.dockwidget.setFeatures(features) - - try: - # New API - margin = 0 - if CONF.get('main', 'use_custom_margin'): - margin = CONF.get('main', 'custom_margin') - plugin.update_margins(margin) - except AttributeError: - # Old API - plugin._update_margins() - - @Slot() - def show_preferences(self): - """Edit Spyder preferences.""" - self.preferences.open_dialog(self.prefs_dialog_size) - - def set_prefs_size(self, size): - """Save preferences dialog size.""" - self.prefs_dialog_size = size - - # ---- Open files server - def start_open_files_server(self): - self.open_files_server.setsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR, 1) - port = select_port(default_port=OPEN_FILES_PORT) - CONF.set('main', 'open_files_port', port) - - # This is necessary in case it's not possible to bind a port for the - # server in the system. - # Fixes spyder-ide/spyder#18262 - try: - self.open_files_server.bind(('127.0.0.1', port)) - except OSError: - self.open_files_server = None - return - - # Number of petitions the server can queue - self.open_files_server.listen(20) - - while 1: # 1 is faster than True - try: - req, dummy = self.open_files_server.accept() - except socket.error as e: - # See spyder-ide/spyder#1275 for details on why errno EINTR is - # silently ignored here. - eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR - # To avoid a traceback after closing on Windows - if e.args[0] == eintr: - continue - # handle a connection abort on close error - enotsock = (errno.WSAENOTSOCK if os.name == 'nt' - else errno.ENOTSOCK) - if e.args[0] in [errno.ECONNABORTED, enotsock]: - return - if self.already_closed: - return - raise - fname = req.recv(1024) - fname = fname.decode('utf-8') - self.sig_open_external_file.emit(fname) - req.sendall(b' ') - - # ---- Quit and restart, and reset spyder defaults - @Slot() - def reset_spyder(self): - """ - Quit and reset Spyder and then Restart application. - """ - answer = QMessageBox.warning(self, _("Warning"), - _("Spyder will restart and reset to default settings:

" - "Do you want to continue?"), - QMessageBox.Yes | QMessageBox.No) - if answer == QMessageBox.Yes: - self.restart(reset=True) - - @Slot() - def restart(self, reset=False, close_immediately=False): - """Wrapper to handle plugins request to restart Spyder.""" - self.application.restart( - reset=reset, close_immediately=close_immediately) - - # ---- Global Switcher - def open_switcher(self, symbol=False): - """Open switcher dialog box.""" - if self.switcher is not None and self.switcher.isVisible(): - self.switcher.clear() - self.switcher.hide() - return - if symbol: - self.switcher.set_search_text('@') - else: - self.switcher.set_search_text('') - self.switcher.setup() - self.switcher.show() - - # Note: The +6 pixel on the top makes it look better - # FIXME: Why is this using the toolbars menu? A: To not be on top of - # the toolbars. - # Probably toolbars should be taken into account for this 'delta' only - # when are visible - delta_top = (self.toolbar.toolbars_menu.geometry().height() + - self.menuBar().geometry().height() + 6) - - self.switcher.set_position(delta_top) - - def open_symbolfinder(self): - """Open symbol list management dialog box.""" - self.open_switcher(symbol=True) - - def create_switcher(self): - """Create switcher dialog instance.""" - if self.switcher is None: - from spyder.widgets.switcher import Switcher - self.switcher = Switcher(self) - - return self.switcher - - # --- For OpenGL - def _test_setting_opengl(self, option): - """Get the current OpenGL implementation in use""" - if option == 'software': - return QCoreApplication.testAttribute(Qt.AA_UseSoftwareOpenGL) - elif option == 'desktop': - return QCoreApplication.testAttribute(Qt.AA_UseDesktopOpenGL) - elif option == 'gles': - return QCoreApplication.testAttribute(Qt.AA_UseOpenGLES) - - -#============================================================================== -# Main -#============================================================================== -def main(options, args): - """Main function""" - # **** For Pytest **** - if running_under_pytest(): - if CONF.get('main', 'opengl') != 'automatic': - option = CONF.get('main', 'opengl') - set_opengl_implementation(option) - - app = create_application() - window = create_window(MainWindow, app, None, options, None) - return window - - # **** Handle hide_console option **** - if options.show_console: - print("(Deprecated) --show console does nothing, now the default " - " behavior is to show the console, use --hide-console if you " - "want to hide it") - - if set_attached_console_visible is not None: - set_attached_console_visible(not options.hide_console - or options.reset_config_files - or options.reset_to_defaults - or options.optimize - or bool(get_debug_level())) - - # **** Set OpenGL implementation to use **** - # This attribute must be set before creating the application. - # See spyder-ide/spyder#11227 - if options.opengl_implementation: - option = options.opengl_implementation - set_opengl_implementation(option) - else: - if CONF.get('main', 'opengl') != 'automatic': - option = CONF.get('main', 'opengl') - set_opengl_implementation(option) - - # **** Set high DPI scaling **** - # This attribute must be set before creating the application. - if hasattr(Qt, 'AA_EnableHighDpiScaling'): - QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, - CONF.get('main', 'high_dpi_scaling')) - - # **** Set debugging info **** - if get_debug_level() > 0: - delete_debug_log_files() - setup_logging(options) - - # **** Create the application **** - app = create_application() - - # **** Create splash screen **** - splash = create_splash_screen() - if splash is not None: - splash.show() - splash.showMessage( - _("Initializing..."), - int(Qt.AlignBottom | Qt.AlignCenter | Qt.AlignAbsolute), - QColor(Qt.white) - ) - QApplication.processEvents() - - if options.reset_to_defaults: - # Reset Spyder settings to defaults - CONF.reset_to_defaults() - return - elif options.optimize: - # Optimize the whole Spyder's source code directory - import spyder - programs.run_python_script(module="compileall", - args=[spyder.__path__[0]], p_args=['-O']) - return - - # **** Read faulthandler log file **** - faulthandler_file = get_conf_path('faulthandler.log') - previous_crash = '' - if osp.exists(faulthandler_file): - with open(faulthandler_file, 'r') as f: - previous_crash = f.read() - - # Remove file to not pick it up for next time. - try: - dst = get_conf_path('faulthandler.log.old') - shutil.move(faulthandler_file, dst) - except Exception: - pass - CONF.set('main', 'previous_crash', previous_crash) - - # **** Set color for links **** - set_links_color(app) - - # **** Create main window **** - mainwindow = None - try: - if PY3 and options.report_segfault: - import faulthandler - with open(faulthandler_file, 'w') as f: - faulthandler.enable(file=f) - mainwindow = create_window( - MainWindow, app, splash, options, args - ) - else: - mainwindow = create_window(MainWindow, app, splash, options, args) - except FontError: - QMessageBox.information(None, "Spyder", - "Spyder was unable to load the Spyder 3 " - "icon theme. That's why it's going to fallback to the " - "theme used in Spyder 2.

" - "For that, please close this window and start Spyder again.") - CONF.set('appearance', 'icon_theme', 'spyder 2') - if mainwindow is None: - # An exception occurred - if splash is not None: - splash.hide() - return - - ORIGINAL_SYS_EXIT() - - -if __name__ == "__main__": - main() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder, the Scientific Python Development Environment +===================================================== + +Developed and maintained by the Spyder Project +Contributors + +Copyright © Spyder Project Contributors +Licensed under the terms of the MIT License +(see spyder/__init__.py for details) +""" + +# ============================================================================= +# Stdlib imports +# ============================================================================= +from collections import OrderedDict +from enum import Enum +import errno +import gc +import logging +import os +import os.path as osp +import shutil +import signal +import socket +import sys +import threading +import traceback + +#============================================================================== +# Check requirements before proceeding +#============================================================================== +from spyder import requirements +requirements.check_path() +requirements.check_qt() + +#============================================================================== +# Third-party imports +#============================================================================== +from qtpy.compat import from_qvariant +from qtpy.QtCore import (QCoreApplication, Qt, QTimer, Signal, Slot, + qInstallMessageHandler) +from qtpy.QtGui import QColor, QKeySequence +from qtpy.QtWidgets import (QApplication, QMainWindow, QMenu, QMessageBox, + QShortcut, QStyleFactory) + +# Avoid a "Cannot mix incompatible Qt library" error on Windows platforms +from qtpy import QtSvg # analysis:ignore + +# Avoid a bug in Qt: https://bugreports.qt.io/browse/QTBUG-46720 +from qtpy import QtWebEngineWidgets # analysis:ignore + +from qtawesome.iconic_font import FontError + +#============================================================================== +# Local imports +# NOTE: Move (if possible) import's of widgets and plugins exactly where they +# are needed in MainWindow to speed up perceived startup time (i.e. the time +# from clicking the Spyder icon to showing the splash screen). +#============================================================================== +from spyder import __version__ +from spyder import dependencies +from spyder.app.find_plugins import ( + find_external_plugins, find_internal_plugins) +from spyder.app.utils import ( + create_application, create_splash_screen, create_window, ORIGINAL_SYS_EXIT, + delete_debug_log_files, qt_message_handler, set_links_color, setup_logging, + set_opengl_implementation) +from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY +from spyder.config.base import (_, DEV, get_conf_path, get_debug_level, + get_home_dir, get_module_source_path, + is_pynsist, running_in_mac_app, + running_under_pytest, STDERR) +from spyder.config.gui import is_dark_font_color +from spyder.config.main import OPEN_FILES_PORT +from spyder.config.manager import CONF +from spyder.config.utils import IMPORT_EXT, is_gtk_desktop +from spyder.otherplugins import get_spyderplugins_mods +from spyder.py3compat import configparser as cp, PY3, to_text_string +from spyder.utils import encoding, programs +from spyder.utils.icon_manager import ima +from spyder.utils.misc import (select_port, getcwd_or_home, + get_python_executable) +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import (create_action, add_actions, file_uri, + qapplication, start_file) +from spyder.utils.stylesheet import APP_STYLESHEET + +# Spyder API Imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import ( + Plugins, SpyderPlugin, SpyderPluginV2, SpyderDockablePlugin, + SpyderPluginWidget) + +#============================================================================== +# Windows only local imports +#============================================================================== +set_attached_console_visible = None +is_attached_console_visible = None +set_windows_appusermodelid = None +if os.name == 'nt': + from spyder.utils.windows import (set_attached_console_visible, + set_windows_appusermodelid) + +#============================================================================== +# Constants +#============================================================================== +# Module logger +logger = logging.getLogger(__name__) + +#============================================================================== +# Install Qt messaage handler +#============================================================================== +qInstallMessageHandler(qt_message_handler) + +#============================================================================== +# Main Window +#============================================================================== +class MainWindow(QMainWindow): + """Spyder main window""" + DOCKOPTIONS = ( + QMainWindow.AllowTabbedDocks | QMainWindow.AllowNestedDocks | + QMainWindow.AnimatedDocks + ) + SPYDER_PATH = get_conf_path('path') + SPYDER_NOT_ACTIVE_PATH = get_conf_path('not_active_path') + DEFAULT_LAYOUTS = 4 + INITIAL_CWD = getcwd_or_home() + + # Signals + restore_scrollbar_position = Signal() + sig_setup_finished = Signal() + all_actions_defined = Signal() + # type: (OrderedDict, OrderedDict) + sig_pythonpath_changed = Signal(object, object) + sig_open_external_file = Signal(str) + sig_resized = Signal("QResizeEvent") + sig_moved = Signal("QMoveEvent") + sig_layout_setup_ready = Signal(object) # Related to default layouts + + # ---- Plugin handling methods + # ------------------------------------------------------------------------ + def get_plugin(self, plugin_name, error=True): + """ + Return a plugin instance by providing the plugin class. + """ + if plugin_name in PLUGIN_REGISTRY: + return PLUGIN_REGISTRY.get_plugin(plugin_name) + + if error: + raise SpyderAPIError(f'Plugin "{plugin_name}" not found!') + + return None + + def get_dockable_plugins(self): + """Get a list of all dockable plugins.""" + dockable_plugins = [] + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + if isinstance(plugin, (SpyderDockablePlugin, SpyderPluginWidget)): + dockable_plugins.append((plugin_name, plugin)) + return dockable_plugins + + def is_plugin_enabled(self, plugin_name): + """Determine if a given plugin is going to be loaded.""" + return PLUGIN_REGISTRY.is_plugin_enabled(plugin_name) + + def is_plugin_available(self, plugin_name): + """Determine if a given plugin is available.""" + return PLUGIN_REGISTRY.is_plugin_available(plugin_name) + + def show_status_message(self, message, timeout): + """ + Show a status message in Spyder Main Window. + """ + status_bar = self.statusBar() + if status_bar.isVisible(): + status_bar.showMessage(message, timeout) + + def show_plugin_compatibility_message(self, message): + """ + Show a compatibility message. + """ + messageBox = QMessageBox(self) + messageBox.setWindowModality(Qt.NonModal) + messageBox.setAttribute(Qt.WA_DeleteOnClose) + messageBox.setWindowTitle(_('Compatibility Check')) + messageBox.setText(message) + messageBox.setStandardButtons(QMessageBox.Ok) + messageBox.show() + + def register_plugin(self, plugin_name, external=False, omit_conf=False): + """ + Register a plugin in Spyder Main Window. + """ + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + + self.set_splash(_("Loading {}...").format(plugin.get_name())) + logger.info("Loading {}...".format(plugin.NAME)) + + # Check plugin compatibility + is_compatible, message = plugin.check_compatibility() + plugin.is_compatible = is_compatible + plugin.get_description() + + if not is_compatible: + self.show_compatibility_message(message) + return + + # Connect Plugin Signals to main window methods + plugin.sig_exception_occurred.connect(self.handle_exception) + plugin.sig_free_memory_requested.connect(self.free_memory) + plugin.sig_quit_requested.connect(self.close) + plugin.sig_redirect_stdio_requested.connect( + self.redirect_internalshell_stdio) + plugin.sig_status_message_requested.connect(self.show_status_message) + + if isinstance(plugin, SpyderDockablePlugin): + plugin.sig_focus_changed.connect(self.plugin_focus_changed) + plugin.sig_switch_to_plugin_requested.connect( + self.switch_to_plugin) + plugin.sig_update_ancestor_requested.connect( + lambda: plugin.set_ancestor(self)) + + # Connect Main window Signals to plugin signals + self.sig_moved.connect(plugin.sig_mainwindow_moved) + self.sig_resized.connect(plugin.sig_mainwindow_resized) + + # Register plugin + plugin._register(omit_conf=omit_conf) + + if isinstance(plugin, SpyderDockablePlugin): + # Add dockwidget + self.add_dockwidget(plugin) + + # Update margins + margin = 0 + if CONF.get('main', 'use_custom_margin'): + margin = CONF.get('main', 'custom_margin') + plugin.update_margins(margin) + + if plugin_name == Plugins.Shortcuts: + for action, context, action_name in self.shortcut_queue: + self.register_shortcut(action, context, action_name) + self.shortcut_queue = [] + + logger.info("Registering shortcuts for {}...".format(plugin.NAME)) + for action_name, action in plugin.get_actions().items(): + context = (getattr(action, 'shortcut_context', plugin.NAME) + or plugin.NAME) + + if getattr(action, 'register_shortcut', True): + if isinstance(action_name, Enum): + action_name = action_name.value + if Plugins.Shortcuts in PLUGIN_REGISTRY: + self.register_shortcut(action, context, action_name) + else: + self.shortcut_queue.append((action, context, action_name)) + + if isinstance(plugin, SpyderDockablePlugin): + try: + context = '_' + name = 'switch to {}'.format(plugin.CONF_SECTION) + shortcut = CONF.get_shortcut(context, name, + plugin_name=plugin.CONF_SECTION) + except (cp.NoSectionError, cp.NoOptionError): + shortcut = None + + sc = QShortcut(QKeySequence(), self, + lambda: self.switch_to_plugin(plugin)) + sc.setContext(Qt.ApplicationShortcut) + plugin._shortcut = sc + + if Plugins.Shortcuts in PLUGIN_REGISTRY: + self.register_shortcut(sc, context, name) + self.register_shortcut( + plugin.toggle_view_action, context, name) + else: + self.shortcut_queue.append((sc, context, name)) + self.shortcut_queue.append( + (plugin.toggle_view_action, context, name)) + + def unregister_plugin(self, plugin): + """ + Unregister a plugin from the Spyder Main Window. + """ + logger.info("Unloading {}...".format(plugin.NAME)) + + # Disconnect all slots + signals = [ + plugin.sig_quit_requested, + plugin.sig_redirect_stdio_requested, + plugin.sig_status_message_requested, + ] + + for sig in signals: + try: + sig.disconnect() + except TypeError: + pass + + # Unregister shortcuts for actions + logger.info("Unregistering shortcuts for {}...".format(plugin.NAME)) + for action_name, action in plugin.get_actions().items(): + context = (getattr(action, 'shortcut_context', plugin.NAME) + or plugin.NAME) + self.shortcuts.unregister_shortcut(action, context, action_name) + + # Unregister switch to shortcut + shortcut = None + try: + context = '_' + name = 'switch to {}'.format(plugin.CONF_SECTION) + shortcut = CONF.get_shortcut(context, name, + plugin_name=plugin.CONF_SECTION) + except Exception: + pass + + if shortcut is not None: + self.shortcuts.unregister_shortcut( + plugin._shortcut, + context, + "Switch to {}".format(plugin.CONF_SECTION), + ) + + # Remove dockwidget + logger.info("Removing {} dockwidget...".format(plugin.NAME)) + self.remove_dockwidget(plugin) + + plugin._unregister() + + def create_plugin_conf_widget(self, plugin): + """ + Create configuration dialog box page widget. + """ + config_dialog = self.prefs_dialog_instance + if plugin.CONF_WIDGET_CLASS is not None and config_dialog is not None: + conf_widget = plugin.CONF_WIDGET_CLASS(plugin, config_dialog) + conf_widget.initialize() + return conf_widget + + @property + def last_plugin(self): + """ + Get last plugin with focus if it is a dockable widget. + + If a non-dockable plugin has the focus this will return by default + the Editor plugin. + """ + # Needed to prevent errors with the old API at + # spyder/plugins/base::_switch_to_plugin + return self.layouts.get_last_plugin() + + def maximize_dockwidget(self, restore=False): + """ + This is needed to prevent errors with the old API at + spyder/plugins/base::_switch_to_plugin. + + See spyder-ide/spyder#15164 + + Parameters + ---------- + restore : bool, optional + If the current dockwidget needs to be restored to its unmaximized + state. The default is False. + """ + self.layouts.maximize_dockwidget(restore=restore) + + def switch_to_plugin(self, plugin, force_focus=None): + """ + Switch to this plugin. + + Notes + ----- + This operation unmaximizes the current plugin (if any), raises + this plugin to view (if it's hidden) and gives it focus (if + possible). + """ + last_plugin = self.last_plugin + try: + # New API + if (last_plugin is not None + and last_plugin.get_widget().is_maximized + and last_plugin is not plugin): + self.layouts.maximize_dockwidget() + except AttributeError: + # Old API + if (last_plugin is not None and self.last_plugin._ismaximized + and last_plugin is not plugin): + self.layouts.maximize_dockwidget() + + try: + # New API + if not plugin.toggle_view_action.isChecked(): + plugin.toggle_view_action.setChecked(True) + plugin.get_widget().is_visible = False + except AttributeError: + # Old API + if not plugin._toggle_view_action.isChecked(): + plugin._toggle_view_action.setChecked(True) + plugin._widget._is_visible = False + + plugin.change_visibility(True, force_focus=force_focus) + + def remove_dockwidget(self, plugin): + """ + Remove a plugin QDockWidget from the main window. + """ + self.removeDockWidget(plugin.dockwidget) + try: + self.widgetlist.remove(plugin) + except ValueError: + pass + + def tabify_plugins(self, first, second): + """Tabify plugin dockwigdets.""" + self.tabifyDockWidget(first.dockwidget, second.dockwidget) + + def tabify_plugin(self, plugin, default=None): + """ + Tabify the plugin using the list of possible TABIFY options. + + Only do this if the dockwidget does not have more dockwidgets + in the same position and if the plugin is using the New API. + """ + def tabify_helper(plugin, next_to_plugins): + for next_to_plugin in next_to_plugins: + try: + self.tabify_plugins(next_to_plugin, plugin) + break + except SpyderAPIError as err: + logger.error(err) + + # If TABIFY not defined use the [default] + tabify = getattr(plugin, 'TABIFY', [default]) + if not isinstance(tabify, list): + next_to_plugins = [tabify] + else: + next_to_plugins = tabify + + # Check if TABIFY is not a list with None as unique value or a default + # list + if tabify in [[None], []]: + return False + + # Get the actual plugins from the names + next_to_plugins = [self.get_plugin(p) for p in next_to_plugins] + + # First time plugin starts + if plugin.get_conf('first_time', True): + if (isinstance(plugin, SpyderDockablePlugin) + and plugin.NAME != Plugins.Console): + logger.info( + "Tabify {} dockwidget for the first time...".format( + plugin.NAME)) + tabify_helper(plugin, next_to_plugins) + + # Show external plugins + if plugin.NAME in PLUGIN_REGISTRY.external_plugins: + plugin.get_widget().toggle_view(True) + + plugin.set_conf('enable', True) + plugin.set_conf('first_time', False) + else: + # This is needed to ensure plugins are placed correctly when + # switching layouts. + logger.info("Tabify {} dockwidget...".format(plugin.NAME)) + # Check if plugin has no other dockwidgets in the same position + if not bool(self.tabifiedDockWidgets(plugin.dockwidget)): + tabify_helper(plugin, next_to_plugins) + + return True + + def handle_exception(self, error_data): + """ + This method will call the handle exception method of the Console + plugin. It is provided as a signal on the Plugin API for convenience, + so that plugin do not need to explicitly call the Console plugin. + + Parameters + ---------- + error_data: dict + The dictionary containing error data. The expected keys are: + >>> error_data= { + "text": str, + "is_traceback": bool, + "repo": str, + "title": str, + "label": str, + "steps": str, + } + + Notes + ----- + The `is_traceback` key indicates if `text` contains plain text or a + Python error traceback. + + The `title` and `repo` keys indicate how the error data should + customize the report dialog and Github error submission. + + The `label` and `steps` keys allow customizing the content of the + error dialog. + """ + console = self.get_plugin(Plugins.Console, error=False) + if console: + console.handle_exception(error_data) + + def __init__(self, splash=None, options=None): + QMainWindow.__init__(self) + qapp = QApplication.instance() + + if running_under_pytest(): + self._proxy_style = None + else: + from spyder.utils.qthelpers import SpyderProxyStyle + # None is needed, see: https://bugreports.qt.io/browse/PYSIDE-922 + self._proxy_style = SpyderProxyStyle(None) + + # Enabling scaling for high dpi + qapp.setAttribute(Qt.AA_UseHighDpiPixmaps) + + # Set Windows app icon to use .ico file + if os.name == "nt": + qapp.setWindowIcon(ima.get_icon("windows_app_icon")) + + # Set default style + self.default_style = str(qapp.style().objectName()) + + # Save command line options for plugins to access them + self._cli_options = options + + logger.info("Start of MainWindow constructor") + + def signal_handler(signum, frame=None): + """Handler for signals.""" + sys.stdout.write('Handling signal: %s\n' % signum) + sys.stdout.flush() + QApplication.quit() + + if os.name == "nt": + try: + import win32api + win32api.SetConsoleCtrlHandler(signal_handler, True) + except ImportError: + pass + else: + signal.signal(signal.SIGTERM, signal_handler) + if not DEV: + # Make spyder quit when presing ctrl+C in the console + # In DEV Ctrl+C doesn't quit, because it helps to + # capture the traceback when spyder freezes + signal.signal(signal.SIGINT, signal_handler) + + # Use a custom Qt stylesheet + if sys.platform == 'darwin': + spy_path = get_module_source_path('spyder') + img_path = osp.join(spy_path, 'images') + mac_style = open(osp.join(spy_path, 'app', 'mac_stylesheet.qss')).read() + mac_style = mac_style.replace('$IMAGE_PATH', img_path) + self.setStyleSheet(mac_style) + + # Shortcut management data + self.shortcut_data = [] + self.shortcut_queue = [] + + # Handle Spyder path + self.path = () + self.not_active_path = () + self.project_path = () + self._path_manager = None + + # New API + self._APPLICATION_TOOLBARS = OrderedDict() + self._STATUS_WIDGETS = OrderedDict() + # Mapping of new plugin identifiers vs old attributtes + # names given for plugins or to prevent collisions with other + # attributes, i.e layout (Qt) vs layout (SpyderPluginV2) + self._INTERNAL_PLUGINS_MAPPING = { + 'console': Plugins.Console, + 'maininterpreter': Plugins.MainInterpreter, + 'outlineexplorer': Plugins.OutlineExplorer, + 'variableexplorer': Plugins.VariableExplorer, + 'ipyconsole': Plugins.IPythonConsole, + 'workingdirectory': Plugins.WorkingDirectory, + 'projects': Plugins.Projects, + 'findinfiles': Plugins.Find, + 'layouts': Plugins.Layout, + } + + self.thirdparty_plugins = [] + + # File switcher + self.switcher = None + + # Preferences + self.prefs_dialog_size = None + self.prefs_dialog_instance = None + + # Actions + self.undo_action = None + self.redo_action = None + self.copy_action = None + self.cut_action = None + self.paste_action = None + self.selectall_action = None + + # Menu bars + self.edit_menu = None + self.edit_menu_actions = [] + self.search_menu = None + self.search_menu_actions = [] + self.source_menu = None + self.source_menu_actions = [] + self.run_menu = None + self.run_menu_actions = [] + self.debug_menu = None + self.debug_menu_actions = [] + + # TODO: Move to corresponding Plugins + self.main_toolbar = None + self.main_toolbar_actions = [] + self.file_toolbar = None + self.file_toolbar_actions = [] + self.run_toolbar = None + self.run_toolbar_actions = [] + self.debug_toolbar = None + self.debug_toolbar_actions = [] + + self.menus = [] + + if running_under_pytest(): + # Show errors in internal console when testing. + CONF.set('main', 'show_internal_errors', False) + + self.CURSORBLINK_OSDEFAULT = QApplication.cursorFlashTime() + + if set_windows_appusermodelid != None: + res = set_windows_appusermodelid() + logger.info("appusermodelid: %s", res) + + # Setting QTimer if running in travis + test_app = os.environ.get('TEST_CI_APP') + if test_app is not None: + app = qapplication() + timer_shutdown_time = 30000 + self.timer_shutdown = QTimer(self) + self.timer_shutdown.timeout.connect(app.quit) + self.timer_shutdown.start(timer_shutdown_time) + + # Showing splash screen + self.splash = splash + if CONF.get('main', 'current_version', '') != __version__: + CONF.set('main', 'current_version', __version__) + # Execute here the actions to be performed only once after + # each update (there is nothing there for now, but it could + # be useful some day...) + + # List of satellite widgets (registered in add_dockwidget): + self.widgetlist = [] + + # Flags used if closing() is called by the exit() shell command + self.already_closed = False + self.is_starting_up = True + self.is_setting_up = True + + self.window_size = None + self.window_position = None + + # To keep track of the last focused widget + self.last_focused_widget = None + self.previous_focused_widget = None + + # Server to open external files on a single instance + # This is needed in order to handle socket creation problems. + # See spyder-ide/spyder#4132. + if os.name == 'nt': + try: + self.open_files_server = socket.socket(socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP) + except OSError: + self.open_files_server = None + QMessageBox.warning(None, "Spyder", + _("An error occurred while creating a socket needed " + "by Spyder. Please, try to run as an Administrator " + "from cmd.exe the following command and then " + "restart your computer:

netsh winsock reset " + "
").format( + color=QStylePalette.COLOR_BACKGROUND_4)) + else: + self.open_files_server = socket.socket(socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP) + + # Apply main window settings + self.apply_settings() + + # To set all dockwidgets tabs to be on top (in case we want to do it + # in the future) + # self.setTabPosition(Qt.AllDockWidgetAreas, QTabWidget.North) + + logger.info("End of MainWindow constructor") + + # ---- Window setup + def _update_shortcuts_in_panes_menu(self, show=True): + """ + Display the shortcut for the "Switch to plugin..." on the toggle view + action of the plugins displayed in the Help/Panes menu. + + Notes + ----- + SpyderDockablePlugins provide two actions that function as a single + action. The `Switch to Plugin...` action has an assignable shortcut + via the shortcut preferences. The `Plugin toggle View` in the `View` + application menu, uses a custom `Toggle view action` that displays the + shortcut assigned to the `Switch to Plugin...` action, but is not + triggered by that shortcut. + """ + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + if isinstance(plugin, SpyderDockablePlugin): + try: + # New API + action = plugin.toggle_view_action + except AttributeError: + # Old API + action = plugin._toggle_view_action + + if show: + section = plugin.CONF_SECTION + try: + context = '_' + name = 'switch to {}'.format(section) + shortcut = CONF.get_shortcut( + context, name, plugin_name=section) + except (cp.NoSectionError, cp.NoOptionError): + shortcut = QKeySequence() + else: + shortcut = QKeySequence() + + action.setShortcut(shortcut) + + def setup(self): + """Setup main window.""" + PLUGIN_REGISTRY.sig_plugin_ready.connect( + lambda plugin_name, omit_conf: self.register_plugin( + plugin_name, omit_conf=omit_conf)) + + PLUGIN_REGISTRY.set_main(self) + + # TODO: Remove circular dependency between help and ipython console + # and remove this import. Help plugin should take care of it + from spyder.plugins.help.utils.sphinxify import CSS_PATH, DARK_CSS_PATH + logger.info("*** Start of MainWindow setup ***") + logger.info("Updating PYTHONPATH") + path_dict = self.get_spyder_pythonpath_dict() + self.update_python_path(path_dict) + + logger.info("Applying theme configuration...") + ui_theme = CONF.get('appearance', 'ui_theme') + color_scheme = CONF.get('appearance', 'selected') + + if ui_theme == 'dark': + if not running_under_pytest(): + # Set style proxy to fix combobox popup on mac and qdark + qapp = QApplication.instance() + qapp.setStyle(self._proxy_style) + dark_qss = str(APP_STYLESHEET) + self.setStyleSheet(dark_qss) + self.statusBar().setStyleSheet(dark_qss) + css_path = DARK_CSS_PATH + + elif ui_theme == 'light': + if not running_under_pytest(): + # Set style proxy to fix combobox popup on mac and qdark + qapp = QApplication.instance() + qapp.setStyle(self._proxy_style) + light_qss = str(APP_STYLESHEET) + self.setStyleSheet(light_qss) + self.statusBar().setStyleSheet(light_qss) + css_path = CSS_PATH + + elif ui_theme == 'automatic': + if not is_dark_font_color(color_scheme): + if not running_under_pytest(): + # Set style proxy to fix combobox popup on mac and qdark + qapp = QApplication.instance() + qapp.setStyle(self._proxy_style) + dark_qss = str(APP_STYLESHEET) + self.setStyleSheet(dark_qss) + self.statusBar().setStyleSheet(dark_qss) + css_path = DARK_CSS_PATH + else: + light_qss = str(APP_STYLESHEET) + self.setStyleSheet(light_qss) + self.statusBar().setStyleSheet(light_qss) + css_path = CSS_PATH + + # Set css_path as a configuration to be used by the plugins + CONF.set('appearance', 'css_path', css_path) + + # Status bar + status = self.statusBar() + status.setObjectName("StatusBar") + status.showMessage(_("Welcome to Spyder!"), 5000) + + # Switcher instance + logger.info("Loading switcher...") + self.create_switcher() + + # Load and register internal and external plugins + external_plugins = find_external_plugins() + internal_plugins = find_internal_plugins() + all_plugins = external_plugins.copy() + all_plugins.update(internal_plugins.copy()) + + # Determine 'enable' config for the plugins that have it + enabled_plugins = {} + registry_internal_plugins = {} + registry_external_plugins = {} + for plugin in all_plugins.values(): + plugin_name = plugin.NAME + # Disable panes that use web widgets (currently Help and Online + # Help) if the user asks for it. + # See spyder-ide/spyder#16518 + if self._cli_options.no_web_widgets: + if "help" in plugin_name: + continue + plugin_main_attribute_name = ( + self._INTERNAL_PLUGINS_MAPPING[plugin_name] + if plugin_name in self._INTERNAL_PLUGINS_MAPPING + else plugin_name) + if plugin_name in internal_plugins: + registry_internal_plugins[plugin_name] = ( + plugin_main_attribute_name, plugin) + else: + registry_external_plugins[plugin_name] = ( + plugin_main_attribute_name, plugin) + try: + if CONF.get(plugin_main_attribute_name, "enable"): + enabled_plugins[plugin_name] = plugin + PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) + except (cp.NoOptionError, cp.NoSectionError): + enabled_plugins[plugin_name] = plugin + PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) + + PLUGIN_REGISTRY.set_all_internal_plugins(registry_internal_plugins) + PLUGIN_REGISTRY.set_all_external_plugins(registry_external_plugins) + + # Instantiate internal Spyder 5 plugins + for plugin_name in internal_plugins: + if plugin_name in enabled_plugins: + PluginClass = internal_plugins[plugin_name] + if issubclass(PluginClass, SpyderPluginV2): + PLUGIN_REGISTRY.register_plugin(self, PluginClass, + external=False) + + # Instantiate internal Spyder 4 plugins + for plugin_name in internal_plugins: + if plugin_name in enabled_plugins: + PluginClass = internal_plugins[plugin_name] + if issubclass(PluginClass, SpyderPlugin): + plugin_instance = PLUGIN_REGISTRY.register_plugin( + self, PluginClass, external=False) + self.preferences.register_plugin_preferences( + plugin_instance) + + # Instantiate external Spyder 5 plugins + for plugin_name in external_plugins: + if plugin_name in enabled_plugins: + PluginClass = external_plugins[plugin_name] + try: + plugin_instance = PLUGIN_REGISTRY.register_plugin( + self, PluginClass, external=True) + except Exception as error: + print("%s: %s" % (PluginClass, str(error)), file=STDERR) + traceback.print_exc(file=STDERR) + + self.set_splash(_("Loading old third-party plugins...")) + for mod in get_spyderplugins_mods(): + try: + plugin = PLUGIN_REGISTRY.register_plugin(self, mod, + external=True) + if plugin.check_compatibility()[0]: + if hasattr(plugin, 'CONFIGWIDGET_CLASS'): + self.preferences.register_plugin_preferences(plugin) + + if not hasattr(plugin, 'COMPLETION_PROVIDER_NAME'): + self.thirdparty_plugins.append(plugin) + + # Add to dependencies dialog + module = mod.__name__ + name = module.replace('_', '-') + if plugin.DESCRIPTION: + description = plugin.DESCRIPTION + else: + description = plugin.get_plugin_title() + + dependencies.add(module, name, description, + '', None, kind=dependencies.PLUGIN) + except TypeError: + # Fixes spyder-ide/spyder#13977 + pass + except Exception as error: + print("%s: %s" % (mod, str(error)), file=STDERR) + traceback.print_exc(file=STDERR) + + # Set window title + self.set_window_title() + + # Menus + # TODO: Remove when all menus are migrated to use the Main Menu Plugin + logger.info("Creating Menus...") + from spyder.plugins.mainmenu.api import ( + ApplicationMenus, ToolsMenuSections, FileMenuSections) + mainmenu = self.mainmenu + self.edit_menu = mainmenu.get_application_menu("edit_menu") + self.search_menu = mainmenu.get_application_menu("search_menu") + self.source_menu = mainmenu.get_application_menu("source_menu") + self.source_menu.aboutToShow.connect(self.update_source_menu) + self.run_menu = mainmenu.get_application_menu("run_menu") + self.debug_menu = mainmenu.get_application_menu("debug_menu") + + # Switcher shortcuts + self.file_switcher_action = create_action( + self, + _('File switcher...'), + icon=ima.icon('filelist'), + tip=_('Fast switch between files'), + triggered=self.open_switcher, + context=Qt.ApplicationShortcut, + id_='file_switcher') + self.register_shortcut(self.file_switcher_action, context="_", + name="File switcher") + self.symbol_finder_action = create_action( + self, _('Symbol finder...'), + icon=ima.icon('symbol_find'), + tip=_('Fast symbol search in file'), + triggered=self.open_symbolfinder, + context=Qt.ApplicationShortcut, + id_='symbol_finder') + self.register_shortcut(self.symbol_finder_action, context="_", + name="symbol finder", add_shortcut_to_tip=True) + + def create_edit_action(text, tr_text, icon): + textseq = text.split(' ') + method_name = textseq[0].lower()+"".join(textseq[1:]) + action = create_action(self, tr_text, + icon=icon, + triggered=self.global_callback, + data=method_name, + context=Qt.WidgetShortcut) + self.register_shortcut(action, "Editor", text) + return action + + self.undo_action = create_edit_action('Undo', _('Undo'), + ima.icon('undo')) + self.redo_action = create_edit_action('Redo', _('Redo'), + ima.icon('redo')) + self.copy_action = create_edit_action('Copy', _('Copy'), + ima.icon('editcopy')) + self.cut_action = create_edit_action('Cut', _('Cut'), + ima.icon('editcut')) + self.paste_action = create_edit_action('Paste', _('Paste'), + ima.icon('editpaste')) + self.selectall_action = create_edit_action("Select All", + _("Select All"), + ima.icon('selectall')) + + self.edit_menu_actions += [self.undo_action, self.redo_action, + None, self.cut_action, self.copy_action, + self.paste_action, self.selectall_action, + None] + if self.get_plugin(Plugins.Editor, error=False): + self.edit_menu_actions += self.editor.edit_menu_actions + + switcher_actions = [ + self.file_switcher_action, + self.symbol_finder_action + ] + for switcher_action in switcher_actions: + mainmenu.add_item_to_application_menu( + switcher_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Switcher, + before_section=FileMenuSections.Restart) + self.set_splash("") + + # Toolbars + # TODO: Remove after finishing the migration + logger.info("Creating toolbars...") + toolbar = self.toolbar + self.file_toolbar = toolbar.get_application_toolbar("file_toolbar") + self.run_toolbar = toolbar.get_application_toolbar("run_toolbar") + self.debug_toolbar = toolbar.get_application_toolbar("debug_toolbar") + self.main_toolbar = toolbar.get_application_toolbar("main_toolbar") + + # Tools + External Tools (some of this depends on the Application + # plugin) + logger.info("Creating Tools menu...") + + spyder_path_action = create_action( + self, + _("PYTHONPATH manager"), + None, icon=ima.icon('pythonpath'), + triggered=self.show_path_manager, + tip=_("PYTHONPATH manager"), + id_='spyder_path_action') + from spyder.plugins.application.container import ( + ApplicationActions, WinUserEnvDialog) + winenv_action = None + if WinUserEnvDialog: + winenv_action = ApplicationActions.SpyderWindowsEnvVariables + mainmenu.add_item_to_application_menu( + spyder_path_action, + menu_id=ApplicationMenus.Tools, + section=ToolsMenuSections.Tools, + before=winenv_action, + before_section=ToolsMenuSections.External + ) + + # Main toolbar + from spyder.plugins.toolbar.api import ( + ApplicationToolbars, MainToolbarSections) + self.toolbar.add_item_to_application_toolbar( + spyder_path_action, + toolbar_id=ApplicationToolbars.Main, + section=MainToolbarSections.ApplicationSection + ) + + self.set_splash(_("Setting up main window...")) + + # TODO: Migrate to use the MainMenu Plugin instead of list of actions + # Filling out menu/toolbar entries: + add_actions(self.edit_menu, self.edit_menu_actions) + add_actions(self.search_menu, self.search_menu_actions) + add_actions(self.source_menu, self.source_menu_actions) + add_actions(self.run_menu, self.run_menu_actions) + add_actions(self.debug_menu, self.debug_menu_actions) + + # Emitting the signal notifying plugins that main window menu and + # toolbar actions are all defined: + self.all_actions_defined.emit() + + def __getattr__(self, attr): + """ + Redefinition of __getattr__ to enable access to plugins. + + Loaded plugins can be accessed as attributes of the mainwindow + as before, e.g self.console or self.main.console, preserving the + same accessor as before. + """ + # Mapping of new plugin identifiers vs old attributtes + # names given for plugins + try: + if attr in self._INTERNAL_PLUGINS_MAPPING.keys(): + return self.get_plugin( + self._INTERNAL_PLUGINS_MAPPING[attr], error=False) + return self.get_plugin(attr) + except SpyderAPIError: + pass + return super().__getattr__(attr) + + def pre_visible_setup(self): + """ + Actions to be performed before the main window is visible. + + The actions here are related with setting up the main window. + """ + logger.info("Setting up window...") + + for plugin_name in PLUGIN_REGISTRY: + plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) + try: + plugin_instance.before_mainwindow_visible() + except AttributeError: + pass + + # Tabify external plugins which were installed after Spyder was + # installed. + # Note: This is only necessary the first time a plugin is loaded. + # Afterwards, the plugin placement is recorded on the window hexstate, + # which is loaded by the layouts plugin during the next session. + for plugin_name in PLUGIN_REGISTRY.external_plugins: + plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) + if plugin_instance.get_conf('first_time', True): + self.tabify_plugin(plugin_instance, Plugins.Console) + + if self.splash is not None: + self.splash.hide() + + # Menu about to show + for child in self.menuBar().children(): + if isinstance(child, QMenu): + try: + child.aboutToShow.connect(self.update_edit_menu) + child.aboutToShow.connect(self.update_search_menu) + except TypeError: + pass + + # Register custom layouts + for plugin_name in PLUGIN_REGISTRY.external_plugins: + plugin_instance = PLUGIN_REGISTRY.get_plugin(plugin_name) + if hasattr(plugin_instance, 'CUSTOM_LAYOUTS'): + if isinstance(plugin_instance.CUSTOM_LAYOUTS, list): + for custom_layout in plugin_instance.CUSTOM_LAYOUTS: + self.layouts.register_layout( + self, custom_layout) + else: + logger.info( + 'Unable to load custom layouts for {}. ' + 'Expecting a list of layout classes but got {}' + .format(plugin_name, plugin_instance.CUSTOM_LAYOUTS) + ) + + # Needed to ensure dockwidgets/panes layout size distribution + # when a layout state is already present. + # See spyder-ide/spyder#17945 + if self.layouts is not None and CONF.get('main', 'window/state', None): + self.layouts.before_mainwindow_visible() + + logger.info("*** End of MainWindow setup ***") + self.is_starting_up = False + + def post_visible_setup(self): + """ + Actions to be performed only after the main window's `show` method + is triggered. + """ + # Process pending events and hide splash before loading the + # previous session. + QApplication.processEvents() + if self.splash is not None: + self.splash.hide() + + # Call on_mainwindow_visible for all plugins. + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + try: + plugin.on_mainwindow_visible() + QApplication.processEvents() + except AttributeError: + pass + + self.restore_scrollbar_position.emit() + + # Server to maintain just one Spyder instance and open files in it if + # the user tries to start other instances with + # $ spyder foo.py + if ( + CONF.get('main', 'single_instance') and + not self._cli_options.new_instance and + self.open_files_server + ): + t = threading.Thread(target=self.start_open_files_server) + t.daemon = True + t.start() + + # Connect the window to the signal emitted by the previous server + # when it gets a client connected to it + self.sig_open_external_file.connect(self.open_external_file) + + # Update plugins toggle actions to show the "Switch to" plugin shortcut + self._update_shortcuts_in_panes_menu() + + # Reopen last session if no project is active + # NOTE: This needs to be after the calls to on_mainwindow_visible + self.reopen_last_session() + + # Raise the menuBar to the top of the main window widget's stack + # Fixes spyder-ide/spyder#3887. + self.menuBar().raise_() + + # To avoid regressions. We shouldn't have loaded the modules + # below at this point. + if DEV is not None: + assert 'pandas' not in sys.modules + assert 'matplotlib' not in sys.modules + + # Restore undocked plugins + self.restore_undocked_plugins() + + # Notify that the setup of the mainwindow was finished + self.is_setting_up = False + self.sig_setup_finished.emit() + + def reopen_last_session(self): + """ + Reopen last session if no project is active. + + This can't be moved to on_mainwindow_visible in the editor because we + need to let the same method on Projects run first. + """ + projects = self.get_plugin(Plugins.Projects, error=False) + editor = self.get_plugin(Plugins.Editor, error=False) + reopen_last_session = False + + if projects: + if projects.get_active_project() is None: + reopen_last_session = True + else: + reopen_last_session = True + + if editor and reopen_last_session: + editor.setup_open_files(close_previous_files=False) + + def restore_undocked_plugins(self): + """Restore plugins that were undocked in the previous session.""" + logger.info("Restoring undocked plugins from the previous session") + + for plugin_name in PLUGIN_REGISTRY: + plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) + if isinstance(plugin, SpyderDockablePlugin): + if plugin.get_conf('undocked_on_window_close', default=False): + plugin.get_widget().create_window() + elif isinstance(plugin, SpyderPluginWidget): + if plugin.get_option('undocked_on_window_close', + default=False): + plugin._create_window() + + def set_window_title(self): + """Set window title.""" + if DEV is not None: + title = u"Spyder %s (Python %s.%s)" % (__version__, + sys.version_info[0], + sys.version_info[1]) + elif running_in_mac_app() or is_pynsist(): + title = "Spyder" + else: + title = u"Spyder (Python %s.%s)" % (sys.version_info[0], + sys.version_info[1]) + + if get_debug_level(): + title += u" [DEBUG MODE %d]" % get_debug_level() + + window_title = self._cli_options.window_title + if window_title is not None: + title += u' -- ' + to_text_string(window_title) + + # TODO: Remove self.projects reference once there's an API for setting + # window title. + projects = self.get_plugin(Plugins.Projects, error=False) + if projects: + path = projects.get_active_project_path() + if path: + path = path.replace(get_home_dir(), u'~') + title = u'{0} - {1}'.format(path, title) + + self.base_title = title + self.setWindowTitle(self.base_title) + + # TODO: To be removed after all actions are moved to their corresponding + # plugins + def register_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=True, plugin_name=None): + shortcuts = self.get_plugin(Plugins.Shortcuts, error=False) + if shortcuts: + shortcuts.register_shortcut( + qaction_or_qshortcut, + context, + name, + add_shortcut_to_tip=add_shortcut_to_tip, + plugin_name=plugin_name, + ) + + # --- Other + def update_source_menu(self): + """Update source menu options that vary dynamically.""" + # This is necessary to avoid an error at startup. + # Fixes spyder-ide/spyder#14901 + try: + editor = self.get_plugin(Plugins.Editor, error=False) + if editor: + editor.refresh_formatter_name() + except AttributeError: + pass + + def free_memory(self): + """Free memory after event.""" + gc.collect() + + def plugin_focus_changed(self): + """Focus has changed from one plugin to another""" + self.update_edit_menu() + self.update_search_menu() + + def show_shortcuts(self, menu): + """Show action shortcuts in menu.""" + menu_actions = menu.actions() + for action in menu_actions: + if getattr(action, '_shown_shortcut', False): + # This is a SpyderAction + if action._shown_shortcut is not None: + action.setShortcut(action._shown_shortcut) + elif action.menu() is not None: + # This is submenu, so we need to call this again + self.show_shortcuts(action.menu()) + else: + # We don't need to do anything for other elements + continue + + def hide_shortcuts(self, menu): + """Hide action shortcuts in menu.""" + menu_actions = menu.actions() + for action in menu_actions: + if getattr(action, '_shown_shortcut', False): + # This is a SpyderAction + if action._shown_shortcut is not None: + action.setShortcut(QKeySequence()) + elif action.menu() is not None: + # This is submenu, so we need to call this again + self.hide_shortcuts(action.menu()) + else: + # We don't need to do anything for other elements + continue + + def hide_options_menus(self): + """Hide options menu when menubar is pressed in macOS.""" + for plugin in self.widgetlist + self.thirdparty_plugins: + if plugin.CONF_SECTION == 'editor': + editorstack = self.editor.get_current_editorstack() + editorstack.menu.hide() + else: + try: + # New API + plugin.options_menu.hide() + except AttributeError: + # Old API + plugin._options_menu.hide() + + def get_focus_widget_properties(self): + """Get properties of focus widget + Returns tuple (widget, properties) where properties is a tuple of + booleans: (is_console, not_readonly, readwrite_editor)""" + from spyder.plugins.editor.widgets.base import TextEditBaseWidget + from spyder.plugins.ipythonconsole.widgets import ControlWidget + widget = QApplication.focusWidget() + + textedit_properties = None + if isinstance(widget, (TextEditBaseWidget, ControlWidget)): + console = isinstance(widget, ControlWidget) + not_readonly = not widget.isReadOnly() + readwrite_editor = not_readonly and not console + textedit_properties = (console, not_readonly, readwrite_editor) + return widget, textedit_properties + + def update_edit_menu(self): + """Update edit menu""" + widget, textedit_properties = self.get_focus_widget_properties() + if textedit_properties is None: # widget is not an editor/console + return + # !!! Below this line, widget is expected to be a QPlainTextEdit + # instance + console, not_readonly, readwrite_editor = textedit_properties + + if hasattr(self, 'editor'): + # Editor has focus and there is no file opened in it + if (not console and not_readonly and self.editor + and not self.editor.is_file_opened()): + return + + # Disabling all actions to begin with + for child in self.edit_menu.actions(): + child.setEnabled(False) + + self.selectall_action.setEnabled(True) + + # Undo, redo + self.undo_action.setEnabled( readwrite_editor \ + and widget.document().isUndoAvailable() ) + self.redo_action.setEnabled( readwrite_editor \ + and widget.document().isRedoAvailable() ) + + # Copy, cut, paste, delete + has_selection = widget.has_selected_text() + self.copy_action.setEnabled(has_selection) + self.cut_action.setEnabled(has_selection and not_readonly) + self.paste_action.setEnabled(not_readonly) + + # Comment, uncomment, indent, unindent... + if not console and not_readonly: + # This is the editor and current file is writable + if self.get_plugin(Plugins.Editor, error=False): + for action in self.editor.edit_menu_actions: + action.setEnabled(True) + + def update_search_menu(self): + """Update search menu""" + # Disabling all actions except the last one + # (which is Find in files) to begin with + for child in self.search_menu.actions()[:-1]: + child.setEnabled(False) + + widget, textedit_properties = self.get_focus_widget_properties() + if textedit_properties is None: # widget is not an editor/console + return + + # !!! Below this line, widget is expected to be a QPlainTextEdit + # instance + console, not_readonly, readwrite_editor = textedit_properties + + # Find actions only trigger an effect in the Editor + if not console: + for action in self.search_menu.actions(): + try: + action.setEnabled(True) + except RuntimeError: + pass + + # Disable the replace action for read-only files + if len(self.search_menu_actions) > 3: + self.search_menu_actions[3].setEnabled(readwrite_editor) + + def createPopupMenu(self): + return self.application.get_application_context_menu(parent=self) + + def set_splash(self, message): + """Set splash message""" + if self.splash is None: + return + if message: + logger.info(message) + self.splash.show() + self.splash.showMessage(message, + int(Qt.AlignBottom | Qt.AlignCenter | + Qt.AlignAbsolute), + QColor(Qt.white)) + QApplication.processEvents() + + def closeEvent(self, event): + """closeEvent reimplementation""" + if self.closing(True): + event.accept() + else: + event.ignore() + + def resizeEvent(self, event): + """Reimplement Qt method""" + if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): + self.window_size = self.size() + QMainWindow.resizeEvent(self, event) + + # To be used by the tour to be able to resize + self.sig_resized.emit(event) + + def moveEvent(self, event): + """Reimplement Qt method""" + if hasattr(self, 'layouts'): + if not self.isMaximized() and not self.layouts.get_fullscreen_flag(): + self.window_position = self.pos() + QMainWindow.moveEvent(self, event) + # To be used by the tour to be able to move + self.sig_moved.emit(event) + + def hideEvent(self, event): + """Reimplement Qt method""" + try: + for plugin in (self.widgetlist + self.thirdparty_plugins): + # TODO: Remove old API + try: + # New API + if plugin.get_widget().isAncestorOf( + self.last_focused_widget): + plugin.change_visibility(True) + except AttributeError: + # Old API + if plugin.isAncestorOf(self.last_focused_widget): + plugin._visibility_changed(True) + + QMainWindow.hideEvent(self, event) + except RuntimeError: + QMainWindow.hideEvent(self, event) + + def change_last_focused_widget(self, old, now): + """To keep track of to the last focused widget""" + if (now is None and QApplication.activeWindow() is not None): + QApplication.activeWindow().setFocus() + self.last_focused_widget = QApplication.focusWidget() + elif now is not None: + self.last_focused_widget = now + + self.previous_focused_widget = old + + def closing(self, cancelable=False, close_immediately=False): + """Exit tasks""" + if self.already_closed or self.is_starting_up: + return True + + self.plugin_registry = PLUGIN_REGISTRY + + if cancelable and CONF.get('main', 'prompt_on_exit'): + reply = QMessageBox.critical(self, 'Spyder', + 'Do you really want to exit?', + QMessageBox.Yes, QMessageBox.No) + if reply == QMessageBox.No: + return False + + can_close = self.plugin_registry.delete_all_plugins( + excluding={Plugins.Layout}, + close_immediately=close_immediately) + + if not can_close and not close_immediately: + return False + + # Save window settings *after* closing all plugin windows, in order + # to show them in their previous locations in the next session. + # Fixes spyder-ide/spyder#12139 + prefix = 'window' + '/' + if self.layouts is not None: + self.layouts.save_current_window_settings(prefix) + try: + layouts_container = self.layouts.get_container() + if layouts_container: + layouts_container.close() + layouts_container.deleteLater() + self.layouts.deleteLater() + self.plugin_registry.delete_plugin( + Plugins.Layout, teardown=False) + except RuntimeError: + pass + + self.already_closed = True + + if CONF.get('main', 'single_instance') and self.open_files_server: + self.open_files_server.close() + + QApplication.processEvents() + + return True + + def add_dockwidget(self, plugin): + """ + Add a plugin QDockWidget to the main window. + """ + try: + # New API + if plugin.is_compatible: + dockwidget, location = plugin.create_dockwidget(self) + self.addDockWidget(location, dockwidget) + self.widgetlist.append(plugin) + except AttributeError: + # Old API + if plugin._is_compatible: + dockwidget, location = plugin._create_dockwidget() + self.addDockWidget(location, dockwidget) + self.widgetlist.append(plugin) + + def global_callback(self): + """Global callback""" + widget = QApplication.focusWidget() + action = self.sender() + callback = from_qvariant(action.data(), to_text_string) + from spyder.plugins.editor.widgets.base import TextEditBaseWidget + from spyder.plugins.ipythonconsole.widgets import ControlWidget + + if isinstance(widget, (TextEditBaseWidget, ControlWidget)): + getattr(widget, callback)() + else: + return + + def redirect_internalshell_stdio(self, state): + console = self.get_plugin(Plugins.Console, error=False) + if console: + if state: + console.redirect_stds() + else: + console.restore_stds() + + def open_external_console(self, fname, wdir, args, interact, debug, python, + python_args, systerm, post_mortem=False): + """Open external console""" + if systerm: + # Running script in an external system terminal + try: + if CONF.get('main_interpreter', 'default'): + executable = get_python_executable() + else: + executable = CONF.get('main_interpreter', 'executable') + pypath = CONF.get('main', 'spyder_pythonpath', None) + programs.run_python_script_in_terminal( + fname, wdir, args, interact, debug, python_args, + executable, pypath) + except NotImplementedError: + QMessageBox.critical(self, _("Run"), + _("Running an external system terminal " + "is not supported on platform %s." + ) % os.name) + + def open_file(self, fname, external=False): + """ + Open filename with the appropriate application + Redirect to the right widget (txt -> editor, spydata -> workspace, ...) + or open file outside Spyder (if extension is not supported) + """ + fname = to_text_string(fname) + ext = osp.splitext(fname)[1] + editor = self.get_plugin(Plugins.Editor, error=False) + variableexplorer = self.get_plugin( + Plugins.VariableExplorer, error=False) + + if encoding.is_text_file(fname): + if editor: + editor.load(fname) + elif variableexplorer is not None and ext in IMPORT_EXT: + variableexplorer.get_widget().import_data(fname) + elif not external: + fname = file_uri(fname) + start_file(fname) + + def get_initial_working_directory(self): + """Return the initial working directory.""" + return self.INITIAL_CWD + + def open_external_file(self, fname): + """ + Open external files that can be handled either by the Editor or the + variable explorer inside Spyder. + """ + # Check that file exists + fname = encoding.to_unicode_from_fs(fname) + initial_cwd = self.get_initial_working_directory() + if osp.exists(osp.join(initial_cwd, fname)): + fpath = osp.join(initial_cwd, fname) + elif osp.exists(fname): + fpath = fname + else: + return + + # Don't open script that starts Spyder at startup. + # Fixes issue spyder-ide/spyder#14483 + if sys.platform == 'darwin' and 'bin/spyder' in fname: + return + + if osp.isfile(fpath): + self.open_file(fpath, external=True) + elif osp.isdir(fpath): + QMessageBox.warning( + self, _("Error"), + _('To open {fpath} as a project with Spyder, ' + 'please use spyder -p "{fname}".') + .format(fpath=osp.normpath(fpath), fname=fname) + ) + + # --- Path Manager + # ------------------------------------------------------------------------ + def load_python_path(self): + """Load path stored in Spyder configuration folder.""" + if osp.isfile(self.SPYDER_PATH): + with open(self.SPYDER_PATH, 'r', encoding='utf-8') as f: + path = f.read().splitlines() + self.path = tuple(name for name in path if osp.isdir(name)) + + if osp.isfile(self.SPYDER_NOT_ACTIVE_PATH): + with open(self.SPYDER_NOT_ACTIVE_PATH, 'r', + encoding='utf-8') as f: + not_active_path = f.read().splitlines() + self.not_active_path = tuple(name for name in not_active_path + if osp.isdir(name)) + + def save_python_path(self, new_path_dict): + """ + Save path in Spyder configuration folder. + + `new_path_dict` is an OrderedDict that has the new paths as keys and + the state as values. The state is `True` for active and `False` for + inactive. + """ + path = [p for p in new_path_dict] + not_active_path = [p for p in new_path_dict if not new_path_dict[p]] + try: + encoding.writelines(path, self.SPYDER_PATH) + encoding.writelines(not_active_path, self.SPYDER_NOT_ACTIVE_PATH) + except EnvironmentError as e: + logger.error(str(e)) + CONF.set('main', 'spyder_pythonpath', self.get_spyder_pythonpath()) + + def get_spyder_pythonpath_dict(self): + """ + Return Spyder PYTHONPATH. + + The returned ordered dictionary has the paths as keys and the state + as values. The state is `True` for active and `False` for inactive. + + Example: + OrderedDict([('/some/path, True), ('/some/other/path, False)]) + """ + self.load_python_path() + + path_dict = OrderedDict() + for path in self.path: + path_dict[path] = path not in self.not_active_path + + for path in self.project_path: + path_dict[path] = True + + return path_dict + + def get_spyder_pythonpath(self): + """ + Return Spyder PYTHONPATH. + """ + path_dict = self.get_spyder_pythonpath_dict() + path = [k for k, v in path_dict.items() if v] + return path + + def update_python_path(self, new_path_dict): + """Update python path on Spyder interpreter and kernels.""" + # Load previous path + path_dict = self.get_spyder_pythonpath_dict() + + # Save path + if path_dict != new_path_dict: + # It doesn't include the project_path + self.save_python_path(new_path_dict) + + # Load new path + new_path_dict_p = self.get_spyder_pythonpath_dict() # Includes project + + # Any plugin that needs to do some work based on this signal should + # connect to it on plugin registration + self.sig_pythonpath_changed.emit(path_dict, new_path_dict_p) + + @Slot() + def show_path_manager(self): + """Show path manager dialog.""" + def _dialog_finished(result_code): + """Restore path manager dialog instance variable.""" + self._path_manager = None + + if self._path_manager is None: + from spyder.widgets.pathmanager import PathManager + projects = self.get_plugin(Plugins.Projects, error=False) + read_only_path = () + if projects: + read_only_path = tuple(projects.get_pythonpath()) + + dialog = PathManager(self, self.path, read_only_path, + self.not_active_path, sync=True) + self._path_manager = dialog + dialog.sig_path_changed.connect(self.update_python_path) + dialog.redirect_stdio.connect(self.redirect_internalshell_stdio) + dialog.finished.connect(_dialog_finished) + dialog.show() + else: + self._path_manager.show() + self._path_manager.activateWindow() + self._path_manager.raise_() + self._path_manager.setFocus() + + def pythonpath_changed(self): + """Project's PYTHONPATH contribution has changed.""" + projects = self.get_plugin(Plugins.Projects, error=False) + + self.project_path = () + if projects: + self.project_path = tuple(projects.get_pythonpath()) + path_dict = self.get_spyder_pythonpath_dict() + self.update_python_path(path_dict) + + #---- Preferences + def apply_settings(self): + """Apply main window settings.""" + qapp = QApplication.instance() + + # Set 'gtk+' as the default theme in Gtk-based desktops + # Fixes spyder-ide/spyder#2036. + if is_gtk_desktop() and ('GTK+' in QStyleFactory.keys()): + try: + qapp.setStyle('gtk+') + except: + pass + + default = self.DOCKOPTIONS + if CONF.get('main', 'vertical_tabs'): + default = default|QMainWindow.VerticalTabs + self.setDockOptions(default) + + self.apply_panes_settings() + + if CONF.get('main', 'use_custom_cursor_blinking'): + qapp.setCursorFlashTime( + CONF.get('main', 'custom_cursor_blinking')) + else: + qapp.setCursorFlashTime(self.CURSORBLINK_OSDEFAULT) + + def apply_panes_settings(self): + """Update dockwidgets features settings.""" + for plugin in (self.widgetlist + self.thirdparty_plugins): + features = plugin.dockwidget.FEATURES + + plugin.dockwidget.setFeatures(features) + + try: + # New API + margin = 0 + if CONF.get('main', 'use_custom_margin'): + margin = CONF.get('main', 'custom_margin') + plugin.update_margins(margin) + except AttributeError: + # Old API + plugin._update_margins() + + @Slot() + def show_preferences(self): + """Edit Spyder preferences.""" + self.preferences.open_dialog(self.prefs_dialog_size) + + def set_prefs_size(self, size): + """Save preferences dialog size.""" + self.prefs_dialog_size = size + + # ---- Open files server + def start_open_files_server(self): + self.open_files_server.setsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR, 1) + port = select_port(default_port=OPEN_FILES_PORT) + CONF.set('main', 'open_files_port', port) + + # This is necessary in case it's not possible to bind a port for the + # server in the system. + # Fixes spyder-ide/spyder#18262 + try: + self.open_files_server.bind(('127.0.0.1', port)) + except OSError: + self.open_files_server = None + return + + # Number of petitions the server can queue + self.open_files_server.listen(20) + + while 1: # 1 is faster than True + try: + req, dummy = self.open_files_server.accept() + except socket.error as e: + # See spyder-ide/spyder#1275 for details on why errno EINTR is + # silently ignored here. + eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR + # To avoid a traceback after closing on Windows + if e.args[0] == eintr: + continue + # handle a connection abort on close error + enotsock = (errno.WSAENOTSOCK if os.name == 'nt' + else errno.ENOTSOCK) + if e.args[0] in [errno.ECONNABORTED, enotsock]: + return + if self.already_closed: + return + raise + fname = req.recv(1024) + fname = fname.decode('utf-8') + self.sig_open_external_file.emit(fname) + req.sendall(b' ') + + # ---- Quit and restart, and reset spyder defaults + @Slot() + def reset_spyder(self): + """ + Quit and reset Spyder and then Restart application. + """ + answer = QMessageBox.warning(self, _("Warning"), + _("Spyder will restart and reset to default settings:

" + "Do you want to continue?"), + QMessageBox.Yes | QMessageBox.No) + if answer == QMessageBox.Yes: + self.restart(reset=True) + + @Slot() + def restart(self, reset=False, close_immediately=False): + """Wrapper to handle plugins request to restart Spyder.""" + self.application.restart( + reset=reset, close_immediately=close_immediately) + + # ---- Global Switcher + def open_switcher(self, symbol=False): + """Open switcher dialog box.""" + if self.switcher is not None and self.switcher.isVisible(): + self.switcher.clear() + self.switcher.hide() + return + if symbol: + self.switcher.set_search_text('@') + else: + self.switcher.set_search_text('') + self.switcher.setup() + self.switcher.show() + + # Note: The +6 pixel on the top makes it look better + # FIXME: Why is this using the toolbars menu? A: To not be on top of + # the toolbars. + # Probably toolbars should be taken into account for this 'delta' only + # when are visible + delta_top = (self.toolbar.toolbars_menu.geometry().height() + + self.menuBar().geometry().height() + 6) + + self.switcher.set_position(delta_top) + + def open_symbolfinder(self): + """Open symbol list management dialog box.""" + self.open_switcher(symbol=True) + + def create_switcher(self): + """Create switcher dialog instance.""" + if self.switcher is None: + from spyder.widgets.switcher import Switcher + self.switcher = Switcher(self) + + return self.switcher + + # --- For OpenGL + def _test_setting_opengl(self, option): + """Get the current OpenGL implementation in use""" + if option == 'software': + return QCoreApplication.testAttribute(Qt.AA_UseSoftwareOpenGL) + elif option == 'desktop': + return QCoreApplication.testAttribute(Qt.AA_UseDesktopOpenGL) + elif option == 'gles': + return QCoreApplication.testAttribute(Qt.AA_UseOpenGLES) + + +#============================================================================== +# Main +#============================================================================== +def main(options, args): + """Main function""" + # **** For Pytest **** + if running_under_pytest(): + if CONF.get('main', 'opengl') != 'automatic': + option = CONF.get('main', 'opengl') + set_opengl_implementation(option) + + app = create_application() + window = create_window(MainWindow, app, None, options, None) + return window + + # **** Handle hide_console option **** + if options.show_console: + print("(Deprecated) --show console does nothing, now the default " + " behavior is to show the console, use --hide-console if you " + "want to hide it") + + if set_attached_console_visible is not None: + set_attached_console_visible(not options.hide_console + or options.reset_config_files + or options.reset_to_defaults + or options.optimize + or bool(get_debug_level())) + + # **** Set OpenGL implementation to use **** + # This attribute must be set before creating the application. + # See spyder-ide/spyder#11227 + if options.opengl_implementation: + option = options.opengl_implementation + set_opengl_implementation(option) + else: + if CONF.get('main', 'opengl') != 'automatic': + option = CONF.get('main', 'opengl') + set_opengl_implementation(option) + + # **** Set high DPI scaling **** + # This attribute must be set before creating the application. + if hasattr(Qt, 'AA_EnableHighDpiScaling'): + QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, + CONF.get('main', 'high_dpi_scaling')) + + # **** Set debugging info **** + if get_debug_level() > 0: + delete_debug_log_files() + setup_logging(options) + + # **** Create the application **** + app = create_application() + + # **** Create splash screen **** + splash = create_splash_screen() + if splash is not None: + splash.show() + splash.showMessage( + _("Initializing..."), + int(Qt.AlignBottom | Qt.AlignCenter | Qt.AlignAbsolute), + QColor(Qt.white) + ) + QApplication.processEvents() + + if options.reset_to_defaults: + # Reset Spyder settings to defaults + CONF.reset_to_defaults() + return + elif options.optimize: + # Optimize the whole Spyder's source code directory + import spyder + programs.run_python_script(module="compileall", + args=[spyder.__path__[0]], p_args=['-O']) + return + + # **** Read faulthandler log file **** + faulthandler_file = get_conf_path('faulthandler.log') + previous_crash = '' + if osp.exists(faulthandler_file): + with open(faulthandler_file, 'r') as f: + previous_crash = f.read() + + # Remove file to not pick it up for next time. + try: + dst = get_conf_path('faulthandler.log.old') + shutil.move(faulthandler_file, dst) + except Exception: + pass + CONF.set('main', 'previous_crash', previous_crash) + + # **** Set color for links **** + set_links_color(app) + + # **** Create main window **** + mainwindow = None + try: + if PY3 and options.report_segfault: + import faulthandler + with open(faulthandler_file, 'w') as f: + faulthandler.enable(file=f) + mainwindow = create_window( + MainWindow, app, splash, options, args + ) + else: + mainwindow = create_window(MainWindow, app, splash, options, args) + except FontError: + QMessageBox.information(None, "Spyder", + "Spyder was unable to load the Spyder 3 " + "icon theme. That's why it's going to fallback to the " + "theme used in Spyder 2.

" + "For that, please close this window and start Spyder again.") + CONF.set('appearance', 'icon_theme', 'spyder 2') + if mainwindow is None: + # An exception occurred + if splash is not None: + splash.hide() + return + + ORIGINAL_SYS_EXIT() + + +if __name__ == "__main__": + main() diff --git a/spyder/app/restart.py b/spyder/app/restart.py index 524e96b8ad8..b85dd03cb1e 100644 --- a/spyder/app/restart.py +++ b/spyder/app/restart.py @@ -1,301 +1,301 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Restart Spyder - -A helper script that allows to restart (and also reset) Spyder from within the -running application. -""" - -# Standard library imports -import ast -import os -import os.path as osp -import subprocess -import sys -import time - -# Third party imports -from qtpy.QtCore import Qt, QTimer -from qtpy.QtGui import QColor, QIcon -from qtpy.QtWidgets import QApplication, QMessageBox, QWidget - -# Local imports -from spyder.app.utils import create_splash_screen -from spyder.config.base import _, running_in_mac_app -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.encoding import to_unicode -from spyder.utils.qthelpers import qapplication -from spyder.config.manager import CONF - - -PY2 = sys.version[0] == '2' -IS_WINDOWS = os.name == 'nt' -SLEEP_TIME = 0.2 # Seconds for throttling control -CLOSE_ERROR, RESET_ERROR, RESTART_ERROR = [1, 2, 3] # Spyder error codes - - -def _is_pid_running_on_windows(pid): - """Check if a process is running on windows systems based on the pid.""" - pid = str(pid) - - # Hide flashing command prompt - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - - process = subprocess.Popen(r'tasklist /fi "PID eq {0}"'.format(pid), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - startupinfo=startupinfo) - stdoutdata, stderrdata = process.communicate() - stdoutdata = to_unicode(stdoutdata) - process.kill() - check = pid in stdoutdata - - return check - - -def _is_pid_running_on_unix(pid): - """Check if a process is running on unix systems based on the pid.""" - try: - # On unix systems os.kill with a 0 as second argument only pokes the - # process (if it exists) and does not kill it - os.kill(pid, 0) - except OSError: - return False - else: - return True - - -def is_pid_running(pid): - """Check if a process is running based on the pid.""" - # Select the correct function depending on the OS - if os.name == 'nt': - return _is_pid_running_on_windows(pid) - else: - return _is_pid_running_on_unix(pid) - - -class Restarter(QWidget): - """Widget in charge of displaying the splash information screen and the - error messages. - """ - - def __init__(self): - super(Restarter, self).__init__() - self.ellipsis = ['', '.', '..', '...', '..', '.'] - - # Widgets - self.timer_ellipsis = QTimer(self) - self.splash = create_splash_screen() - - # Widget setup - self.setVisible(False) - self.splash.show() - - self.timer_ellipsis.timeout.connect(self.animate_ellipsis) - - def _show_message(self, text): - """Show message on splash screen.""" - self.splash.showMessage(text, - int(Qt.AlignBottom | Qt.AlignCenter | - Qt.AlignAbsolute), - QColor(Qt.white)) - - def animate_ellipsis(self): - """Animate dots at the end of the splash screen message.""" - ellipsis = self.ellipsis.pop(0) - text = ' ' * len(ellipsis) + self.splash_text + ellipsis - self.ellipsis.append(ellipsis) - self._show_message(text) - - def set_splash_message(self, text): - """Sets the text in the bottom of the Splash screen.""" - self.splash_text = text - self._show_message(text) - self.timer_ellipsis.start(500) - - # Wait 1.2 seconds so we can give feedback to users that a - # restart is happening. - for __ in range(40): - time.sleep(0.03) - QApplication.processEvents() - - def launch_error_message(self, error_type, error=None): - """Launch a message box with a predefined error message. - - Parameters - ---------- - error_type : int [CLOSE_ERROR, RESET_ERROR, RESTART_ERROR] - Possible error codes when restarting/resetting spyder. - error : Exception - Actual Python exception error caught. - """ - messages = {CLOSE_ERROR: _("It was not possible to close the previous " - "Spyder instance.\nRestart aborted."), - RESET_ERROR: _("Spyder could not reset to factory " - "defaults.\nRestart aborted."), - RESTART_ERROR: _("It was not possible to restart Spyder.\n" - "Operation aborted.")} - titles = {CLOSE_ERROR: _("Spyder exit error"), - RESET_ERROR: _("Spyder reset error"), - RESTART_ERROR: _("Spyder restart error")} - - if error: - e = error.__repr__() - message = messages[error_type] + "\n\n{0}".format(e) - else: - message = messages[error_type] - - title = titles[error_type] - self.splash.hide() - QMessageBox.warning(self, title, message, QMessageBox.Ok) - raise RuntimeError(message) - - -def main(): - #========================================================================== - # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must - # be set before creating the application. - #========================================================================== - env = os.environ.copy() - - if CONF.get('main', 'high_dpi_custom_scale_factor'): - factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) - f = list(filter(None, factors.split(';'))) - if len(f) == 1: - env['QT_SCALE_FACTOR'] = f[0] - else: - env['QT_SCREEN_SCALE_FACTORS'] = factors - else: - env['QT_SCALE_FACTOR'] = '' - env['QT_SCREEN_SCALE_FACTORS'] = '' - - # Splash screen - # ------------------------------------------------------------------------- - # Start Qt Splash to inform the user of the current status - app = qapplication() - restarter = Restarter() - - APP_ICON = QIcon(get_image_path("spyder")) - app.setWindowIcon(APP_ICON) - restarter.set_splash_message(_('Closing Spyder')) - - # Get variables - spyder_args = env.pop('SPYDER_ARGS', None) - pid = env.pop('SPYDER_PID', None) - is_bootstrap = env.pop('SPYDER_IS_BOOTSTRAP', None) - reset = env.pop('SPYDER_RESET', 'False') - - # Get the spyder base folder based on this file - spyder_dir = osp.dirname(osp.dirname(osp.dirname(osp.abspath(__file__)))) - - if not any([spyder_args, pid, is_bootstrap, reset]): - error = "This script can only be called from within a Spyder instance" - raise RuntimeError(error) - - # Variables were stored as string literals in the environment, so to use - # them we need to parse them in a safe manner. - is_bootstrap = ast.literal_eval(is_bootstrap) - pid = ast.literal_eval(pid) - args = ast.literal_eval(spyder_args) - reset = ast.literal_eval(reset) - - # SPYDER_DEBUG takes presedence over SPYDER_ARGS - if '--debug' in args: - args.remove('--debug') - for level in ['minimal', 'verbose']: - arg = f'--debug-info={level}' - if arg in args: - args.remove(arg) - - # Enforce the --new-instance flag when running spyder - if '--new-instance' not in args: - if is_bootstrap and '--' not in args: - args = args + ['--', '--new-instance'] - else: - args.append('--new-instance') - - # Create the arguments needed for resetting - if '--' in args: - args_reset = ['--', '--reset'] - else: - args_reset = ['--reset'] - - # Build the base command - if running_in_mac_app(sys.executable): - exe = env['EXECUTABLEPATH'] - command = [f'"{exe}"'] - else: - if is_bootstrap: - script = osp.join(spyder_dir, 'bootstrap.py') - else: - script = osp.join(spyder_dir, 'spyder', 'app', 'start.py') - - command = [f'"{sys.executable}"', f'"{script}"'] - - # Adjust the command and/or arguments to subprocess depending on the OS - shell = not IS_WINDOWS - - # Before launching a new Spyder instance we need to make sure that the - # previous one has closed. We wait for a fixed and "reasonable" amount of - # time and check, otherwise an error is launched - wait_time = 90 if IS_WINDOWS else 30 # Seconds - for counter in range(int(wait_time / SLEEP_TIME)): - if not is_pid_running(pid): - break - time.sleep(SLEEP_TIME) # Throttling control - QApplication.processEvents() # Needed to refresh the splash - else: - # The old spyder instance took too long to close and restart aborts - restarter.launch_error_message(error_type=CLOSE_ERROR) - - # Reset Spyder (if required) - # ------------------------------------------------------------------------- - if reset: - restarter.set_splash_message(_('Resetting Spyder to defaults')) - - try: - p = subprocess.Popen(' '.join(command + args_reset), - shell=shell, env=env) - except Exception as error: - restarter.launch_error_message(error_type=RESET_ERROR, error=error) - else: - p.communicate() - pid_reset = p.pid - - # Before launching a new Spyder instance we need to make sure that the - # reset subprocess has closed. We wait for a fixed and "reasonable" - # amount of time and check, otherwise an error is launched. - wait_time = 20 # Seconds - for counter in range(int(wait_time / SLEEP_TIME)): - if not is_pid_running(pid_reset): - break - time.sleep(SLEEP_TIME) # Throttling control - QApplication.processEvents() # Needed to refresh the splash - else: - # The reset subprocess took too long and it is killed - try: - p.kill() - except OSError as error: - restarter.launch_error_message(error_type=RESET_ERROR, - error=error) - else: - restarter.launch_error_message(error_type=RESET_ERROR) - - # Restart - # ------------------------------------------------------------------------- - restarter.set_splash_message(_('Restarting')) - try: - subprocess.Popen(' '.join(command + args), shell=shell, env=env) - except Exception as error: - restarter.launch_error_message(error_type=RESTART_ERROR, error=error) - - -if __name__ == '__main__': - main() +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Restart Spyder + +A helper script that allows to restart (and also reset) Spyder from within the +running application. +""" + +# Standard library imports +import ast +import os +import os.path as osp +import subprocess +import sys +import time + +# Third party imports +from qtpy.QtCore import Qt, QTimer +from qtpy.QtGui import QColor, QIcon +from qtpy.QtWidgets import QApplication, QMessageBox, QWidget + +# Local imports +from spyder.app.utils import create_splash_screen +from spyder.config.base import _, running_in_mac_app +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.encoding import to_unicode +from spyder.utils.qthelpers import qapplication +from spyder.config.manager import CONF + + +PY2 = sys.version[0] == '2' +IS_WINDOWS = os.name == 'nt' +SLEEP_TIME = 0.2 # Seconds for throttling control +CLOSE_ERROR, RESET_ERROR, RESTART_ERROR = [1, 2, 3] # Spyder error codes + + +def _is_pid_running_on_windows(pid): + """Check if a process is running on windows systems based on the pid.""" + pid = str(pid) + + # Hide flashing command prompt + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + process = subprocess.Popen(r'tasklist /fi "PID eq {0}"'.format(pid), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + startupinfo=startupinfo) + stdoutdata, stderrdata = process.communicate() + stdoutdata = to_unicode(stdoutdata) + process.kill() + check = pid in stdoutdata + + return check + + +def _is_pid_running_on_unix(pid): + """Check if a process is running on unix systems based on the pid.""" + try: + # On unix systems os.kill with a 0 as second argument only pokes the + # process (if it exists) and does not kill it + os.kill(pid, 0) + except OSError: + return False + else: + return True + + +def is_pid_running(pid): + """Check if a process is running based on the pid.""" + # Select the correct function depending on the OS + if os.name == 'nt': + return _is_pid_running_on_windows(pid) + else: + return _is_pid_running_on_unix(pid) + + +class Restarter(QWidget): + """Widget in charge of displaying the splash information screen and the + error messages. + """ + + def __init__(self): + super(Restarter, self).__init__() + self.ellipsis = ['', '.', '..', '...', '..', '.'] + + # Widgets + self.timer_ellipsis = QTimer(self) + self.splash = create_splash_screen() + + # Widget setup + self.setVisible(False) + self.splash.show() + + self.timer_ellipsis.timeout.connect(self.animate_ellipsis) + + def _show_message(self, text): + """Show message on splash screen.""" + self.splash.showMessage(text, + int(Qt.AlignBottom | Qt.AlignCenter | + Qt.AlignAbsolute), + QColor(Qt.white)) + + def animate_ellipsis(self): + """Animate dots at the end of the splash screen message.""" + ellipsis = self.ellipsis.pop(0) + text = ' ' * len(ellipsis) + self.splash_text + ellipsis + self.ellipsis.append(ellipsis) + self._show_message(text) + + def set_splash_message(self, text): + """Sets the text in the bottom of the Splash screen.""" + self.splash_text = text + self._show_message(text) + self.timer_ellipsis.start(500) + + # Wait 1.2 seconds so we can give feedback to users that a + # restart is happening. + for __ in range(40): + time.sleep(0.03) + QApplication.processEvents() + + def launch_error_message(self, error_type, error=None): + """Launch a message box with a predefined error message. + + Parameters + ---------- + error_type : int [CLOSE_ERROR, RESET_ERROR, RESTART_ERROR] + Possible error codes when restarting/resetting spyder. + error : Exception + Actual Python exception error caught. + """ + messages = {CLOSE_ERROR: _("It was not possible to close the previous " + "Spyder instance.\nRestart aborted."), + RESET_ERROR: _("Spyder could not reset to factory " + "defaults.\nRestart aborted."), + RESTART_ERROR: _("It was not possible to restart Spyder.\n" + "Operation aborted.")} + titles = {CLOSE_ERROR: _("Spyder exit error"), + RESET_ERROR: _("Spyder reset error"), + RESTART_ERROR: _("Spyder restart error")} + + if error: + e = error.__repr__() + message = messages[error_type] + "\n\n{0}".format(e) + else: + message = messages[error_type] + + title = titles[error_type] + self.splash.hide() + QMessageBox.warning(self, title, message, QMessageBox.Ok) + raise RuntimeError(message) + + +def main(): + #========================================================================== + # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must + # be set before creating the application. + #========================================================================== + env = os.environ.copy() + + if CONF.get('main', 'high_dpi_custom_scale_factor'): + factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) + f = list(filter(None, factors.split(';'))) + if len(f) == 1: + env['QT_SCALE_FACTOR'] = f[0] + else: + env['QT_SCREEN_SCALE_FACTORS'] = factors + else: + env['QT_SCALE_FACTOR'] = '' + env['QT_SCREEN_SCALE_FACTORS'] = '' + + # Splash screen + # ------------------------------------------------------------------------- + # Start Qt Splash to inform the user of the current status + app = qapplication() + restarter = Restarter() + + APP_ICON = QIcon(get_image_path("spyder")) + app.setWindowIcon(APP_ICON) + restarter.set_splash_message(_('Closing Spyder')) + + # Get variables + spyder_args = env.pop('SPYDER_ARGS', None) + pid = env.pop('SPYDER_PID', None) + is_bootstrap = env.pop('SPYDER_IS_BOOTSTRAP', None) + reset = env.pop('SPYDER_RESET', 'False') + + # Get the spyder base folder based on this file + spyder_dir = osp.dirname(osp.dirname(osp.dirname(osp.abspath(__file__)))) + + if not any([spyder_args, pid, is_bootstrap, reset]): + error = "This script can only be called from within a Spyder instance" + raise RuntimeError(error) + + # Variables were stored as string literals in the environment, so to use + # them we need to parse them in a safe manner. + is_bootstrap = ast.literal_eval(is_bootstrap) + pid = ast.literal_eval(pid) + args = ast.literal_eval(spyder_args) + reset = ast.literal_eval(reset) + + # SPYDER_DEBUG takes presedence over SPYDER_ARGS + if '--debug' in args: + args.remove('--debug') + for level in ['minimal', 'verbose']: + arg = f'--debug-info={level}' + if arg in args: + args.remove(arg) + + # Enforce the --new-instance flag when running spyder + if '--new-instance' not in args: + if is_bootstrap and '--' not in args: + args = args + ['--', '--new-instance'] + else: + args.append('--new-instance') + + # Create the arguments needed for resetting + if '--' in args: + args_reset = ['--', '--reset'] + else: + args_reset = ['--reset'] + + # Build the base command + if running_in_mac_app(sys.executable): + exe = env['EXECUTABLEPATH'] + command = [f'"{exe}"'] + else: + if is_bootstrap: + script = osp.join(spyder_dir, 'bootstrap.py') + else: + script = osp.join(spyder_dir, 'spyder', 'app', 'start.py') + + command = [f'"{sys.executable}"', f'"{script}"'] + + # Adjust the command and/or arguments to subprocess depending on the OS + shell = not IS_WINDOWS + + # Before launching a new Spyder instance we need to make sure that the + # previous one has closed. We wait for a fixed and "reasonable" amount of + # time and check, otherwise an error is launched + wait_time = 90 if IS_WINDOWS else 30 # Seconds + for counter in range(int(wait_time / SLEEP_TIME)): + if not is_pid_running(pid): + break + time.sleep(SLEEP_TIME) # Throttling control + QApplication.processEvents() # Needed to refresh the splash + else: + # The old spyder instance took too long to close and restart aborts + restarter.launch_error_message(error_type=CLOSE_ERROR) + + # Reset Spyder (if required) + # ------------------------------------------------------------------------- + if reset: + restarter.set_splash_message(_('Resetting Spyder to defaults')) + + try: + p = subprocess.Popen(' '.join(command + args_reset), + shell=shell, env=env) + except Exception as error: + restarter.launch_error_message(error_type=RESET_ERROR, error=error) + else: + p.communicate() + pid_reset = p.pid + + # Before launching a new Spyder instance we need to make sure that the + # reset subprocess has closed. We wait for a fixed and "reasonable" + # amount of time and check, otherwise an error is launched. + wait_time = 20 # Seconds + for counter in range(int(wait_time / SLEEP_TIME)): + if not is_pid_running(pid_reset): + break + time.sleep(SLEEP_TIME) # Throttling control + QApplication.processEvents() # Needed to refresh the splash + else: + # The reset subprocess took too long and it is killed + try: + p.kill() + except OSError as error: + restarter.launch_error_message(error_type=RESET_ERROR, + error=error) + else: + restarter.launch_error_message(error_type=RESET_ERROR) + + # Restart + # ------------------------------------------------------------------------- + restarter.set_splash_message(_('Restarting')) + try: + subprocess.Popen(' '.join(command + args), shell=shell, env=env) + except Exception as error: + restarter.launch_error_message(error_type=RESTART_ERROR, error=error) + + +if __name__ == '__main__': + main() diff --git a/spyder/app/start.py b/spyder/app/start.py index c4e8771859d..e1286162f96 100644 --- a/spyder/app/start.py +++ b/spyder/app/start.py @@ -1,270 +1,270 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -# Remove PYTHONPATH paths from sys.path before other imports to protect against -# shadowed standard libraries. -import os -import sys -if os.environ.get('PYTHONPATH'): - for path in os.environ['PYTHONPATH'].split(os.pathsep): - try: - sys.path.remove(path.rstrip(os.sep)) - except ValueError: - pass - -# Standard library imports -import ctypes -import logging -import os.path as osp -import random -import socket -import time - -# Prevent showing internal logging errors -# Fixes spyder-ide/spyder#15768 -logging.raiseExceptions = False - -# Prevent that our dependencies display warnings when not in debug mode. -# Some of them are reported in the console and others through our -# report error dialog. -# Note: The log level when debugging is set on the main window. -# Fixes spyder-ide/spyder#15163 -root_logger = logging.getLogger() -root_logger.setLevel(logging.ERROR) - -# Prevent a race condition with ZMQ -# See spyder-ide/spyder#5324. -import zmq - -# Load GL library to prevent segmentation faults on some Linux systems -# See spyder-ide/spyder#3226 and spyder-ide/spyder#3332. -try: - ctypes.CDLL("libGL.so.1", mode=ctypes.RTLD_GLOBAL) -except: - pass - -# Local imports -from spyder.app.cli_options import get_options -from spyder.config.base import (get_conf_path, running_in_mac_app, - reset_config_files, running_under_pytest) -from spyder.utils.external import lockfile -from spyder.py3compat import is_unicode - - -# Get argv -if running_under_pytest(): - sys_argv = [sys.argv[0]] - CLI_OPTIONS, CLI_ARGS = get_options(sys_argv) -else: - CLI_OPTIONS, CLI_ARGS = get_options() - -# Start Spyder with a clean configuration directory for testing purposes -if CLI_OPTIONS.safe_mode: - os.environ['SPYDER_SAFE_MODE'] = 'True' - -if CLI_OPTIONS.conf_dir: - os.environ['SPYDER_CONFDIR'] = CLI_OPTIONS.conf_dir - - -def send_args_to_spyder(args): - """ - Simple socket client used to send the args passed to the Spyder - executable to an already running instance. - - Args can be Python scripts or files with these extensions: .spydata, .mat, - .npy, or .h5, which can be imported by the Variable Explorer. - """ - from spyder.config.manager import CONF - port = CONF.get('main', 'open_files_port') - print_warning = True - - # Wait ~50 secs for the server to be up - # Taken from https://stackoverflow.com/a/4766598/438386 - for __ in range(200): - try: - for arg in args: - client = socket.socket(socket.AF_INET, socket.SOCK_STREAM, - socket.IPPROTO_TCP) - client.connect(("127.0.0.1", port)) - if is_unicode(arg): - arg = arg.encode('utf-8') - client.send(osp.abspath(arg)) - client.close() - except socket.error: - # Print informative warning to let users know what Spyder is doing - if print_warning: - print("Waiting for the server to open files and directories " - "to be up (perhaps it failed to be started).") - print_warning = False - - # Wait 250 ms before trying again - time.sleep(0.25) - continue - break - - -def main(): - """ - Start Spyder application. - - If single instance mode is turned on (default behavior) and an instance of - Spyder is already running, this will just parse and send command line - options to the application. - """ - # Parse command line options - options, args = (CLI_OPTIONS, CLI_ARGS) - - # This is to allow reset without reading our conf file - if options.reset_config_files: - # Remove all configuration files! - reset_config_files() - return - - from spyder.config.manager import CONF - - # Store variable to be used in self.restart (restart spyder instance) - os.environ['SPYDER_ARGS'] = str(sys.argv[1:]) - - #========================================================================== - # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must - # be set before creating the application. - #========================================================================== - if CONF.get('main', 'high_dpi_custom_scale_factor'): - factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) - f = list(filter(None, factors.split(';'))) - if len(f) == 1: - os.environ['QT_SCALE_FACTOR'] = f[0] - else: - os.environ['QT_SCREEN_SCALE_FACTORS'] = factors - else: - os.environ['QT_SCALE_FACTOR'] = '' - os.environ['QT_SCREEN_SCALE_FACTORS'] = '' - - if sys.platform == 'darwin': - # Fixes launching issues with Big Sur (spyder-ide/spyder#14222) - os.environ['QT_MAC_WANTS_LAYER'] = '1' - # Prevent Spyder from crashing in macOS if locale is not defined - LANG = os.environ.get('LANG') - LC_ALL = os.environ.get('LC_ALL') - if bool(LANG) and not bool(LC_ALL): - LC_ALL = LANG - elif not bool(LANG) and bool(LC_ALL): - LANG = LC_ALL - else: - LANG = LC_ALL = 'en_US.UTF-8' - - os.environ['LANG'] = LANG - os.environ['LC_ALL'] = LC_ALL - - # Don't show useless warning in the terminal where Spyder - # was started. - # See spyder-ide/spyder#3730. - os.environ['EVENT_NOKQUEUE'] = '1' - else: - # Prevent our kernels to crash when Python fails to identify - # the system locale. - # Fixes spyder-ide/spyder#7051. - try: - from locale import getlocale - getlocale() - except ValueError: - # This can fail on Windows. See spyder-ide/spyder#6886. - try: - os.environ['LANG'] = 'C' - os.environ['LC_ALL'] = 'C' - except Exception: - pass - - if options.debug_info: - levels = {'minimal': '2', 'verbose': '3'} - os.environ['SPYDER_DEBUG'] = levels[options.debug_info] - - _filename = 'spyder-debug.log' - if options.debug_output == 'file': - _filepath = osp.realpath(_filename) - else: - _filepath = get_conf_path(_filename) - os.environ['SPYDER_DEBUG_FILE'] = _filepath - - if options.paths: - from spyder.config.base import get_conf_paths - sys.stdout.write('\nconfig:' + '\n') - for path in reversed(get_conf_paths()): - sys.stdout.write('\t' + path + '\n') - sys.stdout.write('\n' ) - return - - if (CONF.get('main', 'single_instance') and not options.new_instance - and not options.reset_config_files - and not running_in_mac_app()): - # Minimal delay (0.1-0.2 secs) to avoid that several - # instances started at the same time step in their - # own foots while trying to create the lock file - time.sleep(random.randrange(1000, 2000, 90)/10000.) - - # Lock file creation - lock_file = get_conf_path('spyder.lock') - lock = lockfile.FilesystemLock(lock_file) - - # Try to lock spyder.lock. If it's *possible* to do it, then - # there is no previous instance running and we can start a - # new one. If *not*, then there is an instance already - # running, which is locking that file - try: - lock_created = lock.lock() - except: - # If locking fails because of errors in the lockfile - # module, try to remove a possibly stale spyder.lock. - # This is reported to solve all problems with lockfile. - # See spyder-ide/spyder#2363. - try: - if os.name == 'nt': - if osp.isdir(lock_file): - import shutil - shutil.rmtree(lock_file, ignore_errors=True) - else: - if osp.islink(lock_file): - os.unlink(lock_file) - except: - pass - - # Then start Spyder as usual and *don't* continue - # executing this script because it doesn't make - # sense - from spyder.app import mainwindow - if running_under_pytest(): - return mainwindow.main(options, args) - else: - mainwindow.main(options, args) - return - - if lock_created: - # Start a new instance - from spyder.app import mainwindow - if running_under_pytest(): - return mainwindow.main(options, args) - else: - mainwindow.main(options, args) - else: - # Pass args to Spyder or print an informative - # message - if args: - send_args_to_spyder(args) - else: - print("Spyder is already running. If you want to open a new \n" - "instance, please use the --new-instance option") - else: - from spyder.app import mainwindow - if running_under_pytest(): - return mainwindow.main(options, args) - else: - mainwindow.main(options, args) - - -if __name__ == "__main__": - main() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +# Remove PYTHONPATH paths from sys.path before other imports to protect against +# shadowed standard libraries. +import os +import sys +if os.environ.get('PYTHONPATH'): + for path in os.environ['PYTHONPATH'].split(os.pathsep): + try: + sys.path.remove(path.rstrip(os.sep)) + except ValueError: + pass + +# Standard library imports +import ctypes +import logging +import os.path as osp +import random +import socket +import time + +# Prevent showing internal logging errors +# Fixes spyder-ide/spyder#15768 +logging.raiseExceptions = False + +# Prevent that our dependencies display warnings when not in debug mode. +# Some of them are reported in the console and others through our +# report error dialog. +# Note: The log level when debugging is set on the main window. +# Fixes spyder-ide/spyder#15163 +root_logger = logging.getLogger() +root_logger.setLevel(logging.ERROR) + +# Prevent a race condition with ZMQ +# See spyder-ide/spyder#5324. +import zmq + +# Load GL library to prevent segmentation faults on some Linux systems +# See spyder-ide/spyder#3226 and spyder-ide/spyder#3332. +try: + ctypes.CDLL("libGL.so.1", mode=ctypes.RTLD_GLOBAL) +except: + pass + +# Local imports +from spyder.app.cli_options import get_options +from spyder.config.base import (get_conf_path, running_in_mac_app, + reset_config_files, running_under_pytest) +from spyder.utils.external import lockfile +from spyder.py3compat import is_unicode + + +# Get argv +if running_under_pytest(): + sys_argv = [sys.argv[0]] + CLI_OPTIONS, CLI_ARGS = get_options(sys_argv) +else: + CLI_OPTIONS, CLI_ARGS = get_options() + +# Start Spyder with a clean configuration directory for testing purposes +if CLI_OPTIONS.safe_mode: + os.environ['SPYDER_SAFE_MODE'] = 'True' + +if CLI_OPTIONS.conf_dir: + os.environ['SPYDER_CONFDIR'] = CLI_OPTIONS.conf_dir + + +def send_args_to_spyder(args): + """ + Simple socket client used to send the args passed to the Spyder + executable to an already running instance. + + Args can be Python scripts or files with these extensions: .spydata, .mat, + .npy, or .h5, which can be imported by the Variable Explorer. + """ + from spyder.config.manager import CONF + port = CONF.get('main', 'open_files_port') + print_warning = True + + # Wait ~50 secs for the server to be up + # Taken from https://stackoverflow.com/a/4766598/438386 + for __ in range(200): + try: + for arg in args: + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM, + socket.IPPROTO_TCP) + client.connect(("127.0.0.1", port)) + if is_unicode(arg): + arg = arg.encode('utf-8') + client.send(osp.abspath(arg)) + client.close() + except socket.error: + # Print informative warning to let users know what Spyder is doing + if print_warning: + print("Waiting for the server to open files and directories " + "to be up (perhaps it failed to be started).") + print_warning = False + + # Wait 250 ms before trying again + time.sleep(0.25) + continue + break + + +def main(): + """ + Start Spyder application. + + If single instance mode is turned on (default behavior) and an instance of + Spyder is already running, this will just parse and send command line + options to the application. + """ + # Parse command line options + options, args = (CLI_OPTIONS, CLI_ARGS) + + # This is to allow reset without reading our conf file + if options.reset_config_files: + # Remove all configuration files! + reset_config_files() + return + + from spyder.config.manager import CONF + + # Store variable to be used in self.restart (restart spyder instance) + os.environ['SPYDER_ARGS'] = str(sys.argv[1:]) + + #========================================================================== + # Proper high DPI scaling is available in Qt >= 5.6.0. This attribute must + # be set before creating the application. + #========================================================================== + if CONF.get('main', 'high_dpi_custom_scale_factor'): + factors = str(CONF.get('main', 'high_dpi_custom_scale_factors')) + f = list(filter(None, factors.split(';'))) + if len(f) == 1: + os.environ['QT_SCALE_FACTOR'] = f[0] + else: + os.environ['QT_SCREEN_SCALE_FACTORS'] = factors + else: + os.environ['QT_SCALE_FACTOR'] = '' + os.environ['QT_SCREEN_SCALE_FACTORS'] = '' + + if sys.platform == 'darwin': + # Fixes launching issues with Big Sur (spyder-ide/spyder#14222) + os.environ['QT_MAC_WANTS_LAYER'] = '1' + # Prevent Spyder from crashing in macOS if locale is not defined + LANG = os.environ.get('LANG') + LC_ALL = os.environ.get('LC_ALL') + if bool(LANG) and not bool(LC_ALL): + LC_ALL = LANG + elif not bool(LANG) and bool(LC_ALL): + LANG = LC_ALL + else: + LANG = LC_ALL = 'en_US.UTF-8' + + os.environ['LANG'] = LANG + os.environ['LC_ALL'] = LC_ALL + + # Don't show useless warning in the terminal where Spyder + # was started. + # See spyder-ide/spyder#3730. + os.environ['EVENT_NOKQUEUE'] = '1' + else: + # Prevent our kernels to crash when Python fails to identify + # the system locale. + # Fixes spyder-ide/spyder#7051. + try: + from locale import getlocale + getlocale() + except ValueError: + # This can fail on Windows. See spyder-ide/spyder#6886. + try: + os.environ['LANG'] = 'C' + os.environ['LC_ALL'] = 'C' + except Exception: + pass + + if options.debug_info: + levels = {'minimal': '2', 'verbose': '3'} + os.environ['SPYDER_DEBUG'] = levels[options.debug_info] + + _filename = 'spyder-debug.log' + if options.debug_output == 'file': + _filepath = osp.realpath(_filename) + else: + _filepath = get_conf_path(_filename) + os.environ['SPYDER_DEBUG_FILE'] = _filepath + + if options.paths: + from spyder.config.base import get_conf_paths + sys.stdout.write('\nconfig:' + '\n') + for path in reversed(get_conf_paths()): + sys.stdout.write('\t' + path + '\n') + sys.stdout.write('\n' ) + return + + if (CONF.get('main', 'single_instance') and not options.new_instance + and not options.reset_config_files + and not running_in_mac_app()): + # Minimal delay (0.1-0.2 secs) to avoid that several + # instances started at the same time step in their + # own foots while trying to create the lock file + time.sleep(random.randrange(1000, 2000, 90)/10000.) + + # Lock file creation + lock_file = get_conf_path('spyder.lock') + lock = lockfile.FilesystemLock(lock_file) + + # Try to lock spyder.lock. If it's *possible* to do it, then + # there is no previous instance running and we can start a + # new one. If *not*, then there is an instance already + # running, which is locking that file + try: + lock_created = lock.lock() + except: + # If locking fails because of errors in the lockfile + # module, try to remove a possibly stale spyder.lock. + # This is reported to solve all problems with lockfile. + # See spyder-ide/spyder#2363. + try: + if os.name == 'nt': + if osp.isdir(lock_file): + import shutil + shutil.rmtree(lock_file, ignore_errors=True) + else: + if osp.islink(lock_file): + os.unlink(lock_file) + except: + pass + + # Then start Spyder as usual and *don't* continue + # executing this script because it doesn't make + # sense + from spyder.app import mainwindow + if running_under_pytest(): + return mainwindow.main(options, args) + else: + mainwindow.main(options, args) + return + + if lock_created: + # Start a new instance + from spyder.app import mainwindow + if running_under_pytest(): + return mainwindow.main(options, args) + else: + mainwindow.main(options, args) + else: + # Pass args to Spyder or print an informative + # message + if args: + send_args_to_spyder(args) + else: + print("Spyder is already running. If you want to open a new \n" + "instance, please use the --new-instance option") + else: + from spyder.app import mainwindow + if running_under_pytest(): + return mainwindow.main(options, args) + else: + mainwindow.main(options, args) + + +if __name__ == "__main__": + main() diff --git a/spyder/config/base.py b/spyder/config/base.py index ab4d1b865f5..b247916e2e8 100644 --- a/spyder/config/base.py +++ b/spyder/config/base.py @@ -1,630 +1,630 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder base configuration management - -This file only deals with non-GUI configuration features -(in other words, we won't import any PyQt object here, avoiding any -sip API incompatibility issue in spyder's non-gui modules) -""" - -import codecs -import locale -import os -import os.path as osp -import re -import shutil -import sys -import tempfile -import uuid -import warnings - -# Local imports -from spyder import __version__ -from spyder.py3compat import is_unicode, PY3, to_text_string, is_text_string -from spyder.utils import encoding - -#============================================================================== -# Only for development -#============================================================================== -# To activate/deactivate certain things for development -# SPYDER_DEV is (and *only* has to be) set in bootstrap.py -DEV = os.environ.get('SPYDER_DEV') - -# Manually override whether the dev configuration directory is used. -USE_DEV_CONFIG_DIR = os.environ.get('SPYDER_USE_DEV_CONFIG_DIR') - -# Get a random id for the safe-mode config dir -CLEAN_DIR_ID = str(uuid.uuid4()).split('-')[-1] - - -def get_safe_mode(): - """ - Make Spyder use a temp clean configuration directory for testing - purposes SPYDER_SAFE_MODE can be set using the --safe-mode option. - """ - return bool(os.environ.get('SPYDER_SAFE_MODE')) - - -def running_under_pytest(): - """ - Return True if currently running under pytest. - - This function is used to do some adjustment for testing. The environment - variable SPYDER_PYTEST is defined in conftest.py. - """ - return bool(os.environ.get('SPYDER_PYTEST')) - - -def running_in_ci(): - """Return True if currently running under CI.""" - return bool(os.environ.get('CI')) - - -def running_in_ci_with_conda(): - """Return True if currently running under CI with conda packages.""" - return running_in_ci() and bool(os.environ.get('USE_CONDA')) - - -def is_stable_version(version): - """ - Return true if version is stable, i.e. with letters in the final component. - - Stable version examples: ``1.2``, ``1.3.4``, ``1.0.5``. - Non-stable version examples: ``1.3.4beta``, ``0.1.0rc1``, ``3.0.0dev0``. - """ - if not isinstance(version, tuple): - version = version.split('.') - last_part = version[-1] - - if not re.search(r'[a-zA-Z]', last_part): - return True - else: - return False - - -def use_dev_config_dir(use_dev_config_dir=USE_DEV_CONFIG_DIR): - """Return whether the dev configuration directory should used.""" - if use_dev_config_dir is not None: - if use_dev_config_dir.lower() in {'false', '0'}: - use_dev_config_dir = False - else: - use_dev_config_dir = DEV or not is_stable_version(__version__) - - return use_dev_config_dir - - -#============================================================================== -# Debug helpers -#============================================================================== -# This is needed after restarting and using debug_print -STDOUT = sys.stdout if PY3 else codecs.getwriter('utf-8')(sys.stdout) -STDERR = sys.stderr - - -def get_debug_level(): - debug_env = os.environ.get('SPYDER_DEBUG', '') - if not debug_env.isdigit(): - debug_env = bool(debug_env) - return int(debug_env) - - -def debug_print(*message): - """Output debug messages to stdout""" - warnings.warn("debug_print is deprecated; use the logging module instead.") - if get_debug_level(): - ss = STDOUT - if PY3: - # This is needed after restarting and using debug_print - for m in message: - ss.buffer.write(str(m).encode('utf-8')) - print('', file=ss) - else: - print(*message, file=ss) - - -#============================================================================== -# Configuration paths -#============================================================================== -def get_conf_subfolder(): - """Return the configuration subfolder for different ooperating systems.""" - # Spyder settings dir - # NOTE: During the 2.x.x series this dir was named .spyder2, but - # since 3.0+ we've reverted back to use .spyder to simplify major - # updates in version (required when we change APIs by Linux - # packagers) - if sys.platform.startswith('linux'): - SUBFOLDER = 'spyder' - else: - SUBFOLDER = '.spyder' - - # We can't have PY2 and PY3 settings in the same dir because: - # 1. This leads to ugly crashes and freezes (e.g. by trying to - # embed a PY2 interpreter in PY3) - # 2. We need to save the list of installed modules (for code - # completion) separately for each version - if PY3: - SUBFOLDER = SUBFOLDER + '-py3' - - # If running a development/beta version, save config in a separate - # directory to avoid wiping or contaiminating the user's saved stable - # configuration. - if use_dev_config_dir(): - SUBFOLDER = SUBFOLDER + '-dev' - - return SUBFOLDER - - -def get_project_config_folder(): - """Return the default project configuration folder.""" - return '.spyproject' - - -def get_home_dir(): - """Return user home directory.""" - try: - # expanduser() returns a raw byte string which needs to be - # decoded with the codec that the OS is using to represent - # file paths. - path = encoding.to_unicode_from_fs(osp.expanduser('~')) - except Exception: - path = '' - - if osp.isdir(path): - return path - else: - # Get home from alternative locations - for env_var in ('HOME', 'USERPROFILE', 'TMP'): - # os.environ.get() returns a raw byte string which needs to be - # decoded with the codec that the OS is using to represent - # environment variables. - path = encoding.to_unicode_from_fs(os.environ.get(env_var, '')) - if osp.isdir(path): - return path - else: - path = '' - - if not path: - raise RuntimeError('Please set the environment variable HOME to ' - 'your user/home directory path so Spyder can ' - 'start properly.') - - -def get_clean_conf_dir(): - """ - Return the path to a temp clean configuration dir, for tests and safe mode. - """ - conf_dir = osp.join( - tempfile.gettempdir(), - 'spyder-clean-conf-dirs', - CLEAN_DIR_ID, - ) - return conf_dir - - -def get_custom_conf_dir(): - """ - Use a custom configuration directory, passed through our command - line options or by setting the env var below. - """ - custom_dir = os.environ.get('SPYDER_CONFDIR') - if custom_dir: - custom_dir = osp.abspath(custom_dir) - - # Set env var to not lose its value in future calls when the cwd - # is changed by Spyder. - os.environ['SPYDER_CONFDIR'] = custom_dir - return custom_dir - - -def get_conf_path(filename=None): - """Return absolute path to the config file with the specified filename.""" - # Define conf_dir - if running_under_pytest() or get_safe_mode(): - # Use clean config dir if running tests or the user requests it. - conf_dir = get_clean_conf_dir() - elif get_custom_conf_dir(): - # Use a custom directory if the user decided to do it through - # our command line options. - conf_dir = get_custom_conf_dir() - elif sys.platform.startswith('linux'): - # This makes us follow the XDG standard to save our settings - # on Linux, as it was requested on spyder-ide/spyder#2629. - xdg_config_home = os.environ.get('XDG_CONFIG_HOME', '') - if not xdg_config_home: - xdg_config_home = osp.join(get_home_dir(), '.config') - - if not osp.isdir(xdg_config_home): - os.makedirs(xdg_config_home) - - conf_dir = osp.join(xdg_config_home, get_conf_subfolder()) - else: - conf_dir = osp.join(get_home_dir(), get_conf_subfolder()) - - # Create conf_dir - if not osp.isdir(conf_dir): - if running_under_pytest() or get_safe_mode() or get_custom_conf_dir(): - os.makedirs(conf_dir) - else: - os.mkdir(conf_dir) - - if filename is None: - return conf_dir - else: - return osp.join(conf_dir, filename) - - -def get_conf_paths(): - """Return the files that can update system configuration defaults.""" - CONDA_PREFIX = os.environ.get('CONDA_PREFIX', None) - - if os.name == 'nt': - SEARCH_PATH = ( - 'C:/ProgramData/spyder', - ) - else: - SEARCH_PATH = ( - '/etc/spyder', - '/usr/local/etc/spyder', - ) - - if CONDA_PREFIX is not None: - CONDA_PREFIX = CONDA_PREFIX.replace('\\', '/') - SEARCH_PATH += ( - '{}/etc/spyder'.format(CONDA_PREFIX), - ) - - SEARCH_PATH += ( - '{}/etc/spyder'.format(sys.prefix), - ) - - if running_under_pytest(): - search_paths = [] - tmpfolder = str(tempfile.gettempdir()) - for i in range(3): - path = os.path.join(tmpfolder, 'site-config-' + str(i)) - if not os.path.isdir(path): - os.makedirs(path) - search_paths.append(path) - SEARCH_PATH = tuple(search_paths) - - return SEARCH_PATH - - -def get_module_path(modname): - """Return module *modname* base path""" - return osp.abspath(osp.dirname(sys.modules[modname].__file__)) - - -def get_module_data_path(modname, relpath=None, attr_name='DATAPATH'): - """Return module *modname* data path - Note: relpath is ignored if module has an attribute named *attr_name* - - Handles py2exe/cx_Freeze distributions""" - datapath = getattr(sys.modules[modname], attr_name, '') - if datapath: - return datapath - else: - datapath = get_module_path(modname) - parentdir = osp.join(datapath, osp.pardir) - if osp.isfile(parentdir): - # Parent directory is not a directory but the 'library.zip' file: - # this is either a py2exe or a cx_Freeze distribution - datapath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), - modname)) - if relpath is not None: - datapath = osp.abspath(osp.join(datapath, relpath)) - return datapath - - -def get_module_source_path(modname, basename=None): - """Return module *modname* source path - If *basename* is specified, return *modname.basename* path where - *modname* is a package containing the module *basename* - - *basename* is a filename (not a module name), so it must include the - file extension: .py or .pyw - - Handles py2exe/cx_Freeze distributions""" - srcpath = get_module_path(modname) - parentdir = osp.join(srcpath, osp.pardir) - if osp.isfile(parentdir): - # Parent directory is not a directory but the 'library.zip' file: - # this is either a py2exe or a cx_Freeze distribution - srcpath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), - modname)) - if basename is not None: - srcpath = osp.abspath(osp.join(srcpath, basename)) - return srcpath - - -def is_py2exe_or_cx_Freeze(): - """Return True if this is a py2exe/cx_Freeze distribution of Spyder""" - return osp.isfile(osp.join(get_module_path('spyder'), osp.pardir)) - - -def is_pynsist(): - """Return True if this is a pynsist installation of Spyder.""" - base_path = osp.abspath(osp.dirname(__file__)) - pkgs_path = osp.abspath( - osp.join(base_path, '..', '..', '..', 'pkgs')) - if os.environ.get('PYTHONPATH') is not None: - return pkgs_path in os.environ.get('PYTHONPATH') - return False - - -#============================================================================== -# Translations -#============================================================================== -LANG_FILE = get_conf_path('langconfig') -DEFAULT_LANGUAGE = 'en' - -# This needs to be updated every time a new language is added to spyder, and is -# used by the Preferences configuration to populate the Language QComboBox -LANGUAGE_CODES = { - 'en': u'English', - 'fr': u'Français', - 'es': u'Español', - 'hu': u'Magyar', - 'pt_BR': u'Português', - 'ru': u'Русский', - 'zh_CN': u'简体中文', - 'ja': u'日本語', - 'de': u'Deutsch', - 'pl': u'Polski' -} - -# Disabled languages because their translations are outdated or incomplete -DISABLED_LANGUAGES = ['hu', 'pl'] - - -def get_available_translations(): - """ - List available translations for spyder based on the folders found in the - locale folder. This function checks if LANGUAGE_CODES contain the same - information that is found in the 'locale' folder to ensure that when a new - language is added, LANGUAGE_CODES is updated. - """ - locale_path = get_module_data_path("spyder", relpath="locale", - attr_name='LOCALEPATH') - listdir = os.listdir(locale_path) - langs = [d for d in listdir if osp.isdir(osp.join(locale_path, d))] - langs = [DEFAULT_LANGUAGE] + langs - - # Remove disabled languages - langs = list(set(langs) - set(DISABLED_LANGUAGES)) - - # Check that there is a language code available in case a new translation - # is added, to ensure LANGUAGE_CODES is updated. - for lang in langs: - if lang not in LANGUAGE_CODES: - if DEV: - error = ('Update LANGUAGE_CODES (inside config/base.py) if a ' - 'new translation has been added to Spyder') - print(error) # spyder: test-skip - return ['en'] - return langs - - -def get_interface_language(): - """ - If Spyder has a translation available for the locale language, it will - return the version provided by Spyder adjusted for language subdifferences, - otherwise it will return DEFAULT_LANGUAGE. - - Example: - 1.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the - locale is either 'en_US' or 'en' or 'en_UK', this function will return 'en' - - 2.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the - locale is either 'pt' or 'pt_BR', this function will return 'pt_BR' - """ - - # Solves spyder-ide/spyder#3627. - try: - locale_language = locale.getdefaultlocale()[0] - except ValueError: - locale_language = DEFAULT_LANGUAGE - - # Tests expect English as the interface language - if running_under_pytest(): - locale_language = DEFAULT_LANGUAGE - - language = DEFAULT_LANGUAGE - - if locale_language is not None: - spyder_languages = get_available_translations() - for lang in spyder_languages: - if locale_language == lang: - language = locale_language - break - elif (locale_language.startswith(lang) or - lang.startswith(locale_language)): - language = lang - break - - return language - - -def save_lang_conf(value): - """Save language setting to language config file""" - # Needed to avoid an error when trying to save LANG_FILE - # but the operation fails for some reason. - # See spyder-ide/spyder#8807. - try: - with open(LANG_FILE, 'w') as f: - f.write(value) - except EnvironmentError: - pass - - -def load_lang_conf(): - """ - Load language setting from language config file if it exists, otherwise - try to use the local settings if Spyder provides a translation, or - return the default if no translation provided. - """ - if osp.isfile(LANG_FILE): - with open(LANG_FILE, 'r') as f: - lang = f.read() - else: - lang = get_interface_language() - save_lang_conf(lang) - - # Save language again if it's been disabled - if lang.strip('\n') in DISABLED_LANGUAGES: - lang = DEFAULT_LANGUAGE - save_lang_conf(lang) - - return lang - - -def get_translation(modname, dirname=None): - """Return translation callback for module *modname*""" - if dirname is None: - dirname = modname - - def translate_dumb(x): - """Dumb function to not use translations.""" - if not is_unicode(x): - return to_text_string(x, "utf-8") - return x - - locale_path = get_module_data_path(dirname, relpath="locale", - attr_name='LOCALEPATH') - - # If LANG is defined in Ubuntu, a warning message is displayed, - # so in Unix systems we define the LANGUAGE variable. - language = load_lang_conf() - if os.name == 'nt': - # Trying to set LANG on Windows can fail when Spyder is - # run with admin privileges. - # Fixes spyder-ide/spyder#6886. - try: - os.environ["LANG"] = language # Works on Windows - except Exception: - return translate_dumb - else: - os.environ["LANGUAGE"] = language # Works on Linux - - import gettext - try: - _trans = gettext.translation(modname, locale_path, codeset="utf-8") - lgettext = _trans.lgettext - - def translate_gettext(x): - if not PY3 and is_unicode(x): - x = x.encode("utf-8") - y = lgettext(x) - if is_text_string(y) and PY3: - return y - else: - return to_text_string(y, "utf-8") - return translate_gettext - except Exception: - return translate_dumb - - -# Translation callback -_ = get_translation("spyder") - - -#============================================================================== -# Namespace Browser (Variable Explorer) configuration management -#============================================================================== -# Variable explorer display / check all elements data types for sequences: -# (when saving the variable explorer contents, check_all is True, -CHECK_ALL = False # XXX: If True, this should take too much to compute... - -EXCLUDED_NAMES = ['nan', 'inf', 'infty', 'little_endian', 'colorbar_doc', - 'typecodes', '__builtins__', '__main__', '__doc__', 'NaN', - 'Inf', 'Infinity', 'sctypes', 'rcParams', 'rcParamsDefault', - 'sctypeNA', 'typeNA', 'False_', 'True_'] - - -#============================================================================== -# Mac application utilities -#============================================================================== -def running_in_mac_app(pyexec=None): - """ - Check if Python executable is located inside a standalone Mac app. - - If no executable is provided, the default will check `sys.executable`, i.e. - whether Spyder is running from a standalone Mac app. - - This is important for example for the single_instance option and the - interpreter status in the statusbar. - """ - if pyexec is None: - pyexec = sys.executable - - bpath = get_mac_app_bundle_path() - - if bpath and pyexec == osp.join(bpath, 'Contents/MacOS/python'): - return True - else: - return False - - -def get_mac_app_bundle_path(): - """ - Return the full path to the macOS app bundle. Otherwise return None. - - EXECUTABLEPATH environment variable only exists if Spyder is a macOS app - bundle. In which case it will always end with - "/.app/Conents/MacOS/Spyder". - """ - app_exe_path = os.environ.get('EXECUTABLEPATH', None) - if sys.platform == "darwin" and app_exe_path: - return osp.dirname(osp.dirname(osp.dirname(osp.abspath(app_exe_path)))) - else: - return None - - -# ============================================================================= -# Micromamba -# ============================================================================= -def get_spyder_umamba_path(): - """Return the path to the Micromamba executable bundled with Spyder.""" - if running_in_mac_app(): - path = osp.join(osp.dirname(osp.dirname(__file__)), - 'bin', 'micromamba') - elif is_pynsist(): - path = osp.abspath(osp.join(osp.dirname(osp.dirname(__file__)), - 'bin', 'micromamba.exe')) - else: - path = None - - return path - - -#============================================================================== -# Reset config files -#============================================================================== -SAVED_CONFIG_FILES = ('help', 'onlinehelp', 'path', 'pylint.results', - 'spyder.ini', 'temp.py', 'temp.spydata', 'template.py', - 'history.py', 'history_internal.py', 'workingdir', - '.projects', '.spyproject', '.ropeproject', - 'monitor.log', 'monitor_debug.log', 'rope.log', - 'langconfig', 'spyder.lock', - 'config{}spyder.ini'.format(os.sep), - 'config{}transient.ini'.format(os.sep), - 'lsp_root_path', 'plugins') - - -def reset_config_files(): - """Remove all config files""" - print("*** Reset Spyder settings to defaults ***", file=STDERR) - for fname in SAVED_CONFIG_FILES: - cfg_fname = get_conf_path(fname) - if osp.isfile(cfg_fname) or osp.islink(cfg_fname): - os.remove(cfg_fname) - elif osp.isdir(cfg_fname): - shutil.rmtree(cfg_fname) - else: - continue - print("removing:", cfg_fname, file=STDERR) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder base configuration management + +This file only deals with non-GUI configuration features +(in other words, we won't import any PyQt object here, avoiding any +sip API incompatibility issue in spyder's non-gui modules) +""" + +import codecs +import locale +import os +import os.path as osp +import re +import shutil +import sys +import tempfile +import uuid +import warnings + +# Local imports +from spyder import __version__ +from spyder.py3compat import is_unicode, PY3, to_text_string, is_text_string +from spyder.utils import encoding + +#============================================================================== +# Only for development +#============================================================================== +# To activate/deactivate certain things for development +# SPYDER_DEV is (and *only* has to be) set in bootstrap.py +DEV = os.environ.get('SPYDER_DEV') + +# Manually override whether the dev configuration directory is used. +USE_DEV_CONFIG_DIR = os.environ.get('SPYDER_USE_DEV_CONFIG_DIR') + +# Get a random id for the safe-mode config dir +CLEAN_DIR_ID = str(uuid.uuid4()).split('-')[-1] + + +def get_safe_mode(): + """ + Make Spyder use a temp clean configuration directory for testing + purposes SPYDER_SAFE_MODE can be set using the --safe-mode option. + """ + return bool(os.environ.get('SPYDER_SAFE_MODE')) + + +def running_under_pytest(): + """ + Return True if currently running under pytest. + + This function is used to do some adjustment for testing. The environment + variable SPYDER_PYTEST is defined in conftest.py. + """ + return bool(os.environ.get('SPYDER_PYTEST')) + + +def running_in_ci(): + """Return True if currently running under CI.""" + return bool(os.environ.get('CI')) + + +def running_in_ci_with_conda(): + """Return True if currently running under CI with conda packages.""" + return running_in_ci() and bool(os.environ.get('USE_CONDA')) + + +def is_stable_version(version): + """ + Return true if version is stable, i.e. with letters in the final component. + + Stable version examples: ``1.2``, ``1.3.4``, ``1.0.5``. + Non-stable version examples: ``1.3.4beta``, ``0.1.0rc1``, ``3.0.0dev0``. + """ + if not isinstance(version, tuple): + version = version.split('.') + last_part = version[-1] + + if not re.search(r'[a-zA-Z]', last_part): + return True + else: + return False + + +def use_dev_config_dir(use_dev_config_dir=USE_DEV_CONFIG_DIR): + """Return whether the dev configuration directory should used.""" + if use_dev_config_dir is not None: + if use_dev_config_dir.lower() in {'false', '0'}: + use_dev_config_dir = False + else: + use_dev_config_dir = DEV or not is_stable_version(__version__) + + return use_dev_config_dir + + +#============================================================================== +# Debug helpers +#============================================================================== +# This is needed after restarting and using debug_print +STDOUT = sys.stdout if PY3 else codecs.getwriter('utf-8')(sys.stdout) +STDERR = sys.stderr + + +def get_debug_level(): + debug_env = os.environ.get('SPYDER_DEBUG', '') + if not debug_env.isdigit(): + debug_env = bool(debug_env) + return int(debug_env) + + +def debug_print(*message): + """Output debug messages to stdout""" + warnings.warn("debug_print is deprecated; use the logging module instead.") + if get_debug_level(): + ss = STDOUT + if PY3: + # This is needed after restarting and using debug_print + for m in message: + ss.buffer.write(str(m).encode('utf-8')) + print('', file=ss) + else: + print(*message, file=ss) + + +#============================================================================== +# Configuration paths +#============================================================================== +def get_conf_subfolder(): + """Return the configuration subfolder for different ooperating systems.""" + # Spyder settings dir + # NOTE: During the 2.x.x series this dir was named .spyder2, but + # since 3.0+ we've reverted back to use .spyder to simplify major + # updates in version (required when we change APIs by Linux + # packagers) + if sys.platform.startswith('linux'): + SUBFOLDER = 'spyder' + else: + SUBFOLDER = '.spyder' + + # We can't have PY2 and PY3 settings in the same dir because: + # 1. This leads to ugly crashes and freezes (e.g. by trying to + # embed a PY2 interpreter in PY3) + # 2. We need to save the list of installed modules (for code + # completion) separately for each version + if PY3: + SUBFOLDER = SUBFOLDER + '-py3' + + # If running a development/beta version, save config in a separate + # directory to avoid wiping or contaiminating the user's saved stable + # configuration. + if use_dev_config_dir(): + SUBFOLDER = SUBFOLDER + '-dev' + + return SUBFOLDER + + +def get_project_config_folder(): + """Return the default project configuration folder.""" + return '.spyproject' + + +def get_home_dir(): + """Return user home directory.""" + try: + # expanduser() returns a raw byte string which needs to be + # decoded with the codec that the OS is using to represent + # file paths. + path = encoding.to_unicode_from_fs(osp.expanduser('~')) + except Exception: + path = '' + + if osp.isdir(path): + return path + else: + # Get home from alternative locations + for env_var in ('HOME', 'USERPROFILE', 'TMP'): + # os.environ.get() returns a raw byte string which needs to be + # decoded with the codec that the OS is using to represent + # environment variables. + path = encoding.to_unicode_from_fs(os.environ.get(env_var, '')) + if osp.isdir(path): + return path + else: + path = '' + + if not path: + raise RuntimeError('Please set the environment variable HOME to ' + 'your user/home directory path so Spyder can ' + 'start properly.') + + +def get_clean_conf_dir(): + """ + Return the path to a temp clean configuration dir, for tests and safe mode. + """ + conf_dir = osp.join( + tempfile.gettempdir(), + 'spyder-clean-conf-dirs', + CLEAN_DIR_ID, + ) + return conf_dir + + +def get_custom_conf_dir(): + """ + Use a custom configuration directory, passed through our command + line options or by setting the env var below. + """ + custom_dir = os.environ.get('SPYDER_CONFDIR') + if custom_dir: + custom_dir = osp.abspath(custom_dir) + + # Set env var to not lose its value in future calls when the cwd + # is changed by Spyder. + os.environ['SPYDER_CONFDIR'] = custom_dir + return custom_dir + + +def get_conf_path(filename=None): + """Return absolute path to the config file with the specified filename.""" + # Define conf_dir + if running_under_pytest() or get_safe_mode(): + # Use clean config dir if running tests or the user requests it. + conf_dir = get_clean_conf_dir() + elif get_custom_conf_dir(): + # Use a custom directory if the user decided to do it through + # our command line options. + conf_dir = get_custom_conf_dir() + elif sys.platform.startswith('linux'): + # This makes us follow the XDG standard to save our settings + # on Linux, as it was requested on spyder-ide/spyder#2629. + xdg_config_home = os.environ.get('XDG_CONFIG_HOME', '') + if not xdg_config_home: + xdg_config_home = osp.join(get_home_dir(), '.config') + + if not osp.isdir(xdg_config_home): + os.makedirs(xdg_config_home) + + conf_dir = osp.join(xdg_config_home, get_conf_subfolder()) + else: + conf_dir = osp.join(get_home_dir(), get_conf_subfolder()) + + # Create conf_dir + if not osp.isdir(conf_dir): + if running_under_pytest() or get_safe_mode() or get_custom_conf_dir(): + os.makedirs(conf_dir) + else: + os.mkdir(conf_dir) + + if filename is None: + return conf_dir + else: + return osp.join(conf_dir, filename) + + +def get_conf_paths(): + """Return the files that can update system configuration defaults.""" + CONDA_PREFIX = os.environ.get('CONDA_PREFIX', None) + + if os.name == 'nt': + SEARCH_PATH = ( + 'C:/ProgramData/spyder', + ) + else: + SEARCH_PATH = ( + '/etc/spyder', + '/usr/local/etc/spyder', + ) + + if CONDA_PREFIX is not None: + CONDA_PREFIX = CONDA_PREFIX.replace('\\', '/') + SEARCH_PATH += ( + '{}/etc/spyder'.format(CONDA_PREFIX), + ) + + SEARCH_PATH += ( + '{}/etc/spyder'.format(sys.prefix), + ) + + if running_under_pytest(): + search_paths = [] + tmpfolder = str(tempfile.gettempdir()) + for i in range(3): + path = os.path.join(tmpfolder, 'site-config-' + str(i)) + if not os.path.isdir(path): + os.makedirs(path) + search_paths.append(path) + SEARCH_PATH = tuple(search_paths) + + return SEARCH_PATH + + +def get_module_path(modname): + """Return module *modname* base path""" + return osp.abspath(osp.dirname(sys.modules[modname].__file__)) + + +def get_module_data_path(modname, relpath=None, attr_name='DATAPATH'): + """Return module *modname* data path + Note: relpath is ignored if module has an attribute named *attr_name* + + Handles py2exe/cx_Freeze distributions""" + datapath = getattr(sys.modules[modname], attr_name, '') + if datapath: + return datapath + else: + datapath = get_module_path(modname) + parentdir = osp.join(datapath, osp.pardir) + if osp.isfile(parentdir): + # Parent directory is not a directory but the 'library.zip' file: + # this is either a py2exe or a cx_Freeze distribution + datapath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), + modname)) + if relpath is not None: + datapath = osp.abspath(osp.join(datapath, relpath)) + return datapath + + +def get_module_source_path(modname, basename=None): + """Return module *modname* source path + If *basename* is specified, return *modname.basename* path where + *modname* is a package containing the module *basename* + + *basename* is a filename (not a module name), so it must include the + file extension: .py or .pyw + + Handles py2exe/cx_Freeze distributions""" + srcpath = get_module_path(modname) + parentdir = osp.join(srcpath, osp.pardir) + if osp.isfile(parentdir): + # Parent directory is not a directory but the 'library.zip' file: + # this is either a py2exe or a cx_Freeze distribution + srcpath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), + modname)) + if basename is not None: + srcpath = osp.abspath(osp.join(srcpath, basename)) + return srcpath + + +def is_py2exe_or_cx_Freeze(): + """Return True if this is a py2exe/cx_Freeze distribution of Spyder""" + return osp.isfile(osp.join(get_module_path('spyder'), osp.pardir)) + + +def is_pynsist(): + """Return True if this is a pynsist installation of Spyder.""" + base_path = osp.abspath(osp.dirname(__file__)) + pkgs_path = osp.abspath( + osp.join(base_path, '..', '..', '..', 'pkgs')) + if os.environ.get('PYTHONPATH') is not None: + return pkgs_path in os.environ.get('PYTHONPATH') + return False + + +#============================================================================== +# Translations +#============================================================================== +LANG_FILE = get_conf_path('langconfig') +DEFAULT_LANGUAGE = 'en' + +# This needs to be updated every time a new language is added to spyder, and is +# used by the Preferences configuration to populate the Language QComboBox +LANGUAGE_CODES = { + 'en': u'English', + 'fr': u'Français', + 'es': u'Español', + 'hu': u'Magyar', + 'pt_BR': u'Português', + 'ru': u'Русский', + 'zh_CN': u'简体中文', + 'ja': u'日本語', + 'de': u'Deutsch', + 'pl': u'Polski' +} + +# Disabled languages because their translations are outdated or incomplete +DISABLED_LANGUAGES = ['hu', 'pl'] + + +def get_available_translations(): + """ + List available translations for spyder based on the folders found in the + locale folder. This function checks if LANGUAGE_CODES contain the same + information that is found in the 'locale' folder to ensure that when a new + language is added, LANGUAGE_CODES is updated. + """ + locale_path = get_module_data_path("spyder", relpath="locale", + attr_name='LOCALEPATH') + listdir = os.listdir(locale_path) + langs = [d for d in listdir if osp.isdir(osp.join(locale_path, d))] + langs = [DEFAULT_LANGUAGE] + langs + + # Remove disabled languages + langs = list(set(langs) - set(DISABLED_LANGUAGES)) + + # Check that there is a language code available in case a new translation + # is added, to ensure LANGUAGE_CODES is updated. + for lang in langs: + if lang not in LANGUAGE_CODES: + if DEV: + error = ('Update LANGUAGE_CODES (inside config/base.py) if a ' + 'new translation has been added to Spyder') + print(error) # spyder: test-skip + return ['en'] + return langs + + +def get_interface_language(): + """ + If Spyder has a translation available for the locale language, it will + return the version provided by Spyder adjusted for language subdifferences, + otherwise it will return DEFAULT_LANGUAGE. + + Example: + 1.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the + locale is either 'en_US' or 'en' or 'en_UK', this function will return 'en' + + 2.) Spyder provides ('en', 'de', 'fr', 'es' 'hu' and 'pt_BR'), if the + locale is either 'pt' or 'pt_BR', this function will return 'pt_BR' + """ + + # Solves spyder-ide/spyder#3627. + try: + locale_language = locale.getdefaultlocale()[0] + except ValueError: + locale_language = DEFAULT_LANGUAGE + + # Tests expect English as the interface language + if running_under_pytest(): + locale_language = DEFAULT_LANGUAGE + + language = DEFAULT_LANGUAGE + + if locale_language is not None: + spyder_languages = get_available_translations() + for lang in spyder_languages: + if locale_language == lang: + language = locale_language + break + elif (locale_language.startswith(lang) or + lang.startswith(locale_language)): + language = lang + break + + return language + + +def save_lang_conf(value): + """Save language setting to language config file""" + # Needed to avoid an error when trying to save LANG_FILE + # but the operation fails for some reason. + # See spyder-ide/spyder#8807. + try: + with open(LANG_FILE, 'w') as f: + f.write(value) + except EnvironmentError: + pass + + +def load_lang_conf(): + """ + Load language setting from language config file if it exists, otherwise + try to use the local settings if Spyder provides a translation, or + return the default if no translation provided. + """ + if osp.isfile(LANG_FILE): + with open(LANG_FILE, 'r') as f: + lang = f.read() + else: + lang = get_interface_language() + save_lang_conf(lang) + + # Save language again if it's been disabled + if lang.strip('\n') in DISABLED_LANGUAGES: + lang = DEFAULT_LANGUAGE + save_lang_conf(lang) + + return lang + + +def get_translation(modname, dirname=None): + """Return translation callback for module *modname*""" + if dirname is None: + dirname = modname + + def translate_dumb(x): + """Dumb function to not use translations.""" + if not is_unicode(x): + return to_text_string(x, "utf-8") + return x + + locale_path = get_module_data_path(dirname, relpath="locale", + attr_name='LOCALEPATH') + + # If LANG is defined in Ubuntu, a warning message is displayed, + # so in Unix systems we define the LANGUAGE variable. + language = load_lang_conf() + if os.name == 'nt': + # Trying to set LANG on Windows can fail when Spyder is + # run with admin privileges. + # Fixes spyder-ide/spyder#6886. + try: + os.environ["LANG"] = language # Works on Windows + except Exception: + return translate_dumb + else: + os.environ["LANGUAGE"] = language # Works on Linux + + import gettext + try: + _trans = gettext.translation(modname, locale_path, codeset="utf-8") + lgettext = _trans.lgettext + + def translate_gettext(x): + if not PY3 and is_unicode(x): + x = x.encode("utf-8") + y = lgettext(x) + if is_text_string(y) and PY3: + return y + else: + return to_text_string(y, "utf-8") + return translate_gettext + except Exception: + return translate_dumb + + +# Translation callback +_ = get_translation("spyder") + + +#============================================================================== +# Namespace Browser (Variable Explorer) configuration management +#============================================================================== +# Variable explorer display / check all elements data types for sequences: +# (when saving the variable explorer contents, check_all is True, +CHECK_ALL = False # XXX: If True, this should take too much to compute... + +EXCLUDED_NAMES = ['nan', 'inf', 'infty', 'little_endian', 'colorbar_doc', + 'typecodes', '__builtins__', '__main__', '__doc__', 'NaN', + 'Inf', 'Infinity', 'sctypes', 'rcParams', 'rcParamsDefault', + 'sctypeNA', 'typeNA', 'False_', 'True_'] + + +#============================================================================== +# Mac application utilities +#============================================================================== +def running_in_mac_app(pyexec=None): + """ + Check if Python executable is located inside a standalone Mac app. + + If no executable is provided, the default will check `sys.executable`, i.e. + whether Spyder is running from a standalone Mac app. + + This is important for example for the single_instance option and the + interpreter status in the statusbar. + """ + if pyexec is None: + pyexec = sys.executable + + bpath = get_mac_app_bundle_path() + + if bpath and pyexec == osp.join(bpath, 'Contents/MacOS/python'): + return True + else: + return False + + +def get_mac_app_bundle_path(): + """ + Return the full path to the macOS app bundle. Otherwise return None. + + EXECUTABLEPATH environment variable only exists if Spyder is a macOS app + bundle. In which case it will always end with + "/.app/Conents/MacOS/Spyder". + """ + app_exe_path = os.environ.get('EXECUTABLEPATH', None) + if sys.platform == "darwin" and app_exe_path: + return osp.dirname(osp.dirname(osp.dirname(osp.abspath(app_exe_path)))) + else: + return None + + +# ============================================================================= +# Micromamba +# ============================================================================= +def get_spyder_umamba_path(): + """Return the path to the Micromamba executable bundled with Spyder.""" + if running_in_mac_app(): + path = osp.join(osp.dirname(osp.dirname(__file__)), + 'bin', 'micromamba') + elif is_pynsist(): + path = osp.abspath(osp.join(osp.dirname(osp.dirname(__file__)), + 'bin', 'micromamba.exe')) + else: + path = None + + return path + + +#============================================================================== +# Reset config files +#============================================================================== +SAVED_CONFIG_FILES = ('help', 'onlinehelp', 'path', 'pylint.results', + 'spyder.ini', 'temp.py', 'temp.spydata', 'template.py', + 'history.py', 'history_internal.py', 'workingdir', + '.projects', '.spyproject', '.ropeproject', + 'monitor.log', 'monitor_debug.log', 'rope.log', + 'langconfig', 'spyder.lock', + 'config{}spyder.ini'.format(os.sep), + 'config{}transient.ini'.format(os.sep), + 'lsp_root_path', 'plugins') + + +def reset_config_files(): + """Remove all config files""" + print("*** Reset Spyder settings to defaults ***", file=STDERR) + for fname in SAVED_CONFIG_FILES: + cfg_fname = get_conf_path(fname) + if osp.isfile(cfg_fname) or osp.islink(cfg_fname): + os.remove(cfg_fname) + elif osp.isdir(cfg_fname): + shutil.rmtree(cfg_fname) + else: + continue + print("removing:", cfg_fname, file=STDERR) diff --git a/spyder/config/main.py b/spyder/config/main.py index 6248fd22eb2..0d7654fb991 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -1,643 +1,643 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder configuration options. - -Note: Leave this file free of Qt related imports, so that it can be used to -quickly load a user config file. -""" - -import os -import sys - -# Local import -from spyder.config.base import CHECK_ALL, EXCLUDED_NAMES -from spyder.config.fonts import MEDIUM, SANS_SERIF -from spyder.config.utils import IMPORT_EXT -from spyder.config.appearance import APPEARANCE -from spyder.plugins.editor.utils.findtasks import TASKS_PATTERN -from spyder.utils.introspection.module_completion import PREFERRED_MODULES - - -# ============================================================================= -# Main constants -# ============================================================================= -# Find in files exclude patterns -EXCLUDE_PATTERNS = ['*.csv, *.dat, *.log, *.tmp, *.bak, *.orig'] - -# Extensions that should be visible in Spyder's file/project explorers -SHOW_EXT = ['.py', '.ipynb', '.dat', '.pdf', '.png', '.svg', '.md', '.yml', - '.yaml'] - -# Extensions supported by Spyder (Editor or Variable explorer) -USEFUL_EXT = IMPORT_EXT + SHOW_EXT - -# Name filters for file/project explorers (excluding files without extension) -NAME_FILTERS = ['README', 'INSTALL', 'LICENSE', 'CHANGELOG'] -NAME_FILTERS += ['*' + _ext for _ext in USEFUL_EXT if _ext not in NAME_FILTERS] - -# Port used to detect if there is a running instance and to communicate with -# it to open external files -OPEN_FILES_PORT = 21128 - -# OS Specific -WIN = os.name == 'nt' -MAC = sys.platform == 'darwin' -LINUX = sys.platform.startswith('linux') -CTRL = "Meta" if MAC else "Ctrl" - -# Modules to be preloaded for Rope and Jedi -PRELOAD_MDOULES = ', '.join(PREFERRED_MODULES) - - -# ============================================================================= -# Defaults -# ============================================================================= -DEFAULTS = [ - ('main', - { - 'opengl': 'software', - 'single_instance': True, - 'open_files_port': OPEN_FILES_PORT, - 'mac_open_file': False, - 'normal_screen_resolution': True, - 'high_dpi_scaling': False, - 'high_dpi_custom_scale_factor': False, - 'high_dpi_custom_scale_factors': '1.5', - 'vertical_tabs': False, - 'prompt_on_exit': False, - 'panes_locked': True, - 'window/size': (1260, 740), - 'window/position': (10, 10), - 'window/is_maximized': True, - 'window/is_fullscreen': False, - 'window/prefs_dialog_size': (1050, 530), - 'use_custom_margin': True, - 'custom_margin': 0, - 'use_custom_cursor_blinking': False, - 'show_internal_errors': True, - 'check_updates_on_startup': True, - 'cursor/width': 2, - 'completion/size': (300, 180), - 'report_error/remember_token': False, - 'show_dpi_message': True, - }), - ('toolbar', - { - 'enable': True, - 'toolbars_visible': True, - 'last_visible_toolbars': [], - }), - ('statusbar', - { - 'show_status_bar': True, - 'memory_usage/enable': True, - 'memory_usage/timeout': 2000, - 'cpu_usage/enable': False, - 'cpu_usage/timeout': 2000, - 'clock/enable': False, - 'clock/timeout': 1000, - }), - ('quick_layouts', - { - 'place_holder': '', - 'names': [], - 'order': [], - 'active': [], - 'ui_names': [] - }), - ('internal_console', - { - 'max_line_count': 300, - 'working_dir_history': 30, - 'working_dir_adjusttocontents': False, - 'wrap': True, - 'codecompletion/auto': False, - 'external_editor/path': 'SciTE', - 'external_editor/gotoline': '-goto:', - }), - ('main_interpreter', - { - 'default': True, - 'custom': False, - 'umr/enabled': True, - 'umr/verbose': True, - 'umr/namelist': [], - 'custom_interpreters_list': [], - 'custom_interpreter': '', - }), - ('ipython_console', - { - 'show_banner': True, - 'completion_type': 0, - 'show_calltips': True, - 'ask_before_closing': False, - 'show_reset_namespace_warning': True, - 'buffer_size': 500, - 'pylab': True, - 'pylab/autoload': False, - 'pylab/backend': 0, - 'pylab/inline/figure_format': 0, - 'pylab/inline/resolution': 72, - 'pylab/inline/width': 6, - 'pylab/inline/height': 4, - 'pylab/inline/bbox_inches': True, - 'startup/run_lines': '', - 'startup/use_run_file': False, - 'startup/run_file': '', - 'greedy_completer': False, - 'jedi_completer': False, - 'autocall': 0, - 'symbolic_math': False, - 'in_prompt': '', - 'out_prompt': '', - 'show_elapsed_time': False, - 'ask_before_restart': True, - # This is True because there are libraries like Pyomo - # that generate a lot of Command Prompts while running, - # and that's extremely annoying for Windows users. - 'hide_cmd_windows': True, - 'pdb_prevent_closing': True, - 'pdb_ignore_lib': False, - 'pdb_execute_events': True, - 'pdb_use_exclamation_mark': True, - 'pdb_stop_first_line': True - }), - ('variable_explorer', - { - 'check_all': CHECK_ALL, - 'dataframe_format': '.6g', # No percent sign to avoid problems - # with ConfigParser's interpolation - 'excluded_names': EXCLUDED_NAMES, - 'exclude_private': True, - 'exclude_uppercase': False, - 'exclude_capitalized': False, - 'exclude_unsupported': False, - 'exclude_callables_and_modules': True, - 'truncate': True, - 'minmax': False, - 'show_callable_attributes': True, - 'show_special_attributes': False - }), - ('plots', - { - 'mute_inline_plotting': True, - 'show_plot_outline': False, - 'auto_fit_plotting': True - }), - ('editor', - { - 'printer_header/font/family': SANS_SERIF, - 'printer_header/font/size': MEDIUM, - 'printer_header/font/italic': False, - 'printer_header/font/bold': False, - 'wrap': False, - 'wrapflag': True, - 'todo_list': True, - 'realtime_analysis': True, - 'realtime_analysis/timeout': 2500, - 'outline_explorer': True, - 'line_numbers': True, - 'blank_spaces': False, - 'edge_line': True, - 'edge_line_columns': '79', - 'indent_guides': False, - 'code_folding': True, - 'show_code_folding_warning': True, - 'scroll_past_end': False, - 'toolbox_panel': True, - 'close_parentheses': True, - 'close_quotes': True, - 'add_colons': True, - 'auto_unindent': True, - 'indent_chars': '* *', - 'tab_stop_width_spaces': 4, - 'check_eol_chars': True, - 'convert_eol_on_save': False, - 'convert_eol_on_save_to': 'LF', - 'tab_always_indent': False, - 'intelligent_backspace': True, - 'automatic_completions': True, - 'automatic_completions_after_chars': 3, - 'automatic_completions_after_ms': 300, - 'completions_hint': True, - 'completions_hint_after_ms': 500, - 'underline_errors': False, - 'highlight_current_line': True, - 'highlight_current_cell': True, - 'occurrence_highlighting': True, - 'occurrence_highlighting/timeout': 1500, - 'always_remove_trailing_spaces': False, - 'add_newline': False, - 'always_remove_trailing_newlines': False, - 'show_tab_bar': True, - 'show_class_func_dropdown': False, - 'max_recent_files': 20, - 'save_all_before_run': True, - 'focus_to_editor': True, - 'run_cell_copy': False, - 'onsave_analysis': False, - 'autosave_enabled': True, - 'autosave_interval': 60, - 'docstring_type': 'Numpydoc', - 'strip_trailing_spaces_on_modify': False, - }), - ('historylog', - { - 'enable': True, - 'wrap': True, - 'go_to_eof': True, - 'line_numbers': False, - }), - ('help', - { - 'enable': True, - 'max_history_entries': 20, - 'wrap': True, - 'connect/editor': False, - 'connect/ipython_console': False, - 'math': True, - 'automatic_import': True, - 'plain_mode': False, - 'rich_mode': True, - 'show_source': False, - 'locked': False, - }), - ('onlinehelp', - { - 'enable': True, - 'zoom_factor': .8, - 'handle_links': False, - 'max_history_entries': 20, - }), - ('outline_explorer', - { - 'enable': True, - 'show_fullpath': False, - 'show_all_files': False, - 'group_cells': True, - 'sort_files_alphabetically': False, - 'show_comments': True, - 'follow_cursor': True, - 'display_variables': False - }), - ('project_explorer', - { - 'name_filters': NAME_FILTERS, - 'show_all': True, - 'show_hscrollbar': True, - 'max_recent_projects': 10, - 'visible_if_project_open': True, - 'date_column': False, - 'single_click_to_open': False, - 'show_hidden': True, - 'size_column': False, - 'type_column': False, - 'date_column': False - }), - ('explorer', - { - 'enable': True, - 'name_filters': NAME_FILTERS, - 'show_hidden': False, - 'single_click_to_open': False, - 'size_column': False, - 'type_column': False, - 'date_column': True - }), - ('find_in_files', - { - 'enable': True, - 'supported_encodings': ["utf-8", "iso-8859-1", "cp1252"], - 'exclude': EXCLUDE_PATTERNS, - 'exclude_regexp': False, - 'search_text_regexp': False, - 'search_text': [''], - 'search_text_samples': [TASKS_PATTERN], - 'more_options': False, - 'case_sensitive': False, - 'exclude_case_sensitive': False, - 'max_results': 1000, - }), - ('breakpoints', - { - 'enable': True, - }), - ('completions', - { - 'enable': True, - 'kite_call_to_action': False, - 'enable_code_snippets': True, - 'completions_wait_for_ms': 200, - 'enabled_providers': {}, - 'provider_configuration': {}, - 'request_priorities': {} - }), - ('profiler', - { - 'enable': True, - }), - ('pylint', - { - 'enable': True, - 'history_filenames': [], - 'max_entries': 30, - 'project_dir': None, - }), - ('workingdir', - { - 'working_dir_adjusttocontents': False, - 'working_dir_history': 20, - 'console/use_project_or_home_directory': False, - 'console/use_cwd': True, - 'console/use_fixed_directory': False, - 'startup/use_project_or_home_directory': True, - 'startup/use_fixed_directory': False, - }), - ('tours', - { - 'enable': True, - 'show_tour_message': True, - }), - ('shortcuts', - { - # ---- Global ---- - # -- In app/spyder.py - '_/close pane': "Shift+Ctrl+F4", - '_/lock unlock panes': "Shift+Ctrl+F5", - '_/use next layout': "Shift+Alt+PgDown", - '_/use previous layout': "Shift+Alt+PgUp", - '_/maximize pane': "Ctrl+Alt+Shift+M", - '_/fullscreen mode': "F11", - '_/save current layout': "Shift+Alt+S", - '_/layout preferences': "Shift+Alt+P", - '_/spyder documentation': "F1", - '_/restart': "Shift+Alt+R", - '_/quit': "Ctrl+Q", - # -- In plugins/editor - '_/file switcher': 'Ctrl+P', - '_/symbol finder': 'Ctrl+Alt+P', - '_/debug': "Ctrl+F5", - '_/debug step over': "Ctrl+F10", - '_/debug continue': "Ctrl+F12", - '_/debug step into': "Ctrl+F11", - '_/debug step return': "Ctrl+Shift+F11", - '_/debug exit': "Ctrl+Shift+F12", - '_/run': "F5", - '_/configure': "Ctrl+F6", - '_/re-run last script': "F6", - # -- In plugins/init - '_/switch to help': "Ctrl+Shift+H", - '_/switch to outline_explorer': "Ctrl+Shift+O", - '_/switch to editor': "Ctrl+Shift+E", - '_/switch to historylog': "Ctrl+Shift+L", - '_/switch to onlinehelp': "Ctrl+Shift+D", - '_/switch to project_explorer': "Ctrl+Shift+P", - '_/switch to ipython_console': "Ctrl+Shift+I", - '_/switch to variable_explorer': "Ctrl+Shift+V", - '_/switch to find_in_files': "Ctrl+Shift+F", - '_/switch to explorer': "Ctrl+Shift+X", - '_/switch to plots': "Ctrl+Shift+G", - '_/switch to pylint': "Ctrl+Shift+C", - '_/switch to profiler': "Ctrl+Shift+R", - # -- In widgets/findreplace.py - 'find_replace/find text': "Ctrl+F", - 'find_replace/find next': "F3", - 'find_replace/find previous': "Shift+F3", - 'find_replace/replace text': "Ctrl+R", - 'find_replace/hide find and replace': "Escape", - # ---- Editor ---- - # -- In widgets/sourcecode/codeeditor.py - 'editor/code completion': CTRL+'+Space', - 'editor/duplicate line up': ( - "Ctrl+Alt+Up" if WIN else "Shift+Alt+Up"), - 'editor/duplicate line down': ( - "Ctrl+Alt+Down" if WIN else "Shift+Alt+Down"), - 'editor/delete line': 'Ctrl+D', - 'editor/transform to uppercase': 'Ctrl+Shift+U', - 'editor/transform to lowercase': 'Ctrl+U', - 'editor/indent': 'Ctrl+]', - 'editor/unindent': 'Ctrl+[', - 'editor/move line up': "Alt+Up", - 'editor/move line down': "Alt+Down", - 'editor/go to new line': "Ctrl+Shift+Return", - 'editor/go to definition': "Ctrl+G", - 'editor/toggle comment': "Ctrl+1", - 'editor/blockcomment': "Ctrl+4", - 'editor/unblockcomment': "Ctrl+5", - 'editor/start of line': "Meta+A", - 'editor/end of line': "Meta+E", - 'editor/previous line': "Meta+P", - 'editor/next line': "Meta+N", - 'editor/previous char': "Meta+B", - 'editor/next char': "Meta+F", - 'editor/previous word': "Ctrl+Left", - 'editor/next word': "Ctrl+Right", - 'editor/kill to line end': "Meta+K", - 'editor/kill to line start': "Meta+U", - 'editor/yank': 'Meta+Y', - 'editor/rotate kill ring': 'Shift+Meta+Y', - 'editor/kill previous word': 'Meta+Backspace', - 'editor/kill next word': 'Meta+D', - 'editor/start of document': 'Ctrl+Home', - 'editor/end of document': 'Ctrl+End', - 'editor/undo': 'Ctrl+Z', - 'editor/redo': 'Ctrl+Shift+Z', - 'editor/cut': 'Ctrl+X', - 'editor/copy': 'Ctrl+C', - 'editor/paste': 'Ctrl+V', - 'editor/delete': 'Del', - 'editor/select all': "Ctrl+A", - # -- In widgets/editor.py - 'editor/inspect current object': 'Ctrl+I', - 'editor/breakpoint': 'F12', - 'editor/conditional breakpoint': 'Shift+F12', - 'editor/run selection': "F9", - 'editor/run to line': 'Shift+F9', - 'editor/run from line': CTRL + '+F9', - 'editor/go to line': 'Ctrl+L', - 'editor/go to previous file': CTRL + '+Shift+Tab', - 'editor/go to next file': CTRL + '+Tab', - 'editor/cycle to previous file': 'Ctrl+PgUp', - 'editor/cycle to next file': 'Ctrl+PgDown', - 'editor/new file': "Ctrl+N", - 'editor/open last closed':"Ctrl+Shift+T", - 'editor/open file': "Ctrl+O", - 'editor/save file': "Ctrl+S", - 'editor/save all': "Ctrl+Alt+S", - 'editor/save as': 'Ctrl+Shift+S', - 'editor/close all': "Ctrl+Shift+W", - 'editor/last edit location': "Ctrl+Alt+Shift+Left", - 'editor/previous cursor position': "Alt+Left", - 'editor/next cursor position': "Alt+Right", - 'editor/previous warning': "Ctrl+Alt+Shift+,", - 'editor/next warning': "Ctrl+Alt+Shift+.", - 'editor/zoom in 1': "Ctrl++", - 'editor/zoom in 2': "Ctrl+=", - 'editor/zoom out': "Ctrl+-", - 'editor/zoom reset': "Ctrl+0", - 'editor/close file 1': "Ctrl+W", - 'editor/close file 2': "Ctrl+F4", - 'editor/run cell': CTRL + '+Return', - 'editor/run cell and advance': 'Shift+Return', - 'editor/debug cell': 'Alt+Shift+Return', - 'editor/go to next cell': 'Ctrl+Down', - 'editor/go to previous cell': 'Ctrl+Up', - 'editor/re-run last cell': 'Alt+Return', - 'editor/split vertically': "Ctrl+{", - 'editor/split horizontally': "Ctrl+_", - 'editor/close split panel': "Alt+Shift+W", - 'editor/docstring': "Ctrl+Alt+D", - 'editor/autoformatting': "Ctrl+Alt+I", - 'editor/show in external file explorer': '', - # -- In Breakpoints - '_/switch to breakpoints': "Ctrl+Shift+B", - # ---- Consoles (in widgets/shell) ---- - 'console/inspect current object': "Ctrl+I", - 'console/clear shell': "Ctrl+L", - 'console/clear line': "Shift+Escape", - # ---- In Pylint ---- - 'pylint/run analysis': "F8", - # ---- In Profiler ---- - 'profiler/run profiler': "F10", - # ---- In widgets/ipythonconsole/shell.py ---- - 'ipython_console/new tab': "Ctrl+T", - 'ipython_console/reset namespace': "Ctrl+Alt+R", - 'ipython_console/restart kernel': "Ctrl+.", - 'ipython_console/inspect current object': "Ctrl+I", - 'ipython_console/clear shell': "Ctrl+L", - 'ipython_console/clear line': "Shift+Escape", - 'ipython_console/enter array inline': "Ctrl+Alt+M", - 'ipython_console/enter array table': "Ctrl+M", - # ---- In widgets/arraybuider.py ---- - 'array_builder/enter array inline': "Ctrl+Alt+M", - 'array_builder/enter array table': "Ctrl+M", - # ---- In widgets/variableexplorer/arrayeditor.py ---- - 'variable_explorer/copy': 'Ctrl+C', - # ---- In widgets/variableexplorer/namespacebrowser.py ---- - 'variable_explorer/search': 'Ctrl+F', - 'variable_explorer/refresh': 'Ctrl+R', - # ---- In widgets/plots/figurebrowser.py ---- - 'plots/copy': 'Ctrl+C', - 'plots/previous figure': 'Ctrl+PgUp', - 'plots/next figure': 'Ctrl+PgDown', - 'plots/save': 'Ctrl+S', - 'plots/save all': 'Ctrl+Alt+S', - 'plots/close': 'Ctrl+W', - 'plots/close all': 'Ctrl+Shift+W', - 'plots/zoom in': "Ctrl++", - 'plots/zoom out': "Ctrl+-", - # ---- In widgets/explorer ---- - 'explorer/copy file': 'Ctrl+C', - 'explorer/paste file': 'Ctrl+V', - 'explorer/copy absolute path': 'Ctrl+Alt+C', - 'explorer/copy relative path': 'Ctrl+Alt+Shift+C', - # ---- In plugins/findinfiles/plugin ---- - 'find_in_files/find in files': 'Alt+Shift+F', - }), - ('appearance', APPEARANCE), - ] - - -NAME_MAP = { - # Empty container object means use the rest of defaults - 'spyder': [], - # Splitting these files makes sense for projects, we might as well - # apply the same split for the app global config - # These options change on spyder startup or are tied to a specific OS, - # not good for version control - 'transient': [ - ('main', [ - 'completion/size', - 'crash', - 'current_version', - 'historylog_filename', - 'spyder_pythonpath', - 'window/position', - 'window/prefs_dialog_size', - 'window/size', - 'window/state', - ] - ), - ('toolbar', [ - 'last_visible_toolbars', - ] - ), - ('editor', [ - 'autosave_mapping', - 'bookmarks', - 'filenames', - 'layout_settings', - 'recent_files', - 'splitter_state', - ] - ), - ('explorer', [ - 'file_associations', - ]), - ('find_in_files', [ - 'path_history' - 'search_text', - 'exclude_index', - 'search_in_index', - ] - ), - ('main_interpreter', [ - 'custom_interpreters_list', - 'custom_interpreter', - 'executable', - ] - ), - ('onlinehelp', [ - 'zoom_factor', - ] - ), - ('outline_explorer', [ - 'expanded_state', - 'scrollbar_position', - ], - ), - ('project_explorer', [ - 'current_project_path', - 'expanded_state', - 'recent_projects', - 'max_recent_projects', - 'scrollbar_position', - ] - ), - ('quick_layouts', []), # Empty list means use all options - ('run', [ - 'breakpoints', - 'configurations', - 'defaultconfiguration', - 'default/wdir/fixed_directory', - ] - ), - ('workingdir', [ - 'console/fixed_directory', - 'startup/fixed_directory', - ] - ), - ('pylint', [ - 'history_filenames', - ] - ), - ] -} - - -# ============================================================================= -# Config instance -# ============================================================================= -# IMPORTANT NOTES: -# 1. If you want to *change* the default value of a current option, you need to -# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 -# 2. If you want to *remove* options that are no longer needed in our codebase, -# or if you want to *rename* options, then you need to do a MAJOR update in -# version, e.g. from 3.0.0 to 4.0.0 -# 3. You don't need to touch this value if you're just adding a new option -CONF_VERSION = '70.4.0' +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder configuration options. + +Note: Leave this file free of Qt related imports, so that it can be used to +quickly load a user config file. +""" + +import os +import sys + +# Local import +from spyder.config.base import CHECK_ALL, EXCLUDED_NAMES +from spyder.config.fonts import MEDIUM, SANS_SERIF +from spyder.config.utils import IMPORT_EXT +from spyder.config.appearance import APPEARANCE +from spyder.plugins.editor.utils.findtasks import TASKS_PATTERN +from spyder.utils.introspection.module_completion import PREFERRED_MODULES + + +# ============================================================================= +# Main constants +# ============================================================================= +# Find in files exclude patterns +EXCLUDE_PATTERNS = ['*.csv, *.dat, *.log, *.tmp, *.bak, *.orig'] + +# Extensions that should be visible in Spyder's file/project explorers +SHOW_EXT = ['.py', '.ipynb', '.dat', '.pdf', '.png', '.svg', '.md', '.yml', + '.yaml'] + +# Extensions supported by Spyder (Editor or Variable explorer) +USEFUL_EXT = IMPORT_EXT + SHOW_EXT + +# Name filters for file/project explorers (excluding files without extension) +NAME_FILTERS = ['README', 'INSTALL', 'LICENSE', 'CHANGELOG'] +NAME_FILTERS += ['*' + _ext for _ext in USEFUL_EXT if _ext not in NAME_FILTERS] + +# Port used to detect if there is a running instance and to communicate with +# it to open external files +OPEN_FILES_PORT = 21128 + +# OS Specific +WIN = os.name == 'nt' +MAC = sys.platform == 'darwin' +LINUX = sys.platform.startswith('linux') +CTRL = "Meta" if MAC else "Ctrl" + +# Modules to be preloaded for Rope and Jedi +PRELOAD_MDOULES = ', '.join(PREFERRED_MODULES) + + +# ============================================================================= +# Defaults +# ============================================================================= +DEFAULTS = [ + ('main', + { + 'opengl': 'software', + 'single_instance': True, + 'open_files_port': OPEN_FILES_PORT, + 'mac_open_file': False, + 'normal_screen_resolution': True, + 'high_dpi_scaling': False, + 'high_dpi_custom_scale_factor': False, + 'high_dpi_custom_scale_factors': '1.5', + 'vertical_tabs': False, + 'prompt_on_exit': False, + 'panes_locked': True, + 'window/size': (1260, 740), + 'window/position': (10, 10), + 'window/is_maximized': True, + 'window/is_fullscreen': False, + 'window/prefs_dialog_size': (1050, 530), + 'use_custom_margin': True, + 'custom_margin': 0, + 'use_custom_cursor_blinking': False, + 'show_internal_errors': True, + 'check_updates_on_startup': True, + 'cursor/width': 2, + 'completion/size': (300, 180), + 'report_error/remember_token': False, + 'show_dpi_message': True, + }), + ('toolbar', + { + 'enable': True, + 'toolbars_visible': True, + 'last_visible_toolbars': [], + }), + ('statusbar', + { + 'show_status_bar': True, + 'memory_usage/enable': True, + 'memory_usage/timeout': 2000, + 'cpu_usage/enable': False, + 'cpu_usage/timeout': 2000, + 'clock/enable': False, + 'clock/timeout': 1000, + }), + ('quick_layouts', + { + 'place_holder': '', + 'names': [], + 'order': [], + 'active': [], + 'ui_names': [] + }), + ('internal_console', + { + 'max_line_count': 300, + 'working_dir_history': 30, + 'working_dir_adjusttocontents': False, + 'wrap': True, + 'codecompletion/auto': False, + 'external_editor/path': 'SciTE', + 'external_editor/gotoline': '-goto:', + }), + ('main_interpreter', + { + 'default': True, + 'custom': False, + 'umr/enabled': True, + 'umr/verbose': True, + 'umr/namelist': [], + 'custom_interpreters_list': [], + 'custom_interpreter': '', + }), + ('ipython_console', + { + 'show_banner': True, + 'completion_type': 0, + 'show_calltips': True, + 'ask_before_closing': False, + 'show_reset_namespace_warning': True, + 'buffer_size': 500, + 'pylab': True, + 'pylab/autoload': False, + 'pylab/backend': 0, + 'pylab/inline/figure_format': 0, + 'pylab/inline/resolution': 72, + 'pylab/inline/width': 6, + 'pylab/inline/height': 4, + 'pylab/inline/bbox_inches': True, + 'startup/run_lines': '', + 'startup/use_run_file': False, + 'startup/run_file': '', + 'greedy_completer': False, + 'jedi_completer': False, + 'autocall': 0, + 'symbolic_math': False, + 'in_prompt': '', + 'out_prompt': '', + 'show_elapsed_time': False, + 'ask_before_restart': True, + # This is True because there are libraries like Pyomo + # that generate a lot of Command Prompts while running, + # and that's extremely annoying for Windows users. + 'hide_cmd_windows': True, + 'pdb_prevent_closing': True, + 'pdb_ignore_lib': False, + 'pdb_execute_events': True, + 'pdb_use_exclamation_mark': True, + 'pdb_stop_first_line': True + }), + ('variable_explorer', + { + 'check_all': CHECK_ALL, + 'dataframe_format': '.6g', # No percent sign to avoid problems + # with ConfigParser's interpolation + 'excluded_names': EXCLUDED_NAMES, + 'exclude_private': True, + 'exclude_uppercase': False, + 'exclude_capitalized': False, + 'exclude_unsupported': False, + 'exclude_callables_and_modules': True, + 'truncate': True, + 'minmax': False, + 'show_callable_attributes': True, + 'show_special_attributes': False + }), + ('plots', + { + 'mute_inline_plotting': True, + 'show_plot_outline': False, + 'auto_fit_plotting': True + }), + ('editor', + { + 'printer_header/font/family': SANS_SERIF, + 'printer_header/font/size': MEDIUM, + 'printer_header/font/italic': False, + 'printer_header/font/bold': False, + 'wrap': False, + 'wrapflag': True, + 'todo_list': True, + 'realtime_analysis': True, + 'realtime_analysis/timeout': 2500, + 'outline_explorer': True, + 'line_numbers': True, + 'blank_spaces': False, + 'edge_line': True, + 'edge_line_columns': '79', + 'indent_guides': False, + 'code_folding': True, + 'show_code_folding_warning': True, + 'scroll_past_end': False, + 'toolbox_panel': True, + 'close_parentheses': True, + 'close_quotes': True, + 'add_colons': True, + 'auto_unindent': True, + 'indent_chars': '* *', + 'tab_stop_width_spaces': 4, + 'check_eol_chars': True, + 'convert_eol_on_save': False, + 'convert_eol_on_save_to': 'LF', + 'tab_always_indent': False, + 'intelligent_backspace': True, + 'automatic_completions': True, + 'automatic_completions_after_chars': 3, + 'automatic_completions_after_ms': 300, + 'completions_hint': True, + 'completions_hint_after_ms': 500, + 'underline_errors': False, + 'highlight_current_line': True, + 'highlight_current_cell': True, + 'occurrence_highlighting': True, + 'occurrence_highlighting/timeout': 1500, + 'always_remove_trailing_spaces': False, + 'add_newline': False, + 'always_remove_trailing_newlines': False, + 'show_tab_bar': True, + 'show_class_func_dropdown': False, + 'max_recent_files': 20, + 'save_all_before_run': True, + 'focus_to_editor': True, + 'run_cell_copy': False, + 'onsave_analysis': False, + 'autosave_enabled': True, + 'autosave_interval': 60, + 'docstring_type': 'Numpydoc', + 'strip_trailing_spaces_on_modify': False, + }), + ('historylog', + { + 'enable': True, + 'wrap': True, + 'go_to_eof': True, + 'line_numbers': False, + }), + ('help', + { + 'enable': True, + 'max_history_entries': 20, + 'wrap': True, + 'connect/editor': False, + 'connect/ipython_console': False, + 'math': True, + 'automatic_import': True, + 'plain_mode': False, + 'rich_mode': True, + 'show_source': False, + 'locked': False, + }), + ('onlinehelp', + { + 'enable': True, + 'zoom_factor': .8, + 'handle_links': False, + 'max_history_entries': 20, + }), + ('outline_explorer', + { + 'enable': True, + 'show_fullpath': False, + 'show_all_files': False, + 'group_cells': True, + 'sort_files_alphabetically': False, + 'show_comments': True, + 'follow_cursor': True, + 'display_variables': False + }), + ('project_explorer', + { + 'name_filters': NAME_FILTERS, + 'show_all': True, + 'show_hscrollbar': True, + 'max_recent_projects': 10, + 'visible_if_project_open': True, + 'date_column': False, + 'single_click_to_open': False, + 'show_hidden': True, + 'size_column': False, + 'type_column': False, + 'date_column': False + }), + ('explorer', + { + 'enable': True, + 'name_filters': NAME_FILTERS, + 'show_hidden': False, + 'single_click_to_open': False, + 'size_column': False, + 'type_column': False, + 'date_column': True + }), + ('find_in_files', + { + 'enable': True, + 'supported_encodings': ["utf-8", "iso-8859-1", "cp1252"], + 'exclude': EXCLUDE_PATTERNS, + 'exclude_regexp': False, + 'search_text_regexp': False, + 'search_text': [''], + 'search_text_samples': [TASKS_PATTERN], + 'more_options': False, + 'case_sensitive': False, + 'exclude_case_sensitive': False, + 'max_results': 1000, + }), + ('breakpoints', + { + 'enable': True, + }), + ('completions', + { + 'enable': True, + 'kite_call_to_action': False, + 'enable_code_snippets': True, + 'completions_wait_for_ms': 200, + 'enabled_providers': {}, + 'provider_configuration': {}, + 'request_priorities': {} + }), + ('profiler', + { + 'enable': True, + }), + ('pylint', + { + 'enable': True, + 'history_filenames': [], + 'max_entries': 30, + 'project_dir': None, + }), + ('workingdir', + { + 'working_dir_adjusttocontents': False, + 'working_dir_history': 20, + 'console/use_project_or_home_directory': False, + 'console/use_cwd': True, + 'console/use_fixed_directory': False, + 'startup/use_project_or_home_directory': True, + 'startup/use_fixed_directory': False, + }), + ('tours', + { + 'enable': True, + 'show_tour_message': True, + }), + ('shortcuts', + { + # ---- Global ---- + # -- In app/spyder.py + '_/close pane': "Shift+Ctrl+F4", + '_/lock unlock panes': "Shift+Ctrl+F5", + '_/use next layout': "Shift+Alt+PgDown", + '_/use previous layout': "Shift+Alt+PgUp", + '_/maximize pane': "Ctrl+Alt+Shift+M", + '_/fullscreen mode': "F11", + '_/save current layout': "Shift+Alt+S", + '_/layout preferences': "Shift+Alt+P", + '_/spyder documentation': "F1", + '_/restart': "Shift+Alt+R", + '_/quit': "Ctrl+Q", + # -- In plugins/editor + '_/file switcher': 'Ctrl+P', + '_/symbol finder': 'Ctrl+Alt+P', + '_/debug': "Ctrl+F5", + '_/debug step over': "Ctrl+F10", + '_/debug continue': "Ctrl+F12", + '_/debug step into': "Ctrl+F11", + '_/debug step return': "Ctrl+Shift+F11", + '_/debug exit': "Ctrl+Shift+F12", + '_/run': "F5", + '_/configure': "Ctrl+F6", + '_/re-run last script': "F6", + # -- In plugins/init + '_/switch to help': "Ctrl+Shift+H", + '_/switch to outline_explorer': "Ctrl+Shift+O", + '_/switch to editor': "Ctrl+Shift+E", + '_/switch to historylog': "Ctrl+Shift+L", + '_/switch to onlinehelp': "Ctrl+Shift+D", + '_/switch to project_explorer': "Ctrl+Shift+P", + '_/switch to ipython_console': "Ctrl+Shift+I", + '_/switch to variable_explorer': "Ctrl+Shift+V", + '_/switch to find_in_files': "Ctrl+Shift+F", + '_/switch to explorer': "Ctrl+Shift+X", + '_/switch to plots': "Ctrl+Shift+G", + '_/switch to pylint': "Ctrl+Shift+C", + '_/switch to profiler': "Ctrl+Shift+R", + # -- In widgets/findreplace.py + 'find_replace/find text': "Ctrl+F", + 'find_replace/find next': "F3", + 'find_replace/find previous': "Shift+F3", + 'find_replace/replace text': "Ctrl+R", + 'find_replace/hide find and replace': "Escape", + # ---- Editor ---- + # -- In widgets/sourcecode/codeeditor.py + 'editor/code completion': CTRL+'+Space', + 'editor/duplicate line up': ( + "Ctrl+Alt+Up" if WIN else "Shift+Alt+Up"), + 'editor/duplicate line down': ( + "Ctrl+Alt+Down" if WIN else "Shift+Alt+Down"), + 'editor/delete line': 'Ctrl+D', + 'editor/transform to uppercase': 'Ctrl+Shift+U', + 'editor/transform to lowercase': 'Ctrl+U', + 'editor/indent': 'Ctrl+]', + 'editor/unindent': 'Ctrl+[', + 'editor/move line up': "Alt+Up", + 'editor/move line down': "Alt+Down", + 'editor/go to new line': "Ctrl+Shift+Return", + 'editor/go to definition': "Ctrl+G", + 'editor/toggle comment': "Ctrl+1", + 'editor/blockcomment': "Ctrl+4", + 'editor/unblockcomment': "Ctrl+5", + 'editor/start of line': "Meta+A", + 'editor/end of line': "Meta+E", + 'editor/previous line': "Meta+P", + 'editor/next line': "Meta+N", + 'editor/previous char': "Meta+B", + 'editor/next char': "Meta+F", + 'editor/previous word': "Ctrl+Left", + 'editor/next word': "Ctrl+Right", + 'editor/kill to line end': "Meta+K", + 'editor/kill to line start': "Meta+U", + 'editor/yank': 'Meta+Y', + 'editor/rotate kill ring': 'Shift+Meta+Y', + 'editor/kill previous word': 'Meta+Backspace', + 'editor/kill next word': 'Meta+D', + 'editor/start of document': 'Ctrl+Home', + 'editor/end of document': 'Ctrl+End', + 'editor/undo': 'Ctrl+Z', + 'editor/redo': 'Ctrl+Shift+Z', + 'editor/cut': 'Ctrl+X', + 'editor/copy': 'Ctrl+C', + 'editor/paste': 'Ctrl+V', + 'editor/delete': 'Del', + 'editor/select all': "Ctrl+A", + # -- In widgets/editor.py + 'editor/inspect current object': 'Ctrl+I', + 'editor/breakpoint': 'F12', + 'editor/conditional breakpoint': 'Shift+F12', + 'editor/run selection': "F9", + 'editor/run to line': 'Shift+F9', + 'editor/run from line': CTRL + '+F9', + 'editor/go to line': 'Ctrl+L', + 'editor/go to previous file': CTRL + '+Shift+Tab', + 'editor/go to next file': CTRL + '+Tab', + 'editor/cycle to previous file': 'Ctrl+PgUp', + 'editor/cycle to next file': 'Ctrl+PgDown', + 'editor/new file': "Ctrl+N", + 'editor/open last closed':"Ctrl+Shift+T", + 'editor/open file': "Ctrl+O", + 'editor/save file': "Ctrl+S", + 'editor/save all': "Ctrl+Alt+S", + 'editor/save as': 'Ctrl+Shift+S', + 'editor/close all': "Ctrl+Shift+W", + 'editor/last edit location': "Ctrl+Alt+Shift+Left", + 'editor/previous cursor position': "Alt+Left", + 'editor/next cursor position': "Alt+Right", + 'editor/previous warning': "Ctrl+Alt+Shift+,", + 'editor/next warning': "Ctrl+Alt+Shift+.", + 'editor/zoom in 1': "Ctrl++", + 'editor/zoom in 2': "Ctrl+=", + 'editor/zoom out': "Ctrl+-", + 'editor/zoom reset': "Ctrl+0", + 'editor/close file 1': "Ctrl+W", + 'editor/close file 2': "Ctrl+F4", + 'editor/run cell': CTRL + '+Return', + 'editor/run cell and advance': 'Shift+Return', + 'editor/debug cell': 'Alt+Shift+Return', + 'editor/go to next cell': 'Ctrl+Down', + 'editor/go to previous cell': 'Ctrl+Up', + 'editor/re-run last cell': 'Alt+Return', + 'editor/split vertically': "Ctrl+{", + 'editor/split horizontally': "Ctrl+_", + 'editor/close split panel': "Alt+Shift+W", + 'editor/docstring': "Ctrl+Alt+D", + 'editor/autoformatting': "Ctrl+Alt+I", + 'editor/show in external file explorer': '', + # -- In Breakpoints + '_/switch to breakpoints': "Ctrl+Shift+B", + # ---- Consoles (in widgets/shell) ---- + 'console/inspect current object': "Ctrl+I", + 'console/clear shell': "Ctrl+L", + 'console/clear line': "Shift+Escape", + # ---- In Pylint ---- + 'pylint/run analysis': "F8", + # ---- In Profiler ---- + 'profiler/run profiler': "F10", + # ---- In widgets/ipythonconsole/shell.py ---- + 'ipython_console/new tab': "Ctrl+T", + 'ipython_console/reset namespace': "Ctrl+Alt+R", + 'ipython_console/restart kernel': "Ctrl+.", + 'ipython_console/inspect current object': "Ctrl+I", + 'ipython_console/clear shell': "Ctrl+L", + 'ipython_console/clear line': "Shift+Escape", + 'ipython_console/enter array inline': "Ctrl+Alt+M", + 'ipython_console/enter array table': "Ctrl+M", + # ---- In widgets/arraybuider.py ---- + 'array_builder/enter array inline': "Ctrl+Alt+M", + 'array_builder/enter array table': "Ctrl+M", + # ---- In widgets/variableexplorer/arrayeditor.py ---- + 'variable_explorer/copy': 'Ctrl+C', + # ---- In widgets/variableexplorer/namespacebrowser.py ---- + 'variable_explorer/search': 'Ctrl+F', + 'variable_explorer/refresh': 'Ctrl+R', + # ---- In widgets/plots/figurebrowser.py ---- + 'plots/copy': 'Ctrl+C', + 'plots/previous figure': 'Ctrl+PgUp', + 'plots/next figure': 'Ctrl+PgDown', + 'plots/save': 'Ctrl+S', + 'plots/save all': 'Ctrl+Alt+S', + 'plots/close': 'Ctrl+W', + 'plots/close all': 'Ctrl+Shift+W', + 'plots/zoom in': "Ctrl++", + 'plots/zoom out': "Ctrl+-", + # ---- In widgets/explorer ---- + 'explorer/copy file': 'Ctrl+C', + 'explorer/paste file': 'Ctrl+V', + 'explorer/copy absolute path': 'Ctrl+Alt+C', + 'explorer/copy relative path': 'Ctrl+Alt+Shift+C', + # ---- In plugins/findinfiles/plugin ---- + 'find_in_files/find in files': 'Alt+Shift+F', + }), + ('appearance', APPEARANCE), + ] + + +NAME_MAP = { + # Empty container object means use the rest of defaults + 'spyder': [], + # Splitting these files makes sense for projects, we might as well + # apply the same split for the app global config + # These options change on spyder startup or are tied to a specific OS, + # not good for version control + 'transient': [ + ('main', [ + 'completion/size', + 'crash', + 'current_version', + 'historylog_filename', + 'spyder_pythonpath', + 'window/position', + 'window/prefs_dialog_size', + 'window/size', + 'window/state', + ] + ), + ('toolbar', [ + 'last_visible_toolbars', + ] + ), + ('editor', [ + 'autosave_mapping', + 'bookmarks', + 'filenames', + 'layout_settings', + 'recent_files', + 'splitter_state', + ] + ), + ('explorer', [ + 'file_associations', + ]), + ('find_in_files', [ + 'path_history' + 'search_text', + 'exclude_index', + 'search_in_index', + ] + ), + ('main_interpreter', [ + 'custom_interpreters_list', + 'custom_interpreter', + 'executable', + ] + ), + ('onlinehelp', [ + 'zoom_factor', + ] + ), + ('outline_explorer', [ + 'expanded_state', + 'scrollbar_position', + ], + ), + ('project_explorer', [ + 'current_project_path', + 'expanded_state', + 'recent_projects', + 'max_recent_projects', + 'scrollbar_position', + ] + ), + ('quick_layouts', []), # Empty list means use all options + ('run', [ + 'breakpoints', + 'configurations', + 'defaultconfiguration', + 'default/wdir/fixed_directory', + ] + ), + ('workingdir', [ + 'console/fixed_directory', + 'startup/fixed_directory', + ] + ), + ('pylint', [ + 'history_filenames', + ] + ), + ] +} + + +# ============================================================================= +# Config instance +# ============================================================================= +# IMPORTANT NOTES: +# 1. If you want to *change* the default value of a current option, you need to +# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 +# 2. If you want to *remove* options that are no longer needed in our codebase, +# or if you want to *rename* options, then you need to do a MAJOR update in +# version, e.g. from 3.0.0 to 4.0.0 +# 3. You don't need to touch this value if you're just adding a new option +CONF_VERSION = '70.4.0' diff --git a/spyder/config/manager.py b/spyder/config/manager.py index fdf94f168c3..fca88026148 100644 --- a/spyder/config/manager.py +++ b/spyder/config/manager.py @@ -1,669 +1,669 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Configuration manager providing access to user/site/project configuration. -""" - -# Standard library imports -import logging -import os -import os.path as osp -from typing import Any, Dict, Optional, Set -import weakref - -# Local imports -from spyder.api.utils import PrefixedTuple -from spyder.config.base import ( - _, get_conf_paths, get_conf_path, get_home_dir, reset_config_files) -from spyder.config.main import CONF_VERSION, DEFAULTS, NAME_MAP -from spyder.config.types import ConfigurationKey, ConfigurationObserver -from spyder.config.user import UserConfig, MultiUserConfig, NoDefault, cp -from spyder.utils.programs import check_version - - -logger = logging.getLogger(__name__) - -EXTRA_VALID_SHORTCUT_CONTEXTS = [ - '_', - 'array_builder', - 'console', - 'find_replace', -] - - -class ConfigurationManager(object): - """ - Configuration manager to provide access to user/site/project config. - """ - - def __init__(self, parent=None, active_project_callback=None, - conf_path=None): - """ - Configuration manager to provide access to user/site/project config. - """ - path = conf_path if conf_path else self.get_user_config_path() - if not osp.isdir(path): - os.makedirs(path) - - # Site configuration defines the system defaults if a file - # is found in the site location - conf_paths = get_conf_paths() - site_defaults = DEFAULTS - for conf_path in reversed(conf_paths): - conf_fpath = os.path.join(conf_path, 'spyder.ini') - if os.path.isfile(conf_fpath): - site_config = UserConfig( - 'spyder', - path=conf_path, - defaults=site_defaults, - load=False, - version=CONF_VERSION, - backup=False, - raw_mode=True, - remove_obsolete=False, - ) - site_defaults = site_config.to_list() - - self._parent = parent - self._active_project_callback = active_project_callback - self._user_config = MultiUserConfig( - NAME_MAP, - path=path, - defaults=site_defaults, - load=True, - version=CONF_VERSION, - backup=True, - raw_mode=True, - remove_obsolete=False, - ) - - # This is useful to know in order to execute certain operations when - # bumping CONF_VERSION - self.old_spyder_version = ( - self._user_config._configs_map['spyder']._old_version) - - # Store plugin configurations when CONF_FILE = True - self._plugin_configs = {} - - # TODO: To be implemented in following PR - self._project_configs = {} # Cache project configurations - - # Object observer map - # This dict maps from a configuration key (str/tuple) to a set - # of objects that should be notified on changes to the corresponding - # subscription key per section. The observer objects must be hashable. - # - # type: Dict[ConfigurationKey, Dict[str, Set[ConfigurationObserver]]] - self._observers = {} - - # Set of suscription keys per observer object - # This dict maps from a observer object to the set of configuration - # keys that the object is subscribed to per section. - # - # type: Dict[ConfigurationObserver, Dict[str, Set[ConfigurationKey]]] - self._observer_map_keys = weakref.WeakKeyDictionary() - - # Setup - self.remove_deprecated_config_locations() - - def unregister_plugin(self, plugin_instance): - conf_section = plugin_instance.CONF_SECTION - if conf_section in self._plugin_configs: - self._plugin_configs.pop(conf_section, None) - - def register_plugin(self, plugin_class): - """Register plugin configuration.""" - conf_section = plugin_class.CONF_SECTION - if plugin_class.CONF_FILE and conf_section: - path = self.get_plugin_config_path(conf_section) - version = plugin_class.CONF_VERSION - version = version if version else '0.0.0' - name_map = plugin_class._CONF_NAME_MAP - name_map = name_map if name_map else {'spyder': []} - defaults = plugin_class.CONF_DEFAULTS - - if conf_section in self._plugin_configs: - raise RuntimeError('A plugin with section "{}" already ' - 'exists!'.format(conf_section)) - - plugin_config = MultiUserConfig( - name_map, - path=path, - defaults=defaults, - load=True, - version=version, - backup=True, - raw_mode=True, - remove_obsolete=False, - external_plugin=True - ) - - # Recreate external plugin configs to deal with part two - # (the shortcut conflicts) of spyder-ide/spyder#11132 - if check_version(self.old_spyder_version, '54.0.0', '<'): - # Remove all previous .ini files - try: - plugin_config.cleanup() - except EnvironmentError: - pass - - # Recreate config - plugin_config = MultiUserConfig( - name_map, - path=path, - defaults=defaults, - load=True, - version=version, - backup=True, - raw_mode=True, - remove_obsolete=False, - external_plugin=True - ) - - self._plugin_configs[conf_section] = (plugin_class, plugin_config) - - def remove_deprecated_config_locations(self): - """Removing old .spyder.ini location.""" - old_location = osp.join(get_home_dir(), '.spyder.ini') - if osp.isfile(old_location): - os.remove(old_location) - - def get_active_conf(self, section=None): - """ - Return the active user or project configuration for plugin. - """ - # Add a check for shortcuts! - if section is None: - config = self._user_config - elif section in self._plugin_configs: - _, config = self._plugin_configs[section] - else: - # TODO: implement project configuration on the following PR - config = self._user_config - - return config - - def get_user_config_path(self): - """Return the user configuration path.""" - base_path = get_conf_path() - path = osp.join(base_path, 'config') - if not osp.isdir(path): - os.makedirs(path) - - return path - - def get_plugin_config_path(self, plugin_folder): - """Return the plugin configuration path.""" - base_path = get_conf_path() - path = osp.join(base_path, 'plugins') - if plugin_folder is None: - raise RuntimeError('Plugin needs to define `CONF_SECTION`!') - path = osp.join(base_path, 'plugins', plugin_folder) - if not osp.isdir(path): - os.makedirs(path) - - return path - - # --- Observer pattern - # ------------------------------------------------------------------------ - def observe_configuration(self, - observer: ConfigurationObserver, - section: str, - option: Optional[ConfigurationKey] = None): - """ - Register an `observer` object to listen for changes in the option - `option` on the configuration `section`. - - Parameters - ---------- - observer: ConfigurationObserver - Object that conforms to the `ConfigurationObserver` protocol. - section: str - Name of the configuration section that contains the option - :param:`option` - option: Optional[ConfigurationKey] - Name of the option on the configuration section :param:`section` - that the object is going to suscribe to. If None, the observer - will observe any changes on any of the options of the configuration - section. - """ - section_sets = self._observers.get(section, {}) - option = option if option is not None else '__section' - - option_set = section_sets.get(option, weakref.WeakSet()) - option_set |= {observer} - - section_sets[option] = option_set - self._observers[section] = section_sets - - observer_section_sets = self._observer_map_keys.get(observer, {}) - section_set = observer_section_sets.get(section, set({})) - section_set |= {option} - - observer_section_sets[section] = section_set - self._observer_map_keys[observer] = observer_section_sets - - def unobserve_configuration(self, - observer: ConfigurationObserver, - section: Optional[str] = None, - option: Optional[ConfigurationKey] = None): - """ - Remove an observer to prevent it to receive further changes - on the values of the option `option` of the configuration section - `section`. - - Parameters - ---------- - observer: ConfigurationObserver - Object that conforms to the `ConfigurationObserver` protocol. - section: Optional[str] - Name of the configuration section that contains the option - :param:`option`. If None, the observer is unregistered from all - options for all sections that it has registered to. - option: Optional[ConfigurationKey] - Name of the configuration option on the configuration - :param:`section` that the observer is going to be unsubscribed - from. If None, the observer is unregistered from all the options of - the section `section`. - """ - if observer not in self._observer_map_keys: - return - - observer_sections = self._observer_map_keys[observer] - if section is not None: - section_options = observer_sections[section] - section_observers = self._observers[section] - if option is None: - for option in section_options: - option_observers = section_observers[option] - option_observers.remove(observer) - observer_sections.pop(section) - else: - option_observers = section_observers[option] - option_observers.remove(observer) - else: - for section in observer_sections: - section_options = observer_sections[section] - section_observers = self._observers[section] - for option in section_options: - option_observers = section_observers[option] - option_observers.remove(observer) - self._observer_map_keys.pop(observer) - - def notify_all_observers(self): - """ - Notify all the observers subscribed to all the sections and options. - """ - for section in self._observers: - self.notify_section_all_observers(section) - - def notify_observers(self, - section: str, - option: ConfigurationKey, - recursive_notification: bool = True): - """ - Notify observers of a change in the option `option` of configuration - section `section`. - - Parameters - ---------- - section: str - Name of the configuration section whose option did changed. - option: ConfigurationKey - Name/Path to the option that did changed. - recursive_notification: bool - If True, all objects that observe all changes on the - configuration section and objects that observe partial tuple paths - are notified. For example if the option `opt` of section `sec` - changes, then the observers for section `sec` are notified. - Likewise, if the option `(a, b, c)` changes, then observers for - `(a, b, c)`, `(a, b)` and a are notified as well. - """ - if recursive_notification: - # Notify to section listeners - self._notify_section(section) - - if isinstance(option, tuple) and recursive_notification: - # Notify to partial tuple observers - # e.g., If the option is (a, b, c), observers subscribed to - # (a, b, c), (a, b) and a are notified - option_list = list(option) - while option_list != []: - tuple_option = tuple(option_list) - if len(option_list) == 1: - tuple_option = tuple_option[0] - - value = self.get(section, tuple_option) - self._notify_option(section, tuple_option, value) - option_list.pop(-1) - else: - if option == '__section': - self._notify_section(section) - else: - value = self.get(section, option) - self._notify_option(section, option, value) - - def _notify_option(self, section: str, option: ConfigurationKey, - value: Any): - section_observers = self._observers.get(section, {}) - option_observers = section_observers.get(option, set({})) - if len(option_observers) > 0: - logger.debug('Sending notification to observers of ' - f'{option} in configuration section {section}') - for observer in list(option_observers): - try: - observer.on_configuration_change(option, section, value) - except RuntimeError: - # Prevent errors when Qt Objects are destroyed - self.unobserve_configuration(observer) - - def _notify_section(self, section: str): - section_values = dict(self.items(section) or []) - self._notify_option(section, '__section', section_values) - - def notify_section_all_observers(self, section: str): - """Notify all the observers subscribed to any option of a section.""" - option_observers = self._observers[section] - section_prefix = PrefixedTuple() - # Notify section observers - CONF.notify_observers(section, '__section') - for option in option_observers: - if isinstance(option, tuple): - section_prefix.add_path(option) - else: - try: - self.notify_observers(section, option) - except cp.NoOptionError: - # Skip notification if the option/section does not exist. - # This prevents unexpected errors in the test suite. - pass - # Notify prefixed observers - for prefix in section_prefix: - try: - self.notify_observers(section, prefix) - except cp.NoOptionError: - # See above explanation. - pass - - # --- Projects - # ------------------------------------------------------------------------ - def register_config(self, root_path, config): - """ - Register configuration with `root_path`. - - Useful for registering project configurations as they are opened. - """ - if self.is_project_root(root_path): - if root_path not in self._project_configs: - self._project_configs[root_path] = config - else: - # Validate which are valid site config locations - self._site_config = config - - def get_active_project(self): - """Return the `root_path` of the current active project.""" - callback = self._active_project_callback - if self._active_project_callback: - return callback() - - def is_project_root(self, root_path): - """Check if `root_path` corresponds to a valid spyder project.""" - return False - - def get_project_config_path(self, project_root): - """Return the project configuration path.""" - path = osp.join(project_root, '.spyproj', 'config') - if not osp.isdir(path): - os.makedirs(path) - - # MultiUserConf/UserConf interface - # ------------------------------------------------------------------------ - def items(self, section): - """Return all the items option/values for the given section.""" - config = self.get_active_conf(section) - return config.items(section) - - def options(self, section): - """Return all the options for the given section.""" - config = self.get_active_conf(section) - return config.options(section) - - def get(self, section, option, default=NoDefault): - """ - Get an `option` on a given `section`. - - If section is None, the `option` is requested from default section. - """ - config = self.get_active_conf(section) - if isinstance(option, tuple) and len(option) == 1: - option = option[0] - - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_conf = config.get( - section=section, option=base_option, default={}) - next_ptr = base_conf - for opt in intermediate_options: - next_ptr = next_ptr.get(opt, {}) - - value = next_ptr.get(last_option, None) - if value is None: - value = default - if default is NoDefault: - raise cp.NoOptionError(option, section) - else: - value = config.get(section=section, option=option, default=default) - return value - - def set(self, section, option, value, verbose=False, save=True, - recursive_notification=True, notification=True): - """ - Set an `option` on a given `section`. - - If section is None, the `option` is added to the default section. - """ - original_option = option - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_conf = self.get(section, base_option, {}) - conf_ptr = base_conf - for opt in intermediate_options: - next_ptr = conf_ptr.get(opt, {}) - conf_ptr[opt] = next_ptr - conf_ptr = next_ptr - - conf_ptr[last_option] = value - value = base_conf - option = base_option - - config = self.get_active_conf(section) - config.set(section=section, option=option, value=value, - verbose=verbose, save=save) - if notification: - self.notify_observers( - section, original_option, recursive_notification) - - def get_default(self, section, option): - """ - Get Default value for a given `section` and `option`. - - This is useful for type checking in `get` method. - """ - config = self.get_active_conf(section) - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_default = config.get_default(section, base_option) - conf_ptr = base_default - for opt in intermediate_options: - conf_ptr = conf_ptr[opt] - - return conf_ptr[last_option] - - return config.get_default(section, option) - - def remove_section(self, section): - """Remove `section` and all options within it.""" - config = self.get_active_conf(section) - config.remove_section(section) - - def remove_option(self, section, option): - """Remove `option` from `section`.""" - config = self.get_active_conf(section) - if isinstance(option, tuple): - base_option = option[0] - intermediate_options = option[1:-1] - last_option = option[-1] - - base_conf = self.get(section, base_option) - conf_ptr = base_conf - for opt in intermediate_options: - conf_ptr = conf_ptr[opt] - conf_ptr.pop(last_option) - self.set(section, base_option) - self.notify_observers(section, base_option) - else: - config.remove_option(section, option) - - def reset_to_defaults(self, section=None, notification=True): - """Reset config to Default values.""" - config = self.get_active_conf(section) - config.reset_to_defaults(section=section) - if notification: - if section is not None: - self.notify_section_all_observers(section) - else: - self.notify_all_observers() - - # Shortcut configuration management - # ------------------------------------------------------------------------ - def _get_shortcut_config(self, context, plugin_name=None): - """ - Return the shortcut configuration for global or plugin configs. - - Context must be either '_' for global or the name of a plugin. - """ - context = context.lower() - config = self._user_config - - if plugin_name in self._plugin_configs: - plugin_class, config = self._plugin_configs[plugin_name] - - # Check if plugin has a separate file - if not plugin_class.CONF_FILE: - config = self._user_config - - elif context in self._plugin_configs: - plugin_class, config = self._plugin_configs[context] - - # Check if plugin has a separate file - if not plugin_class.CONF_FILE: - config = self._user_config - - elif context in (self._user_config.sections() - + EXTRA_VALID_SHORTCUT_CONTEXTS): - config = self._user_config - else: - raise ValueError(_("Shortcut context must match '_' or the " - "plugin `CONF_SECTION`!")) - - return config - - def get_shortcut(self, context, name, plugin_name=None): - """ - Get keyboard shortcut (key sequence string). - - Context must be either '_' for global or the name of a plugin. - """ - config = self._get_shortcut_config(context, plugin_name) - return config.get('shortcuts', context + '/' + name.lower()) - - def set_shortcut(self, context, name, keystr, plugin_name=None): - """ - Set keyboard shortcut (key sequence string). - - Context must be either '_' for global or the name of a plugin. - """ - config = self._get_shortcut_config(context, plugin_name) - config.set('shortcuts', context + '/' + name, keystr) - - def config_shortcut(self, action, context, name, parent): - """ - Create a Shortcut namedtuple for a widget. - - The data contained in this tuple will be registered in our shortcuts - preferences page. - """ - # We only import on demand to avoid loading Qt modules - from spyder.config.gui import _config_shortcut - - keystr = self.get_shortcut(context, name) - sc = _config_shortcut(action, context, name, keystr, parent) - return sc - - def iter_shortcuts(self): - """Iterate over keyboard shortcuts.""" - for context_name, keystr in self._user_config.items('shortcuts'): - if context_name == 'enable': - continue - - if 'additional_configuration' not in context_name: - context, name = context_name.split('/', 1) - yield context, name, keystr - - for _, (_, plugin_config) in self._plugin_configs.items(): - items = plugin_config.items('shortcuts') - if items: - for context_name, keystr in items: - context, name = context_name.split('/', 1) - yield context, name, keystr - - def reset_shortcuts(self): - """Reset keyboard shortcuts to default values.""" - self._user_config.reset_to_defaults(section='shortcuts') - for _, (_, plugin_config) in self._plugin_configs.items(): - # TODO: check if the section exists? - plugin_config.reset_to_defaults(section='shortcuts') - - -try: - CONF = ConfigurationManager() -except Exception: - from qtpy.QtWidgets import QApplication, QMessageBox - - # Check if there's an app already running - app = QApplication.instance() - - # Create app, if there's none, in order to display the message below. - # NOTE: Don't use the functions we have to create a QApplication here - # because they could import CONF at some point, which would make this - # fallback fail. - # See issue spyder-ide/spyder#17889 - if app is None: - app = QApplication(['Spyder']) - app.setApplicationName('Spyder') - - reset_reply = QMessageBox.critical( - None, 'Spyder', - _("There was an error while loading Spyder configuration options. " - "You need to reset them for Spyder to be able to launch.\n\n" - "Do you want to proceed?"), - QMessageBox.Yes, QMessageBox.No) - if reset_reply == QMessageBox.Yes: - reset_config_files() - QMessageBox.information( - None, 'Spyder', - _("Spyder configuration files resetted!")) - os._exit(0) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Configuration manager providing access to user/site/project configuration. +""" + +# Standard library imports +import logging +import os +import os.path as osp +from typing import Any, Dict, Optional, Set +import weakref + +# Local imports +from spyder.api.utils import PrefixedTuple +from spyder.config.base import ( + _, get_conf_paths, get_conf_path, get_home_dir, reset_config_files) +from spyder.config.main import CONF_VERSION, DEFAULTS, NAME_MAP +from spyder.config.types import ConfigurationKey, ConfigurationObserver +from spyder.config.user import UserConfig, MultiUserConfig, NoDefault, cp +from spyder.utils.programs import check_version + + +logger = logging.getLogger(__name__) + +EXTRA_VALID_SHORTCUT_CONTEXTS = [ + '_', + 'array_builder', + 'console', + 'find_replace', +] + + +class ConfigurationManager(object): + """ + Configuration manager to provide access to user/site/project config. + """ + + def __init__(self, parent=None, active_project_callback=None, + conf_path=None): + """ + Configuration manager to provide access to user/site/project config. + """ + path = conf_path if conf_path else self.get_user_config_path() + if not osp.isdir(path): + os.makedirs(path) + + # Site configuration defines the system defaults if a file + # is found in the site location + conf_paths = get_conf_paths() + site_defaults = DEFAULTS + for conf_path in reversed(conf_paths): + conf_fpath = os.path.join(conf_path, 'spyder.ini') + if os.path.isfile(conf_fpath): + site_config = UserConfig( + 'spyder', + path=conf_path, + defaults=site_defaults, + load=False, + version=CONF_VERSION, + backup=False, + raw_mode=True, + remove_obsolete=False, + ) + site_defaults = site_config.to_list() + + self._parent = parent + self._active_project_callback = active_project_callback + self._user_config = MultiUserConfig( + NAME_MAP, + path=path, + defaults=site_defaults, + load=True, + version=CONF_VERSION, + backup=True, + raw_mode=True, + remove_obsolete=False, + ) + + # This is useful to know in order to execute certain operations when + # bumping CONF_VERSION + self.old_spyder_version = ( + self._user_config._configs_map['spyder']._old_version) + + # Store plugin configurations when CONF_FILE = True + self._plugin_configs = {} + + # TODO: To be implemented in following PR + self._project_configs = {} # Cache project configurations + + # Object observer map + # This dict maps from a configuration key (str/tuple) to a set + # of objects that should be notified on changes to the corresponding + # subscription key per section. The observer objects must be hashable. + # + # type: Dict[ConfigurationKey, Dict[str, Set[ConfigurationObserver]]] + self._observers = {} + + # Set of suscription keys per observer object + # This dict maps from a observer object to the set of configuration + # keys that the object is subscribed to per section. + # + # type: Dict[ConfigurationObserver, Dict[str, Set[ConfigurationKey]]] + self._observer_map_keys = weakref.WeakKeyDictionary() + + # Setup + self.remove_deprecated_config_locations() + + def unregister_plugin(self, plugin_instance): + conf_section = plugin_instance.CONF_SECTION + if conf_section in self._plugin_configs: + self._plugin_configs.pop(conf_section, None) + + def register_plugin(self, plugin_class): + """Register plugin configuration.""" + conf_section = plugin_class.CONF_SECTION + if plugin_class.CONF_FILE and conf_section: + path = self.get_plugin_config_path(conf_section) + version = plugin_class.CONF_VERSION + version = version if version else '0.0.0' + name_map = plugin_class._CONF_NAME_MAP + name_map = name_map if name_map else {'spyder': []} + defaults = plugin_class.CONF_DEFAULTS + + if conf_section in self._plugin_configs: + raise RuntimeError('A plugin with section "{}" already ' + 'exists!'.format(conf_section)) + + plugin_config = MultiUserConfig( + name_map, + path=path, + defaults=defaults, + load=True, + version=version, + backup=True, + raw_mode=True, + remove_obsolete=False, + external_plugin=True + ) + + # Recreate external plugin configs to deal with part two + # (the shortcut conflicts) of spyder-ide/spyder#11132 + if check_version(self.old_spyder_version, '54.0.0', '<'): + # Remove all previous .ini files + try: + plugin_config.cleanup() + except EnvironmentError: + pass + + # Recreate config + plugin_config = MultiUserConfig( + name_map, + path=path, + defaults=defaults, + load=True, + version=version, + backup=True, + raw_mode=True, + remove_obsolete=False, + external_plugin=True + ) + + self._plugin_configs[conf_section] = (plugin_class, plugin_config) + + def remove_deprecated_config_locations(self): + """Removing old .spyder.ini location.""" + old_location = osp.join(get_home_dir(), '.spyder.ini') + if osp.isfile(old_location): + os.remove(old_location) + + def get_active_conf(self, section=None): + """ + Return the active user or project configuration for plugin. + """ + # Add a check for shortcuts! + if section is None: + config = self._user_config + elif section in self._plugin_configs: + _, config = self._plugin_configs[section] + else: + # TODO: implement project configuration on the following PR + config = self._user_config + + return config + + def get_user_config_path(self): + """Return the user configuration path.""" + base_path = get_conf_path() + path = osp.join(base_path, 'config') + if not osp.isdir(path): + os.makedirs(path) + + return path + + def get_plugin_config_path(self, plugin_folder): + """Return the plugin configuration path.""" + base_path = get_conf_path() + path = osp.join(base_path, 'plugins') + if plugin_folder is None: + raise RuntimeError('Plugin needs to define `CONF_SECTION`!') + path = osp.join(base_path, 'plugins', plugin_folder) + if not osp.isdir(path): + os.makedirs(path) + + return path + + # --- Observer pattern + # ------------------------------------------------------------------------ + def observe_configuration(self, + observer: ConfigurationObserver, + section: str, + option: Optional[ConfigurationKey] = None): + """ + Register an `observer` object to listen for changes in the option + `option` on the configuration `section`. + + Parameters + ---------- + observer: ConfigurationObserver + Object that conforms to the `ConfigurationObserver` protocol. + section: str + Name of the configuration section that contains the option + :param:`option` + option: Optional[ConfigurationKey] + Name of the option on the configuration section :param:`section` + that the object is going to suscribe to. If None, the observer + will observe any changes on any of the options of the configuration + section. + """ + section_sets = self._observers.get(section, {}) + option = option if option is not None else '__section' + + option_set = section_sets.get(option, weakref.WeakSet()) + option_set |= {observer} + + section_sets[option] = option_set + self._observers[section] = section_sets + + observer_section_sets = self._observer_map_keys.get(observer, {}) + section_set = observer_section_sets.get(section, set({})) + section_set |= {option} + + observer_section_sets[section] = section_set + self._observer_map_keys[observer] = observer_section_sets + + def unobserve_configuration(self, + observer: ConfigurationObserver, + section: Optional[str] = None, + option: Optional[ConfigurationKey] = None): + """ + Remove an observer to prevent it to receive further changes + on the values of the option `option` of the configuration section + `section`. + + Parameters + ---------- + observer: ConfigurationObserver + Object that conforms to the `ConfigurationObserver` protocol. + section: Optional[str] + Name of the configuration section that contains the option + :param:`option`. If None, the observer is unregistered from all + options for all sections that it has registered to. + option: Optional[ConfigurationKey] + Name of the configuration option on the configuration + :param:`section` that the observer is going to be unsubscribed + from. If None, the observer is unregistered from all the options of + the section `section`. + """ + if observer not in self._observer_map_keys: + return + + observer_sections = self._observer_map_keys[observer] + if section is not None: + section_options = observer_sections[section] + section_observers = self._observers[section] + if option is None: + for option in section_options: + option_observers = section_observers[option] + option_observers.remove(observer) + observer_sections.pop(section) + else: + option_observers = section_observers[option] + option_observers.remove(observer) + else: + for section in observer_sections: + section_options = observer_sections[section] + section_observers = self._observers[section] + for option in section_options: + option_observers = section_observers[option] + option_observers.remove(observer) + self._observer_map_keys.pop(observer) + + def notify_all_observers(self): + """ + Notify all the observers subscribed to all the sections and options. + """ + for section in self._observers: + self.notify_section_all_observers(section) + + def notify_observers(self, + section: str, + option: ConfigurationKey, + recursive_notification: bool = True): + """ + Notify observers of a change in the option `option` of configuration + section `section`. + + Parameters + ---------- + section: str + Name of the configuration section whose option did changed. + option: ConfigurationKey + Name/Path to the option that did changed. + recursive_notification: bool + If True, all objects that observe all changes on the + configuration section and objects that observe partial tuple paths + are notified. For example if the option `opt` of section `sec` + changes, then the observers for section `sec` are notified. + Likewise, if the option `(a, b, c)` changes, then observers for + `(a, b, c)`, `(a, b)` and a are notified as well. + """ + if recursive_notification: + # Notify to section listeners + self._notify_section(section) + + if isinstance(option, tuple) and recursive_notification: + # Notify to partial tuple observers + # e.g., If the option is (a, b, c), observers subscribed to + # (a, b, c), (a, b) and a are notified + option_list = list(option) + while option_list != []: + tuple_option = tuple(option_list) + if len(option_list) == 1: + tuple_option = tuple_option[0] + + value = self.get(section, tuple_option) + self._notify_option(section, tuple_option, value) + option_list.pop(-1) + else: + if option == '__section': + self._notify_section(section) + else: + value = self.get(section, option) + self._notify_option(section, option, value) + + def _notify_option(self, section: str, option: ConfigurationKey, + value: Any): + section_observers = self._observers.get(section, {}) + option_observers = section_observers.get(option, set({})) + if len(option_observers) > 0: + logger.debug('Sending notification to observers of ' + f'{option} in configuration section {section}') + for observer in list(option_observers): + try: + observer.on_configuration_change(option, section, value) + except RuntimeError: + # Prevent errors when Qt Objects are destroyed + self.unobserve_configuration(observer) + + def _notify_section(self, section: str): + section_values = dict(self.items(section) or []) + self._notify_option(section, '__section', section_values) + + def notify_section_all_observers(self, section: str): + """Notify all the observers subscribed to any option of a section.""" + option_observers = self._observers[section] + section_prefix = PrefixedTuple() + # Notify section observers + CONF.notify_observers(section, '__section') + for option in option_observers: + if isinstance(option, tuple): + section_prefix.add_path(option) + else: + try: + self.notify_observers(section, option) + except cp.NoOptionError: + # Skip notification if the option/section does not exist. + # This prevents unexpected errors in the test suite. + pass + # Notify prefixed observers + for prefix in section_prefix: + try: + self.notify_observers(section, prefix) + except cp.NoOptionError: + # See above explanation. + pass + + # --- Projects + # ------------------------------------------------------------------------ + def register_config(self, root_path, config): + """ + Register configuration with `root_path`. + + Useful for registering project configurations as they are opened. + """ + if self.is_project_root(root_path): + if root_path not in self._project_configs: + self._project_configs[root_path] = config + else: + # Validate which are valid site config locations + self._site_config = config + + def get_active_project(self): + """Return the `root_path` of the current active project.""" + callback = self._active_project_callback + if self._active_project_callback: + return callback() + + def is_project_root(self, root_path): + """Check if `root_path` corresponds to a valid spyder project.""" + return False + + def get_project_config_path(self, project_root): + """Return the project configuration path.""" + path = osp.join(project_root, '.spyproj', 'config') + if not osp.isdir(path): + os.makedirs(path) + + # MultiUserConf/UserConf interface + # ------------------------------------------------------------------------ + def items(self, section): + """Return all the items option/values for the given section.""" + config = self.get_active_conf(section) + return config.items(section) + + def options(self, section): + """Return all the options for the given section.""" + config = self.get_active_conf(section) + return config.options(section) + + def get(self, section, option, default=NoDefault): + """ + Get an `option` on a given `section`. + + If section is None, the `option` is requested from default section. + """ + config = self.get_active_conf(section) + if isinstance(option, tuple) and len(option) == 1: + option = option[0] + + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_conf = config.get( + section=section, option=base_option, default={}) + next_ptr = base_conf + for opt in intermediate_options: + next_ptr = next_ptr.get(opt, {}) + + value = next_ptr.get(last_option, None) + if value is None: + value = default + if default is NoDefault: + raise cp.NoOptionError(option, section) + else: + value = config.get(section=section, option=option, default=default) + return value + + def set(self, section, option, value, verbose=False, save=True, + recursive_notification=True, notification=True): + """ + Set an `option` on a given `section`. + + If section is None, the `option` is added to the default section. + """ + original_option = option + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_conf = self.get(section, base_option, {}) + conf_ptr = base_conf + for opt in intermediate_options: + next_ptr = conf_ptr.get(opt, {}) + conf_ptr[opt] = next_ptr + conf_ptr = next_ptr + + conf_ptr[last_option] = value + value = base_conf + option = base_option + + config = self.get_active_conf(section) + config.set(section=section, option=option, value=value, + verbose=verbose, save=save) + if notification: + self.notify_observers( + section, original_option, recursive_notification) + + def get_default(self, section, option): + """ + Get Default value for a given `section` and `option`. + + This is useful for type checking in `get` method. + """ + config = self.get_active_conf(section) + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_default = config.get_default(section, base_option) + conf_ptr = base_default + for opt in intermediate_options: + conf_ptr = conf_ptr[opt] + + return conf_ptr[last_option] + + return config.get_default(section, option) + + def remove_section(self, section): + """Remove `section` and all options within it.""" + config = self.get_active_conf(section) + config.remove_section(section) + + def remove_option(self, section, option): + """Remove `option` from `section`.""" + config = self.get_active_conf(section) + if isinstance(option, tuple): + base_option = option[0] + intermediate_options = option[1:-1] + last_option = option[-1] + + base_conf = self.get(section, base_option) + conf_ptr = base_conf + for opt in intermediate_options: + conf_ptr = conf_ptr[opt] + conf_ptr.pop(last_option) + self.set(section, base_option) + self.notify_observers(section, base_option) + else: + config.remove_option(section, option) + + def reset_to_defaults(self, section=None, notification=True): + """Reset config to Default values.""" + config = self.get_active_conf(section) + config.reset_to_defaults(section=section) + if notification: + if section is not None: + self.notify_section_all_observers(section) + else: + self.notify_all_observers() + + # Shortcut configuration management + # ------------------------------------------------------------------------ + def _get_shortcut_config(self, context, plugin_name=None): + """ + Return the shortcut configuration for global or plugin configs. + + Context must be either '_' for global or the name of a plugin. + """ + context = context.lower() + config = self._user_config + + if plugin_name in self._plugin_configs: + plugin_class, config = self._plugin_configs[plugin_name] + + # Check if plugin has a separate file + if not plugin_class.CONF_FILE: + config = self._user_config + + elif context in self._plugin_configs: + plugin_class, config = self._plugin_configs[context] + + # Check if plugin has a separate file + if not plugin_class.CONF_FILE: + config = self._user_config + + elif context in (self._user_config.sections() + + EXTRA_VALID_SHORTCUT_CONTEXTS): + config = self._user_config + else: + raise ValueError(_("Shortcut context must match '_' or the " + "plugin `CONF_SECTION`!")) + + return config + + def get_shortcut(self, context, name, plugin_name=None): + """ + Get keyboard shortcut (key sequence string). + + Context must be either '_' for global or the name of a plugin. + """ + config = self._get_shortcut_config(context, plugin_name) + return config.get('shortcuts', context + '/' + name.lower()) + + def set_shortcut(self, context, name, keystr, plugin_name=None): + """ + Set keyboard shortcut (key sequence string). + + Context must be either '_' for global or the name of a plugin. + """ + config = self._get_shortcut_config(context, plugin_name) + config.set('shortcuts', context + '/' + name, keystr) + + def config_shortcut(self, action, context, name, parent): + """ + Create a Shortcut namedtuple for a widget. + + The data contained in this tuple will be registered in our shortcuts + preferences page. + """ + # We only import on demand to avoid loading Qt modules + from spyder.config.gui import _config_shortcut + + keystr = self.get_shortcut(context, name) + sc = _config_shortcut(action, context, name, keystr, parent) + return sc + + def iter_shortcuts(self): + """Iterate over keyboard shortcuts.""" + for context_name, keystr in self._user_config.items('shortcuts'): + if context_name == 'enable': + continue + + if 'additional_configuration' not in context_name: + context, name = context_name.split('/', 1) + yield context, name, keystr + + for _, (_, plugin_config) in self._plugin_configs.items(): + items = plugin_config.items('shortcuts') + if items: + for context_name, keystr in items: + context, name = context_name.split('/', 1) + yield context, name, keystr + + def reset_shortcuts(self): + """Reset keyboard shortcuts to default values.""" + self._user_config.reset_to_defaults(section='shortcuts') + for _, (_, plugin_config) in self._plugin_configs.items(): + # TODO: check if the section exists? + plugin_config.reset_to_defaults(section='shortcuts') + + +try: + CONF = ConfigurationManager() +except Exception: + from qtpy.QtWidgets import QApplication, QMessageBox + + # Check if there's an app already running + app = QApplication.instance() + + # Create app, if there's none, in order to display the message below. + # NOTE: Don't use the functions we have to create a QApplication here + # because they could import CONF at some point, which would make this + # fallback fail. + # See issue spyder-ide/spyder#17889 + if app is None: + app = QApplication(['Spyder']) + app.setApplicationName('Spyder') + + reset_reply = QMessageBox.critical( + None, 'Spyder', + _("There was an error while loading Spyder configuration options. " + "You need to reset them for Spyder to be able to launch.\n\n" + "Do you want to proceed?"), + QMessageBox.Yes, QMessageBox.No) + if reset_reply == QMessageBox.Yes: + reset_config_files() + QMessageBox.information( + None, 'Spyder', + _("Spyder configuration files resetted!")) + os._exit(0) diff --git a/spyder/config/user.py b/spyder/config/user.py index 1ba215264c6..69ec15be297 100644 --- a/spyder/config/user.py +++ b/spyder/config/user.py @@ -1,1021 +1,1021 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -This module provides user configuration file management features for Spyder. - -It is based on the ConfigParser module present in the standard library. -""" - -from __future__ import print_function, unicode_literals - -# Standard library imports -import ast -import copy -import io -import os -import os.path as osp -import re -import shutil -import time - -# Local imports -from spyder.config.base import get_conf_path, get_module_source_path -from spyder.py3compat import configparser as cp -from spyder.py3compat import is_text_string, PY2, to_text_string -from spyder.utils.programs import check_version - - -# ============================================================================ -# Auxiliary classes -# ============================================================================ -class NoDefault: - pass - - -# ============================================================================ -# Defaults class -# ============================================================================ -class DefaultsConfig(cp.ConfigParser, object): - """ - Class used to save defaults to a file and as UserConfig base class. - """ - - def __init__(self, name, path): - """ - Class used to save defaults to a file and as UserConfig base class. - """ - if PY2: - super(DefaultsConfig, self).__init__() - else: - super(DefaultsConfig, self).__init__(interpolation=None) - - self._name = name - self._path = path - - if not osp.isdir(osp.dirname(self._path)): - os.makedirs(osp.dirname(self._path)) - - def _write(self, fp): - """ - Write method for Python 2. - - The one from configparser fails for non-ascii Windows accounts. - """ - if self._defaults: - fp.write('[{}]\n'.format(cp.DEFAULTSECT)) - for (key, value) in self._defaults.items(): - value_plus_end_of_line = str(value).replace('\n', '\n\t') - fp.write('{} = {}\n'.format(key, value_plus_end_of_line)) - - fp.write('\n') - - for section in self._sections: - fp.write('[{}]\n'.format(section)) - for (key, value) in self._sections[section].items(): - if key == '__name__': - continue - - if (value is not None) or (self._optcre == self.OPTCRE): - value = to_text_string(value) - value_plus_end_of_line = value.replace('\n', '\n\t') - key = ' = '.join((key, value_plus_end_of_line)) - - fp.write('{}\n'.format(key)) - - fp.write('\n') - - def _set(self, section, option, value, verbose): - """Set method.""" - if not self.has_section(section): - self.add_section(section) - - if not is_text_string(value): - value = repr(value) - - if verbose: - text = '[{}][{}] = {}'.format(section, option, value) - print(text) # spyder: test-skip - - super(DefaultsConfig, self).set(section, option, value) - - def _save(self): - """Save config into the associated .ini file.""" - fpath = self.get_config_fpath() - - def _write_file(fpath): - with io.open(fpath, 'w', encoding='utf-8') as configfile: - if PY2: - self._write(configfile) - else: - self.write(configfile) - - # See spyder-ide/spyder#1086 and spyder-ide/spyder#1242 for background - # on why this method contains all the exception handling. - try: - # The "easy" way - _write_file(fpath) - except EnvironmentError: - try: - # The "delete and sleep" way - if osp.isfile(fpath): - os.remove(fpath) - - time.sleep(0.05) - _write_file(fpath) - except Exception as e: - print('Failed to write user configuration file to disk, with ' - 'the exception shown below') # spyder: test-skip - print(e) # spyder: test-skip - - def get_config_fpath(self): - """Return the ini file where this configuration is stored.""" - path = self._path - config_file = osp.join(path, '{}.ini'.format(self._name)) - return config_file - - def set_defaults(self, defaults): - """Set default values and save to defaults folder location.""" - for section, options in defaults: - for option in options: - new_value = options[option] - self._set(section, option, new_value, verbose=False) - - -# ============================================================================ -# User config class -# ============================================================================ -class UserConfig(DefaultsConfig): - """ - UserConfig class, based on ConfigParser. - - Parameters - ---------- - name: str - Name of the config - path: str - Configuration file will be saved in path/%name%.ini - defaults: {} or [(str, {}),] - Dictionary containing options *or* list of tuples (sec_name, options) - load: bool - If a previous configuration file is found, load will take the values - from this existing file, instead of using default values. - version: str - version of the configuration file in 'major.minor.micro' format. - backup: bool - A backup will be created on version changes and on initial setup. - raw_mode: bool - If `True` do not apply any automatic conversion on values read from - the configuration. - remove_obsolete: bool - If `True`, values that were removed from the configuration on version - change, are removed from the saved configuration file. - - Notes - ----- - The 'get' and 'set' arguments number and type differ from the overriden - methods. 'defaults' is an attribute and not a method. - """ - DEFAULT_SECTION_NAME = 'main' - - def __init__(self, name, path, defaults=None, load=True, version=None, - backup=False, raw_mode=False, remove_obsolete=False, - external_plugin=False): - """UserConfig class, based on ConfigParser.""" - super(UserConfig, self).__init__(name=name, path=path) - - self._load = load - self._version = self._check_version(version) - self._backup = backup - self._raw = 1 if raw_mode else 0 - self._remove_obsolete = remove_obsolete - self._external_plugin = external_plugin - - self._module_source_path = get_module_source_path('spyder') - self._defaults_folder = 'defaults' - self._backup_folder = 'backups' - self._backup_suffix = '.bak' - self._defaults_name_prefix = 'defaults' - - # This attribute is overriding a method from cp.ConfigParser - self.defaults = self._check_defaults(defaults) - - if backup: - self._make_backup() - - if load: - # If config file already exists, it overrides Default options - previous_fpath = self.get_previous_config_fpath() - self._load_from_ini(previous_fpath) - old_version = self.get_version(version) - self._old_version = old_version - - # Save new defaults - self._save_new_defaults(self.defaults) - - # Updating defaults only if major/minor version is different - if (self._get_minor_version(version) - != self._get_minor_version(old_version)): - - if backup: - self._make_backup(version=old_version) - - self.apply_configuration_patches(old_version=old_version) - - # Remove deprecated options if major version has changed - if remove_obsolete: - self._remove_deprecated_options(old_version) - - # Set new version number - self.set_version(version, save=False) - - if defaults is None: - # If no defaults are defined set .ini file settings as default - self.set_as_defaults() - - # --- Helpers and checkers - # ------------------------------------------------------------------------ - @staticmethod - def _get_minor_version(version): - """Return the 'major.minor' components of the version.""" - return version[:version.rfind('.')] - - @staticmethod - def _get_major_version(version): - """Return the 'major' component of the version.""" - return version[:version.find('.')] - - @staticmethod - def _check_version(version): - """Check version is compliant with format.""" - regex_check = re.match(r'^(\d+).(\d+).(\d+)$', version) - if version is not None and regex_check is None: - raise ValueError('Version number {} is incorrect - must be in ' - 'major.minor.micro format'.format(version)) - - return version - - def _check_defaults(self, defaults): - """Check if defaults are valid and update defaults values.""" - if defaults is None: - defaults = [(self.DEFAULT_SECTION_NAME, {})] - elif isinstance(defaults, dict): - defaults = [(self.DEFAULT_SECTION_NAME, defaults)] - elif isinstance(defaults, list): - # Check is a list of tuples with strings and dictionaries - for sec, options in defaults: - assert is_text_string(sec) - assert isinstance(options, dict) - for opt, _ in options.items(): - assert is_text_string(opt) - else: - raise ValueError('`defaults` must be a dict or a list of tuples!') - - # This attribute is overriding a method from cp.ConfigParser - self.defaults = defaults - - if defaults is not None: - self.reset_to_defaults(save=False) - - return defaults - - @classmethod - def _check_section_option(cls, section, option): - """Check section and option types.""" - if section is None: - section = cls.DEFAULT_SECTION_NAME - elif not is_text_string(section): - raise RuntimeError("Argument 'section' must be a string") - - if not is_text_string(option): - raise RuntimeError("Argument 'option' must be a string") - - return section - - def _make_backup(self, version=None, old_version=None): - """ - Make a backup of the configuration file. - - If `old_version` is `None` a normal backup is made. If `old_version` - is provided, then the backup was requested for minor version changes - and appends the version number to the backup file. - """ - fpath = self.get_config_fpath() - fpath_backup = self.get_backup_fpath_from_version( - version=version, old_version=old_version) - path = os.path.dirname(fpath_backup) - - if not osp.isdir(path): - os.makedirs(path) - - try: - shutil.copyfile(fpath, fpath_backup) - except IOError: - pass - - def _load_from_ini(self, fpath): - """Load config from the associated .ini file found at `fpath`.""" - try: - if PY2: - # Python 2 - if osp.isfile(fpath): - try: - with io.open(fpath, encoding='utf-8') as configfile: - self.readfp(configfile) - except IOError: - error_text = "Failed reading file", fpath - print(error_text) # spyder: test-skip - else: - # Python 3 - self.read(fpath, encoding='utf-8') - except cp.MissingSectionHeaderError: - error_text = 'Warning: File contains no section headers.' - print(error_text) # spyder: test-skip - - def _load_old_defaults(self, old_version): - """Read old defaults.""" - old_defaults = cp.ConfigParser() - path, name = self.get_defaults_path_name_from_version(old_version) - old_defaults.read(osp.join(path, name + '.ini')) - return old_defaults - - def _save_new_defaults(self, defaults): - """Save new defaults.""" - path, name = self.get_defaults_path_name_from_version() - new_defaults = DefaultsConfig(name=name, path=path) - if not osp.isfile(new_defaults.get_config_fpath()): - new_defaults.set_defaults(defaults) - new_defaults._save() - - def _update_defaults(self, defaults, old_version, verbose=False): - """Update defaults after a change in version.""" - old_defaults = self._load_old_defaults(old_version) - for section, options in defaults: - for option in options: - new_value = options[option] - try: - old_val = old_defaults.get(section, option) - except (cp.NoSectionError, cp.NoOptionError): - old_val = None - - if old_val is None or to_text_string(new_value) != old_val: - self._set(section, option, new_value, verbose) - - def _remove_deprecated_options(self, old_version): - """ - Remove options which are present in the .ini file but not in defaults. - """ - old_defaults = self._load_old_defaults(old_version) - for section in old_defaults.sections(): - for option, _ in old_defaults.items(section, raw=self._raw): - if self.get_default(section, option) is NoDefault: - try: - self.remove_option(section, option) - if len(self.items(section, raw=self._raw)) == 0: - self.remove_section(section) - except cp.NoSectionError: - self.remove_section(section) - - # --- Compatibility API - # ------------------------------------------------------------------------ - def get_previous_config_fpath(self): - """Return the last configuration file used if found.""" - return self.get_config_fpath() - - def get_config_fpath_from_version(self, version=None): - """ - Return the configuration path for given version. - - If no version is provided, it returns the current file path. - """ - return self.get_config_fpath() - - def get_backup_fpath_from_version(self, version=None, old_version=None): - """ - Get backup location based on version. - - `old_version` can be used for checking compatibility whereas `version` - relates to adding the version to the file name. - - To be overridden if versions changed backup location. - """ - fpath = self.get_config_fpath() - path = osp.join(osp.dirname(fpath), self._backup_folder) - new_fpath = osp.join(path, osp.basename(fpath)) - if version is None: - backup_fpath = '{}{}'.format(new_fpath, self._backup_suffix) - else: - backup_fpath = "{}-{}{}".format(new_fpath, version, - self._backup_suffix) - return backup_fpath - - def get_defaults_path_name_from_version(self, old_version=None): - """ - Get defaults location based on version. - - To be overridden if versions changed defaults location. - """ - version = old_version if old_version else self._version - defaults_path = osp.join(osp.dirname(self.get_config_fpath()), - self._defaults_folder) - name = '{}-{}-{}'.format( - self._defaults_name_prefix, - self._name, - version, - ) - if not osp.isdir(defaults_path): - os.makedirs(defaults_path) - - return defaults_path, name - - def apply_configuration_patches(self, old_version=None): - """ - Apply any patch to configuration values on version changes. - - To be overridden if patches to configuration values are needed. - """ - pass - - # --- Public API - # ------------------------------------------------------------------------ - def get_version(self, version='0.0.0'): - """Return configuration (not application!) version.""" - return self.get(self.DEFAULT_SECTION_NAME, 'version', version) - - def set_version(self, version='0.0.0', save=True): - """Set configuration (not application!) version.""" - version = self._check_version(version) - self.set(self.DEFAULT_SECTION_NAME, 'version', version, save=save) - - def reset_to_defaults(self, save=True, verbose=False, section=None): - """Reset config to Default values.""" - for sec, options in self.defaults: - if section == None or section == sec: - for option in options: - value = options[option] - self._set(sec, option, value, verbose) - if save: - self._save() - - def set_as_defaults(self): - """Set defaults from the current config.""" - self.defaults = [] - for section in self.sections(): - secdict = {} - for option, value in self.items(section, raw=self._raw): - try: - value = ast.literal_eval(value) - except (SyntaxError, ValueError): - pass - secdict[option] = value - self.defaults.append((section, secdict)) - - def get_default(self, section, option): - """ - Get default value for a given `section` and `option`. - - This is useful for type checking in `get` method. - """ - section = self._check_section_option(section, option) - for sec, options in self.defaults: - if sec == section: - if option in options: - value = options[option] - break - else: - value = NoDefault - - return value - - def get(self, section, option, default=NoDefault): - """ - Get an option. - - Parameters - ---------- - section: str - Section name. If `None` is provide use the default section name. - option: str - Option name for `section`. - default: - Default value (if not specified, an exception will be raised if - option doesn't exist). - """ - section = self._check_section_option(section, option) - - if not self.has_section(section): - if default is NoDefault: - raise cp.NoSectionError(section) - else: - self.add_section(section) - - if not self.has_option(section, option): - if default is NoDefault: - raise cp.NoOptionError(option, section) - else: - self.set(section, option, default) - return default - - value = super(UserConfig, self).get(section, option, raw=self._raw) - - default_value = self.get_default(section, option) - if isinstance(default_value, bool): - value = ast.literal_eval(value) - elif isinstance(default_value, float): - value = float(value) - elif isinstance(default_value, int): - value = int(value) - elif is_text_string(default_value): - if PY2: - try: - value = value.decode('utf-8') - try: - # Some str config values expect to be eval after - # decoding - new_value = ast.literal_eval(value) - if is_text_string(new_value): - value = new_value - except (SyntaxError, ValueError): - pass - except (UnicodeEncodeError, UnicodeDecodeError): - pass - else: - try: - # Lists, tuples, ... - value = ast.literal_eval(value) - except (SyntaxError, ValueError): - pass - - return value - - def set_default(self, section, option, default_value): - """ - Set Default value for a given `section`, `option`. - - If no defaults exist, no default is created. To be able to set - defaults, a call to set_as_defaults is needed to create defaults - based on current values. - """ - section = self._check_section_option(section, option) - for sec, options in self.defaults: - if sec == section: - options[option] = default_value - - def set(self, section, option, value, verbose=False, save=True): - """ - Set an `option` on a given `section`. - - If section is None, the `option` is added to the default section. - """ - section = self._check_section_option(section, option) - default_value = self.get_default(section, option) - - if default_value is NoDefault: - # This let us save correctly string value options with - # no config default that contain non-ascii chars in - # Python 2 - if PY2 and is_text_string(value): - value = repr(value) - - default_value = value - self.set_default(section, option, default_value) - - if isinstance(default_value, bool): - value = bool(value) - elif isinstance(default_value, float): - value = float(value) - elif isinstance(default_value, int): - value = int(value) - elif not is_text_string(default_value): - value = repr(value) - - self._set(section, option, value, verbose) - if save: - self._save() - - def remove_section(self, section): - """Remove `section` and all options within it.""" - super(UserConfig, self).remove_section(section) - self._save() - - def remove_option(self, section, option): - """Remove `option` from `section`.""" - super(UserConfig, self).remove_option(section, option) - self._save() - - def cleanup(self): - """Remove .ini file associated to config.""" - os.remove(self.get_config_fpath()) - - def to_list(self): - """ - Return in list format. - - The format is [('section1', {'opt-1': value, ...}), - ('section2', {'opt-2': othervalue, ...}), ...] - """ - new_defaults = [] - self._load_from_ini(self.get_config_fpath()) - for section in self._sections: - sec_data = {} - for (option, _) in self.items(section): - sec_data[option] = self.get(section, option) - new_defaults.append((section, sec_data)) - - return new_defaults - - -class SpyderUserConfig(UserConfig): - - def get_previous_config_fpath(self): - """ - Override method. - - Return the last configuration file used if found. - """ - fpath = self.get_config_fpath() - - # We don't need to add the contents of the old spyder.ini to - # the configuration of external plugins. This was the cause - # of part two (the shortcut conflicts) of issue - # spyder-ide/spyder#11132 - if self._external_plugin: - previous_paths = [fpath] - else: - previous_paths = [ - # >= 51.0.0 - fpath, - # < 51.0.0 - os.path.join(get_conf_path(), 'spyder.ini'), - ] - - for fpath in previous_paths: - if osp.isfile(fpath): - break - - return fpath - - def get_config_fpath_from_version(self, version=None): - """ - Override method. - - Return the configuration path for given version. - - If no version is provided, it returns the current file path. - """ - if version is None or self._external_plugin: - fpath = self.get_config_fpath() - elif check_version(version, '51.0.0', '<'): - fpath = osp.join(get_conf_path(), 'spyder.ini') - else: - fpath = self.get_config_fpath() - - return fpath - - def get_backup_fpath_from_version(self, version=None, old_version=None): - """ - Override method. - - Make a backup of the configuration file. - """ - if old_version and check_version(old_version, '51.0.0', '<'): - name = 'spyder.ini' - fpath = os.path.join(get_conf_path(), name) - if version is None: - backup_fpath = "{}{}".format(fpath, self._backup_suffix) - else: - backup_fpath = "{}-{}{}".format(fpath, version, - self._backup_suffix) - else: - super_class = super(SpyderUserConfig, self) - backup_fpath = super_class.get_backup_fpath_from_version( - version, old_version) - - return backup_fpath - - def get_defaults_path_name_from_version(self, old_version=None): - """ - Override method. - - Get defaults location based on version. - """ - if old_version: - if check_version(old_version, '51.0.0', '<'): - name = '{}-{}'.format(self._defaults_name_prefix, old_version) - path = osp.join(get_conf_path(), 'defaults') - else: - super_class = super(SpyderUserConfig, self) - path, name = super_class.get_defaults_path_name_from_version( - old_version) - else: - super_class = super(SpyderUserConfig, self) - path, name = super_class.get_defaults_path_name_from_version() - - return path, name - - def apply_configuration_patches(self, old_version=None): - """ - Override method. - - Apply any patch to configuration values on version changes. - """ - self._update_defaults(self.defaults, old_version) - - if self._external_plugin: - return - if old_version and check_version(old_version, '44.1.0', '<'): - run_lines = to_text_string(self.get('ipython_console', - 'startup/run_lines')) - if run_lines is not NoDefault: - run_lines = run_lines.replace(',', '; ') - self.set('ipython_console', 'startup/run_lines', run_lines) - - -class MultiUserConfig(object): - """ - Multiuser config class which emulates the basic UserConfig interface. - - This class provides the same basic interface as UserConfig but allows - splitting the configuration sections and options among several files. - - The `name` is now a `name_map` where the sections and options per file name - are defined. - """ - DEFAULT_FILE_NAME = 'spyder' - - def __init__(self, name_map, path, defaults=None, load=True, version=None, - backup=False, raw_mode=False, remove_obsolete=False, - external_plugin=False): - """Multi user config class based on UserConfig class.""" - self._name_map = self._check_name_map(name_map) - self._path = path - self._defaults = defaults - self._load = load - self._version = version - self._backup = backup - self._raw_mode = 1 if raw_mode else 0 - self._remove_obsolete = remove_obsolete - self._external_plugin = external_plugin - - self._configs_map = {} - self._config_defaults_map = self._get_defaults_for_name_map(defaults, - name_map) - self._config_kwargs = { - 'path': path, - 'defaults': defaults, - 'load': load, - 'version': version, - 'backup': backup, - 'raw_mode': raw_mode, - 'remove_obsolete': False, # This will be handled later on if True - 'external_plugin': external_plugin - } - - for name in name_map: - defaults = self._config_defaults_map.get(name) - mod_kwargs = { - 'name': name, - 'defaults': defaults, - } - new_kwargs = self._config_kwargs.copy() - new_kwargs.update(mod_kwargs) - config_class = self.get_config_class() - self._configs_map[name] = config_class(**new_kwargs) - - # Remove deprecated options if major version has changed - default_config = self._configs_map.get(self.DEFAULT_FILE_NAME) - major_ver = default_config._get_major_version(version) - major_old_ver = default_config._get_major_version( - default_config._old_version) - - # Now we can apply remove_obsolete - if remove_obsolete or major_ver != major_old_ver: - for _, config in self._configs_map.items(): - config._remove_deprecated_options(config._old_version) - - def _get_config(self, section, option): - """Get the correct configuration based on section and option.""" - # Check the filemap first - name = self._get_name_from_map(section, option) - config_value = self._configs_map.get(name, None) - - if config_value is None: - config_value = self._configs_map[self.DEFAULT_FILE_NAME] - return config_value - - def _check_name_map(self, name_map): - """Check `name_map` follows the correct format.""" - # Check section option paris are not repeated - sections_options = [] - for _, sec_opts in name_map.items(): - for section, options in sec_opts: - for option in options: - sec_opt = (section, option) - if sec_opt not in sections_options: - sections_options.append(sec_opt) - else: - error_msg = ( - 'Different files are holding the same ' - 'section/option: "{}/{}"!'.format(section, option) - ) - raise ValueError(error_msg) - return name_map - - @staticmethod - def _get_section_from_defaults(defaults, section): - """Get the section contents from the defaults.""" - for sec, options in defaults: - if section == sec: - value = options - break - else: - raise ValueError('section "{}" not found!'.format(section)) - - return value - - @staticmethod - def _get_option_from_defaults(defaults, section, option): - """Get the section,option value from the defaults.""" - value = NoDefault - for sec, options in defaults: - if section == sec: - value = options.get(option, NoDefault) - break - return value - - @staticmethod - def _remove_section_from_defaults(defaults, section): - """Remove section from defaults.""" - idx_remove = None - for idx, (sec, _) in enumerate(defaults): - if section == sec: - idx_remove = idx - break - - if idx_remove is not None: - defaults.pop(idx) - - @staticmethod - def _remove_option_from_defaults(defaults, section, option): - """Remove section,option from defaults.""" - for sec, options in defaults: - if section == sec: - if option in options: - options.pop(option) - break - - def _get_name_from_map(self, section=None, option=None): - """ - Search for section and option on the name_map and return the name. - """ - for name, sec_opts in self._name_map.items(): - # Ignore the main section - default_sec_name = self._configs_map.get(name).DEFAULT_SECTION_NAME - if name == default_sec_name: - continue - for sec, options in sec_opts: - if sec == section: - if len(options) == 0: - return name - else: - for opt in options: - if opt == option: - return name - - @classmethod - def _get_defaults_for_name_map(cls, defaults, name_map): - """Split the global defaults using the name_map.""" - name_map_config = {} - defaults_copy = copy.deepcopy(defaults) - - for name, sec_opts in name_map.items(): - default_map_for_name = [] - if len(sec_opts) == 0: - name_map_config[name] = defaults_copy - else: - for section, options in sec_opts: - if len(options) == 0: - # Use all on section - sec = cls._get_section_from_defaults(defaults_copy, - section) - - # Remove section from defaults - cls._remove_section_from_defaults(defaults_copy, - section) - - else: - # Iterate and pop! - sec = {} - for opt in options: - val = cls._get_option_from_defaults(defaults_copy, - section, opt) - if val is not NoDefault: - sec[opt] = val - - # Remove option from defaults - cls._remove_option_from_defaults(defaults_copy, - section, opt) - - # Add to config map - default_map_for_name.append((section, sec)) - - name_map_config[name] = default_map_for_name - - return name_map_config - - def get_config_class(self): - """Return the UserConfig class to use.""" - return SpyderUserConfig - - def sections(self): - """Return all sections of the configuration file.""" - sections = set() - for _, config in self._configs_map.items(): - for section in config.sections(): - sections.add(section) - - return list(sorted(sections)) - - def items(self, section): - """Return all the items option/values for the given section.""" - config = self._get_config(section, None) - if config is None: - config = self._configs_map[self.DEFAULT_FILE_NAME] - - if config.has_section(section): - return config.items(section=section) - else: - return None - - def options(self, section): - """Return all the options for the given section.""" - config = self._get_config(section, None) - return config.options(section=section) - - def get_default(self, section, option): - """ - Get Default value for a given `section` and `option`. - - This is useful for type checking in `get` method. - """ - config = self._get_config(section, option) - if config is None: - config = self._configs_map[self.DEFAULT_FILE_NAME] - return config.get_default(section, option) - - def get(self, section, option, default=NoDefault): - """ - Get an `option` on a given `section`. - - If section is None, the `option` is requested from default section. - """ - config = self._get_config(section, option) - - if config is None: - config = self._configs_map[self.DEFAULT_FILE_NAME] - - return config.get(section=section, option=option, default=default) - - def set(self, section, option, value, verbose=False, save=True): - """ - Set an `option` on a given `section`. - - If section is None, the `option` is added to the default section. - """ - config = self._get_config(section, option) - config.set(section=section, option=option, value=value, - verbose=verbose, save=save) - - def reset_to_defaults(self, section=None): - """Reset configuration to Default values.""" - for _, config in self._configs_map.items(): - config.reset_to_defaults(section=section) - - def remove_section(self, section): - """Remove `section` and all options within it.""" - config = self._get_config(section, None) - config.remove_section(section) - - def remove_option(self, section, option): - """Remove `option` from `section`.""" - config = self._get_config(section, option) - config.remove_option(section, option) - - def cleanup(self): - """Remove .ini files associated to configurations.""" - for _, config in self._configs_map.items(): - os.remove(config.get_config_fpath()) - - -class PluginConfig(UserConfig): - """Plugin configuration handler.""" - - -class PluginMultiConfig(MultiUserConfig): - """Plugin configuration handler with multifile support.""" - - def get_config_class(self): - return PluginConfig +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +This module provides user configuration file management features for Spyder. + +It is based on the ConfigParser module present in the standard library. +""" + +from __future__ import print_function, unicode_literals + +# Standard library imports +import ast +import copy +import io +import os +import os.path as osp +import re +import shutil +import time + +# Local imports +from spyder.config.base import get_conf_path, get_module_source_path +from spyder.py3compat import configparser as cp +from spyder.py3compat import is_text_string, PY2, to_text_string +from spyder.utils.programs import check_version + + +# ============================================================================ +# Auxiliary classes +# ============================================================================ +class NoDefault: + pass + + +# ============================================================================ +# Defaults class +# ============================================================================ +class DefaultsConfig(cp.ConfigParser, object): + """ + Class used to save defaults to a file and as UserConfig base class. + """ + + def __init__(self, name, path): + """ + Class used to save defaults to a file and as UserConfig base class. + """ + if PY2: + super(DefaultsConfig, self).__init__() + else: + super(DefaultsConfig, self).__init__(interpolation=None) + + self._name = name + self._path = path + + if not osp.isdir(osp.dirname(self._path)): + os.makedirs(osp.dirname(self._path)) + + def _write(self, fp): + """ + Write method for Python 2. + + The one from configparser fails for non-ascii Windows accounts. + """ + if self._defaults: + fp.write('[{}]\n'.format(cp.DEFAULTSECT)) + for (key, value) in self._defaults.items(): + value_plus_end_of_line = str(value).replace('\n', '\n\t') + fp.write('{} = {}\n'.format(key, value_plus_end_of_line)) + + fp.write('\n') + + for section in self._sections: + fp.write('[{}]\n'.format(section)) + for (key, value) in self._sections[section].items(): + if key == '__name__': + continue + + if (value is not None) or (self._optcre == self.OPTCRE): + value = to_text_string(value) + value_plus_end_of_line = value.replace('\n', '\n\t') + key = ' = '.join((key, value_plus_end_of_line)) + + fp.write('{}\n'.format(key)) + + fp.write('\n') + + def _set(self, section, option, value, verbose): + """Set method.""" + if not self.has_section(section): + self.add_section(section) + + if not is_text_string(value): + value = repr(value) + + if verbose: + text = '[{}][{}] = {}'.format(section, option, value) + print(text) # spyder: test-skip + + super(DefaultsConfig, self).set(section, option, value) + + def _save(self): + """Save config into the associated .ini file.""" + fpath = self.get_config_fpath() + + def _write_file(fpath): + with io.open(fpath, 'w', encoding='utf-8') as configfile: + if PY2: + self._write(configfile) + else: + self.write(configfile) + + # See spyder-ide/spyder#1086 and spyder-ide/spyder#1242 for background + # on why this method contains all the exception handling. + try: + # The "easy" way + _write_file(fpath) + except EnvironmentError: + try: + # The "delete and sleep" way + if osp.isfile(fpath): + os.remove(fpath) + + time.sleep(0.05) + _write_file(fpath) + except Exception as e: + print('Failed to write user configuration file to disk, with ' + 'the exception shown below') # spyder: test-skip + print(e) # spyder: test-skip + + def get_config_fpath(self): + """Return the ini file where this configuration is stored.""" + path = self._path + config_file = osp.join(path, '{}.ini'.format(self._name)) + return config_file + + def set_defaults(self, defaults): + """Set default values and save to defaults folder location.""" + for section, options in defaults: + for option in options: + new_value = options[option] + self._set(section, option, new_value, verbose=False) + + +# ============================================================================ +# User config class +# ============================================================================ +class UserConfig(DefaultsConfig): + """ + UserConfig class, based on ConfigParser. + + Parameters + ---------- + name: str + Name of the config + path: str + Configuration file will be saved in path/%name%.ini + defaults: {} or [(str, {}),] + Dictionary containing options *or* list of tuples (sec_name, options) + load: bool + If a previous configuration file is found, load will take the values + from this existing file, instead of using default values. + version: str + version of the configuration file in 'major.minor.micro' format. + backup: bool + A backup will be created on version changes and on initial setup. + raw_mode: bool + If `True` do not apply any automatic conversion on values read from + the configuration. + remove_obsolete: bool + If `True`, values that were removed from the configuration on version + change, are removed from the saved configuration file. + + Notes + ----- + The 'get' and 'set' arguments number and type differ from the overriden + methods. 'defaults' is an attribute and not a method. + """ + DEFAULT_SECTION_NAME = 'main' + + def __init__(self, name, path, defaults=None, load=True, version=None, + backup=False, raw_mode=False, remove_obsolete=False, + external_plugin=False): + """UserConfig class, based on ConfigParser.""" + super(UserConfig, self).__init__(name=name, path=path) + + self._load = load + self._version = self._check_version(version) + self._backup = backup + self._raw = 1 if raw_mode else 0 + self._remove_obsolete = remove_obsolete + self._external_plugin = external_plugin + + self._module_source_path = get_module_source_path('spyder') + self._defaults_folder = 'defaults' + self._backup_folder = 'backups' + self._backup_suffix = '.bak' + self._defaults_name_prefix = 'defaults' + + # This attribute is overriding a method from cp.ConfigParser + self.defaults = self._check_defaults(defaults) + + if backup: + self._make_backup() + + if load: + # If config file already exists, it overrides Default options + previous_fpath = self.get_previous_config_fpath() + self._load_from_ini(previous_fpath) + old_version = self.get_version(version) + self._old_version = old_version + + # Save new defaults + self._save_new_defaults(self.defaults) + + # Updating defaults only if major/minor version is different + if (self._get_minor_version(version) + != self._get_minor_version(old_version)): + + if backup: + self._make_backup(version=old_version) + + self.apply_configuration_patches(old_version=old_version) + + # Remove deprecated options if major version has changed + if remove_obsolete: + self._remove_deprecated_options(old_version) + + # Set new version number + self.set_version(version, save=False) + + if defaults is None: + # If no defaults are defined set .ini file settings as default + self.set_as_defaults() + + # --- Helpers and checkers + # ------------------------------------------------------------------------ + @staticmethod + def _get_minor_version(version): + """Return the 'major.minor' components of the version.""" + return version[:version.rfind('.')] + + @staticmethod + def _get_major_version(version): + """Return the 'major' component of the version.""" + return version[:version.find('.')] + + @staticmethod + def _check_version(version): + """Check version is compliant with format.""" + regex_check = re.match(r'^(\d+).(\d+).(\d+)$', version) + if version is not None and regex_check is None: + raise ValueError('Version number {} is incorrect - must be in ' + 'major.minor.micro format'.format(version)) + + return version + + def _check_defaults(self, defaults): + """Check if defaults are valid and update defaults values.""" + if defaults is None: + defaults = [(self.DEFAULT_SECTION_NAME, {})] + elif isinstance(defaults, dict): + defaults = [(self.DEFAULT_SECTION_NAME, defaults)] + elif isinstance(defaults, list): + # Check is a list of tuples with strings and dictionaries + for sec, options in defaults: + assert is_text_string(sec) + assert isinstance(options, dict) + for opt, _ in options.items(): + assert is_text_string(opt) + else: + raise ValueError('`defaults` must be a dict or a list of tuples!') + + # This attribute is overriding a method from cp.ConfigParser + self.defaults = defaults + + if defaults is not None: + self.reset_to_defaults(save=False) + + return defaults + + @classmethod + def _check_section_option(cls, section, option): + """Check section and option types.""" + if section is None: + section = cls.DEFAULT_SECTION_NAME + elif not is_text_string(section): + raise RuntimeError("Argument 'section' must be a string") + + if not is_text_string(option): + raise RuntimeError("Argument 'option' must be a string") + + return section + + def _make_backup(self, version=None, old_version=None): + """ + Make a backup of the configuration file. + + If `old_version` is `None` a normal backup is made. If `old_version` + is provided, then the backup was requested for minor version changes + and appends the version number to the backup file. + """ + fpath = self.get_config_fpath() + fpath_backup = self.get_backup_fpath_from_version( + version=version, old_version=old_version) + path = os.path.dirname(fpath_backup) + + if not osp.isdir(path): + os.makedirs(path) + + try: + shutil.copyfile(fpath, fpath_backup) + except IOError: + pass + + def _load_from_ini(self, fpath): + """Load config from the associated .ini file found at `fpath`.""" + try: + if PY2: + # Python 2 + if osp.isfile(fpath): + try: + with io.open(fpath, encoding='utf-8') as configfile: + self.readfp(configfile) + except IOError: + error_text = "Failed reading file", fpath + print(error_text) # spyder: test-skip + else: + # Python 3 + self.read(fpath, encoding='utf-8') + except cp.MissingSectionHeaderError: + error_text = 'Warning: File contains no section headers.' + print(error_text) # spyder: test-skip + + def _load_old_defaults(self, old_version): + """Read old defaults.""" + old_defaults = cp.ConfigParser() + path, name = self.get_defaults_path_name_from_version(old_version) + old_defaults.read(osp.join(path, name + '.ini')) + return old_defaults + + def _save_new_defaults(self, defaults): + """Save new defaults.""" + path, name = self.get_defaults_path_name_from_version() + new_defaults = DefaultsConfig(name=name, path=path) + if not osp.isfile(new_defaults.get_config_fpath()): + new_defaults.set_defaults(defaults) + new_defaults._save() + + def _update_defaults(self, defaults, old_version, verbose=False): + """Update defaults after a change in version.""" + old_defaults = self._load_old_defaults(old_version) + for section, options in defaults: + for option in options: + new_value = options[option] + try: + old_val = old_defaults.get(section, option) + except (cp.NoSectionError, cp.NoOptionError): + old_val = None + + if old_val is None or to_text_string(new_value) != old_val: + self._set(section, option, new_value, verbose) + + def _remove_deprecated_options(self, old_version): + """ + Remove options which are present in the .ini file but not in defaults. + """ + old_defaults = self._load_old_defaults(old_version) + for section in old_defaults.sections(): + for option, _ in old_defaults.items(section, raw=self._raw): + if self.get_default(section, option) is NoDefault: + try: + self.remove_option(section, option) + if len(self.items(section, raw=self._raw)) == 0: + self.remove_section(section) + except cp.NoSectionError: + self.remove_section(section) + + # --- Compatibility API + # ------------------------------------------------------------------------ + def get_previous_config_fpath(self): + """Return the last configuration file used if found.""" + return self.get_config_fpath() + + def get_config_fpath_from_version(self, version=None): + """ + Return the configuration path for given version. + + If no version is provided, it returns the current file path. + """ + return self.get_config_fpath() + + def get_backup_fpath_from_version(self, version=None, old_version=None): + """ + Get backup location based on version. + + `old_version` can be used for checking compatibility whereas `version` + relates to adding the version to the file name. + + To be overridden if versions changed backup location. + """ + fpath = self.get_config_fpath() + path = osp.join(osp.dirname(fpath), self._backup_folder) + new_fpath = osp.join(path, osp.basename(fpath)) + if version is None: + backup_fpath = '{}{}'.format(new_fpath, self._backup_suffix) + else: + backup_fpath = "{}-{}{}".format(new_fpath, version, + self._backup_suffix) + return backup_fpath + + def get_defaults_path_name_from_version(self, old_version=None): + """ + Get defaults location based on version. + + To be overridden if versions changed defaults location. + """ + version = old_version if old_version else self._version + defaults_path = osp.join(osp.dirname(self.get_config_fpath()), + self._defaults_folder) + name = '{}-{}-{}'.format( + self._defaults_name_prefix, + self._name, + version, + ) + if not osp.isdir(defaults_path): + os.makedirs(defaults_path) + + return defaults_path, name + + def apply_configuration_patches(self, old_version=None): + """ + Apply any patch to configuration values on version changes. + + To be overridden if patches to configuration values are needed. + """ + pass + + # --- Public API + # ------------------------------------------------------------------------ + def get_version(self, version='0.0.0'): + """Return configuration (not application!) version.""" + return self.get(self.DEFAULT_SECTION_NAME, 'version', version) + + def set_version(self, version='0.0.0', save=True): + """Set configuration (not application!) version.""" + version = self._check_version(version) + self.set(self.DEFAULT_SECTION_NAME, 'version', version, save=save) + + def reset_to_defaults(self, save=True, verbose=False, section=None): + """Reset config to Default values.""" + for sec, options in self.defaults: + if section == None or section == sec: + for option in options: + value = options[option] + self._set(sec, option, value, verbose) + if save: + self._save() + + def set_as_defaults(self): + """Set defaults from the current config.""" + self.defaults = [] + for section in self.sections(): + secdict = {} + for option, value in self.items(section, raw=self._raw): + try: + value = ast.literal_eval(value) + except (SyntaxError, ValueError): + pass + secdict[option] = value + self.defaults.append((section, secdict)) + + def get_default(self, section, option): + """ + Get default value for a given `section` and `option`. + + This is useful for type checking in `get` method. + """ + section = self._check_section_option(section, option) + for sec, options in self.defaults: + if sec == section: + if option in options: + value = options[option] + break + else: + value = NoDefault + + return value + + def get(self, section, option, default=NoDefault): + """ + Get an option. + + Parameters + ---------- + section: str + Section name. If `None` is provide use the default section name. + option: str + Option name for `section`. + default: + Default value (if not specified, an exception will be raised if + option doesn't exist). + """ + section = self._check_section_option(section, option) + + if not self.has_section(section): + if default is NoDefault: + raise cp.NoSectionError(section) + else: + self.add_section(section) + + if not self.has_option(section, option): + if default is NoDefault: + raise cp.NoOptionError(option, section) + else: + self.set(section, option, default) + return default + + value = super(UserConfig, self).get(section, option, raw=self._raw) + + default_value = self.get_default(section, option) + if isinstance(default_value, bool): + value = ast.literal_eval(value) + elif isinstance(default_value, float): + value = float(value) + elif isinstance(default_value, int): + value = int(value) + elif is_text_string(default_value): + if PY2: + try: + value = value.decode('utf-8') + try: + # Some str config values expect to be eval after + # decoding + new_value = ast.literal_eval(value) + if is_text_string(new_value): + value = new_value + except (SyntaxError, ValueError): + pass + except (UnicodeEncodeError, UnicodeDecodeError): + pass + else: + try: + # Lists, tuples, ... + value = ast.literal_eval(value) + except (SyntaxError, ValueError): + pass + + return value + + def set_default(self, section, option, default_value): + """ + Set Default value for a given `section`, `option`. + + If no defaults exist, no default is created. To be able to set + defaults, a call to set_as_defaults is needed to create defaults + based on current values. + """ + section = self._check_section_option(section, option) + for sec, options in self.defaults: + if sec == section: + options[option] = default_value + + def set(self, section, option, value, verbose=False, save=True): + """ + Set an `option` on a given `section`. + + If section is None, the `option` is added to the default section. + """ + section = self._check_section_option(section, option) + default_value = self.get_default(section, option) + + if default_value is NoDefault: + # This let us save correctly string value options with + # no config default that contain non-ascii chars in + # Python 2 + if PY2 and is_text_string(value): + value = repr(value) + + default_value = value + self.set_default(section, option, default_value) + + if isinstance(default_value, bool): + value = bool(value) + elif isinstance(default_value, float): + value = float(value) + elif isinstance(default_value, int): + value = int(value) + elif not is_text_string(default_value): + value = repr(value) + + self._set(section, option, value, verbose) + if save: + self._save() + + def remove_section(self, section): + """Remove `section` and all options within it.""" + super(UserConfig, self).remove_section(section) + self._save() + + def remove_option(self, section, option): + """Remove `option` from `section`.""" + super(UserConfig, self).remove_option(section, option) + self._save() + + def cleanup(self): + """Remove .ini file associated to config.""" + os.remove(self.get_config_fpath()) + + def to_list(self): + """ + Return in list format. + + The format is [('section1', {'opt-1': value, ...}), + ('section2', {'opt-2': othervalue, ...}), ...] + """ + new_defaults = [] + self._load_from_ini(self.get_config_fpath()) + for section in self._sections: + sec_data = {} + for (option, _) in self.items(section): + sec_data[option] = self.get(section, option) + new_defaults.append((section, sec_data)) + + return new_defaults + + +class SpyderUserConfig(UserConfig): + + def get_previous_config_fpath(self): + """ + Override method. + + Return the last configuration file used if found. + """ + fpath = self.get_config_fpath() + + # We don't need to add the contents of the old spyder.ini to + # the configuration of external plugins. This was the cause + # of part two (the shortcut conflicts) of issue + # spyder-ide/spyder#11132 + if self._external_plugin: + previous_paths = [fpath] + else: + previous_paths = [ + # >= 51.0.0 + fpath, + # < 51.0.0 + os.path.join(get_conf_path(), 'spyder.ini'), + ] + + for fpath in previous_paths: + if osp.isfile(fpath): + break + + return fpath + + def get_config_fpath_from_version(self, version=None): + """ + Override method. + + Return the configuration path for given version. + + If no version is provided, it returns the current file path. + """ + if version is None or self._external_plugin: + fpath = self.get_config_fpath() + elif check_version(version, '51.0.0', '<'): + fpath = osp.join(get_conf_path(), 'spyder.ini') + else: + fpath = self.get_config_fpath() + + return fpath + + def get_backup_fpath_from_version(self, version=None, old_version=None): + """ + Override method. + + Make a backup of the configuration file. + """ + if old_version and check_version(old_version, '51.0.0', '<'): + name = 'spyder.ini' + fpath = os.path.join(get_conf_path(), name) + if version is None: + backup_fpath = "{}{}".format(fpath, self._backup_suffix) + else: + backup_fpath = "{}-{}{}".format(fpath, version, + self._backup_suffix) + else: + super_class = super(SpyderUserConfig, self) + backup_fpath = super_class.get_backup_fpath_from_version( + version, old_version) + + return backup_fpath + + def get_defaults_path_name_from_version(self, old_version=None): + """ + Override method. + + Get defaults location based on version. + """ + if old_version: + if check_version(old_version, '51.0.0', '<'): + name = '{}-{}'.format(self._defaults_name_prefix, old_version) + path = osp.join(get_conf_path(), 'defaults') + else: + super_class = super(SpyderUserConfig, self) + path, name = super_class.get_defaults_path_name_from_version( + old_version) + else: + super_class = super(SpyderUserConfig, self) + path, name = super_class.get_defaults_path_name_from_version() + + return path, name + + def apply_configuration_patches(self, old_version=None): + """ + Override method. + + Apply any patch to configuration values on version changes. + """ + self._update_defaults(self.defaults, old_version) + + if self._external_plugin: + return + if old_version and check_version(old_version, '44.1.0', '<'): + run_lines = to_text_string(self.get('ipython_console', + 'startup/run_lines')) + if run_lines is not NoDefault: + run_lines = run_lines.replace(',', '; ') + self.set('ipython_console', 'startup/run_lines', run_lines) + + +class MultiUserConfig(object): + """ + Multiuser config class which emulates the basic UserConfig interface. + + This class provides the same basic interface as UserConfig but allows + splitting the configuration sections and options among several files. + + The `name` is now a `name_map` where the sections and options per file name + are defined. + """ + DEFAULT_FILE_NAME = 'spyder' + + def __init__(self, name_map, path, defaults=None, load=True, version=None, + backup=False, raw_mode=False, remove_obsolete=False, + external_plugin=False): + """Multi user config class based on UserConfig class.""" + self._name_map = self._check_name_map(name_map) + self._path = path + self._defaults = defaults + self._load = load + self._version = version + self._backup = backup + self._raw_mode = 1 if raw_mode else 0 + self._remove_obsolete = remove_obsolete + self._external_plugin = external_plugin + + self._configs_map = {} + self._config_defaults_map = self._get_defaults_for_name_map(defaults, + name_map) + self._config_kwargs = { + 'path': path, + 'defaults': defaults, + 'load': load, + 'version': version, + 'backup': backup, + 'raw_mode': raw_mode, + 'remove_obsolete': False, # This will be handled later on if True + 'external_plugin': external_plugin + } + + for name in name_map: + defaults = self._config_defaults_map.get(name) + mod_kwargs = { + 'name': name, + 'defaults': defaults, + } + new_kwargs = self._config_kwargs.copy() + new_kwargs.update(mod_kwargs) + config_class = self.get_config_class() + self._configs_map[name] = config_class(**new_kwargs) + + # Remove deprecated options if major version has changed + default_config = self._configs_map.get(self.DEFAULT_FILE_NAME) + major_ver = default_config._get_major_version(version) + major_old_ver = default_config._get_major_version( + default_config._old_version) + + # Now we can apply remove_obsolete + if remove_obsolete or major_ver != major_old_ver: + for _, config in self._configs_map.items(): + config._remove_deprecated_options(config._old_version) + + def _get_config(self, section, option): + """Get the correct configuration based on section and option.""" + # Check the filemap first + name = self._get_name_from_map(section, option) + config_value = self._configs_map.get(name, None) + + if config_value is None: + config_value = self._configs_map[self.DEFAULT_FILE_NAME] + return config_value + + def _check_name_map(self, name_map): + """Check `name_map` follows the correct format.""" + # Check section option paris are not repeated + sections_options = [] + for _, sec_opts in name_map.items(): + for section, options in sec_opts: + for option in options: + sec_opt = (section, option) + if sec_opt not in sections_options: + sections_options.append(sec_opt) + else: + error_msg = ( + 'Different files are holding the same ' + 'section/option: "{}/{}"!'.format(section, option) + ) + raise ValueError(error_msg) + return name_map + + @staticmethod + def _get_section_from_defaults(defaults, section): + """Get the section contents from the defaults.""" + for sec, options in defaults: + if section == sec: + value = options + break + else: + raise ValueError('section "{}" not found!'.format(section)) + + return value + + @staticmethod + def _get_option_from_defaults(defaults, section, option): + """Get the section,option value from the defaults.""" + value = NoDefault + for sec, options in defaults: + if section == sec: + value = options.get(option, NoDefault) + break + return value + + @staticmethod + def _remove_section_from_defaults(defaults, section): + """Remove section from defaults.""" + idx_remove = None + for idx, (sec, _) in enumerate(defaults): + if section == sec: + idx_remove = idx + break + + if idx_remove is not None: + defaults.pop(idx) + + @staticmethod + def _remove_option_from_defaults(defaults, section, option): + """Remove section,option from defaults.""" + for sec, options in defaults: + if section == sec: + if option in options: + options.pop(option) + break + + def _get_name_from_map(self, section=None, option=None): + """ + Search for section and option on the name_map and return the name. + """ + for name, sec_opts in self._name_map.items(): + # Ignore the main section + default_sec_name = self._configs_map.get(name).DEFAULT_SECTION_NAME + if name == default_sec_name: + continue + for sec, options in sec_opts: + if sec == section: + if len(options) == 0: + return name + else: + for opt in options: + if opt == option: + return name + + @classmethod + def _get_defaults_for_name_map(cls, defaults, name_map): + """Split the global defaults using the name_map.""" + name_map_config = {} + defaults_copy = copy.deepcopy(defaults) + + for name, sec_opts in name_map.items(): + default_map_for_name = [] + if len(sec_opts) == 0: + name_map_config[name] = defaults_copy + else: + for section, options in sec_opts: + if len(options) == 0: + # Use all on section + sec = cls._get_section_from_defaults(defaults_copy, + section) + + # Remove section from defaults + cls._remove_section_from_defaults(defaults_copy, + section) + + else: + # Iterate and pop! + sec = {} + for opt in options: + val = cls._get_option_from_defaults(defaults_copy, + section, opt) + if val is not NoDefault: + sec[opt] = val + + # Remove option from defaults + cls._remove_option_from_defaults(defaults_copy, + section, opt) + + # Add to config map + default_map_for_name.append((section, sec)) + + name_map_config[name] = default_map_for_name + + return name_map_config + + def get_config_class(self): + """Return the UserConfig class to use.""" + return SpyderUserConfig + + def sections(self): + """Return all sections of the configuration file.""" + sections = set() + for _, config in self._configs_map.items(): + for section in config.sections(): + sections.add(section) + + return list(sorted(sections)) + + def items(self, section): + """Return all the items option/values for the given section.""" + config = self._get_config(section, None) + if config is None: + config = self._configs_map[self.DEFAULT_FILE_NAME] + + if config.has_section(section): + return config.items(section=section) + else: + return None + + def options(self, section): + """Return all the options for the given section.""" + config = self._get_config(section, None) + return config.options(section=section) + + def get_default(self, section, option): + """ + Get Default value for a given `section` and `option`. + + This is useful for type checking in `get` method. + """ + config = self._get_config(section, option) + if config is None: + config = self._configs_map[self.DEFAULT_FILE_NAME] + return config.get_default(section, option) + + def get(self, section, option, default=NoDefault): + """ + Get an `option` on a given `section`. + + If section is None, the `option` is requested from default section. + """ + config = self._get_config(section, option) + + if config is None: + config = self._configs_map[self.DEFAULT_FILE_NAME] + + return config.get(section=section, option=option, default=default) + + def set(self, section, option, value, verbose=False, save=True): + """ + Set an `option` on a given `section`. + + If section is None, the `option` is added to the default section. + """ + config = self._get_config(section, option) + config.set(section=section, option=option, value=value, + verbose=verbose, save=save) + + def reset_to_defaults(self, section=None): + """Reset configuration to Default values.""" + for _, config in self._configs_map.items(): + config.reset_to_defaults(section=section) + + def remove_section(self, section): + """Remove `section` and all options within it.""" + config = self._get_config(section, None) + config.remove_section(section) + + def remove_option(self, section, option): + """Remove `option` from `section`.""" + config = self._get_config(section, option) + config.remove_option(section, option) + + def cleanup(self): + """Remove .ini files associated to configurations.""" + for _, config in self._configs_map.items(): + os.remove(config.get_config_fpath()) + + +class PluginConfig(UserConfig): + """Plugin configuration handler.""" + + +class PluginMultiConfig(MultiUserConfig): + """Plugin configuration handler with multifile support.""" + + def get_config_class(self): + return PluginConfig diff --git a/spyder/dependencies.py b/spyder/dependencies.py index b3a6fdf5413..d382ce818cb 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -1,448 +1,448 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Module checking Spyder runtime dependencies""" - -# Standard library imports -import os -import os.path as osp -import sys - -# Local imports -from spyder.config.base import _, is_pynsist, running_in_ci, running_in_mac_app -from spyder.utils import programs - -HERE = osp.dirname(osp.abspath(__file__)) - -# ============================================================================= -# Kind of dependency -# ============================================================================= -MANDATORY = 'mandatory' -OPTIONAL = 'optional' -PLUGIN = 'spyder plugins' - - -# ============================================================================= -# Versions -# ============================================================================= -# Hard dependencies -APPLAUNCHSERVICES_REQVER = '>=0.3.0' -ATOMICWRITES_REQVER = '>=1.2.0' -CHARDET_REQVER = '>=2.0.0' -CLOUDPICKLE_REQVER = '>=0.5.0' -COOKIECUTTER_REQVER = '>=1.6.0' -DIFF_MATCH_PATCH_REQVER = '>=20181111' -# None for pynsist install for now -# (check way to add dist.info/egg.info from packages without wheels available) -INTERVALTREE_REQVER = None if is_pynsist() else '>=3.0.2' -IPYTHON_REQVER = ">=7.31.1;<8.0.0" -JEDI_REQVER = '>=0.17.2;<0.19.0' -JELLYFISH_REQVER = '>=0.7' -JSONSCHEMA_REQVER = '>=3.2.0' -KEYRING_REQVER = '>=17.0.0' -NBCONVERT_REQVER = '>=4.0' -NUMPYDOC_REQVER = '>=0.6.0' -PARAMIKO_REQVER = '>=2.4.0' -PARSO_REQVER = '>=0.7.0;<0.9.0' -PEXPECT_REQVER = '>=4.4.0' -PICKLESHARE_REQVER = '>=0.4' -PSUTIL_REQVER = '>=5.3' -PYGMENTS_REQVER = '>=2.0' -PYLINT_REQVER = '>=2.5.0;<3.0' -PYLSP_REQVER = '>=1.5.0;<1.6.0' -PYLSP_BLACK_REQVER = '>=1.2.0' -PYLS_SPYDER_REQVER = '>=0.4.0' -PYXDG_REQVER = '>=0.26' -PYZMQ_REQVER = '>=22.1.0' -QDARKSTYLE_REQVER = '>=3.0.2;<3.1.0' -QSTYLIZER_REQVER = '>=0.1.10' -QTAWESOME_REQVER = '>=1.0.2' -QTCONSOLE_REQVER = '>=5.3.0;<5.4.0' -QTPY_REQVER = '>=2.1.0' -RTREE_REQVER = '>=0.9.7' -SETUPTOOLS_REQVER = '>=49.6.0' -SPHINX_REQVER = '>=0.6.6' -SPYDER_KERNELS_REQVER = '>=2.3.2;<2.4.0' -TEXTDISTANCE_REQVER = '>=4.2.0' -THREE_MERGE_REQVER = '>=0.1.1' -# None for pynsist install for now -# (check way to add dist.info/egg.info from packages without wheels available) -WATCHDOG_REQVER = None if is_pynsist() else '>=0.10.3' - - -# Optional dependencies -CYTHON_REQVER = '>=0.21' -MATPLOTLIB_REQVER = '>=3.0.0' -NUMPY_REQVER = '>=1.7' -PANDAS_REQVER = '>=1.1.1' -SCIPY_REQVER = '>=0.17.0' -SYMPY_REQVER = '>=0.7.3' - - -# ============================================================================= -# Descriptions -# NOTE: We declare our dependencies in **alphabetical** order -# If some dependencies are limited to some systems only, add a 'display' key. -# See 'applaunchservices' for an example. -# ============================================================================= -# List of descriptions -DESCRIPTIONS = [ - {'modname': "applaunchservices", - 'package_name': "applaunchservices", - 'features': _("Notify macOS that Spyder can open Python files"), - 'required_version': APPLAUNCHSERVICES_REQVER, - 'display': sys.platform == "darwin" and not running_in_mac_app()}, - {'modname': "atomicwrites", - 'package_name': "atomicwrites", - 'features': _("Atomic file writes in the Editor"), - 'required_version': ATOMICWRITES_REQVER}, - {'modname': "chardet", - 'package_name': "chardet", - 'features': _("Character encoding auto-detection for the Editor"), - 'required_version': CHARDET_REQVER}, - {'modname': "cloudpickle", - 'package_name': "cloudpickle", - 'features': _("Handle communications between kernel and frontend"), - 'required_version': CLOUDPICKLE_REQVER}, - {'modname': "cookiecutter", - 'package_name': "cookiecutter", - 'features': _("Create projects from cookiecutter templates"), - 'required_version': COOKIECUTTER_REQVER}, - {'modname': "diff_match_patch", - 'package_name': "diff-match-patch", - 'features': _("Compute text file diff changes during edition"), - 'required_version': DIFF_MATCH_PATCH_REQVER}, - {'modname': "intervaltree", - 'package_name': "intervaltree", - 'features': _("Compute folding range nesting levels"), - 'required_version': INTERVALTREE_REQVER}, - {'modname': "IPython", - 'package_name': "IPython", - 'features': _("IPython interactive python environment"), - 'required_version': IPYTHON_REQVER}, - {'modname': "jedi", - 'package_name': "jedi", - 'features': _("Main backend for the Python Language Server"), - 'required_version': JEDI_REQVER}, - {'modname': "jellyfish", - 'package_name': "jellyfish", - 'features': _("Optimize algorithms for folding"), - 'required_version': JELLYFISH_REQVER}, - {'modname': 'jsonschema', - 'package_name': 'jsonschema', - 'features': _('Verify if snippets files are valid'), - 'required_version': JSONSCHEMA_REQVER}, - {'modname': "keyring", - 'package_name': "keyring", - 'features': _("Save Github credentials to report internal " - "errors securely"), - 'required_version': KEYRING_REQVER}, - {'modname': "nbconvert", - 'package_name': "nbconvert", - 'features': _("Manipulate Jupyter notebooks in the Editor"), - 'required_version': NBCONVERT_REQVER}, - {'modname': "numpydoc", - 'package_name': "numpydoc", - 'features': _("Improve code completion for objects that use Numpy docstrings"), - 'required_version': NUMPYDOC_REQVER}, - {'modname': "paramiko", - 'package_name': "paramiko", - 'features': _("Connect to remote kernels through SSH"), - 'required_version': PARAMIKO_REQVER, - 'display': os.name == 'nt'}, - {'modname': "parso", - 'package_name': "parso", - 'features': _("Python parser that supports error recovery and " - "round-trip parsing"), - 'required_version': PARSO_REQVER}, - {'modname': "pexpect", - 'package_name': "pexpect", - 'features': _("Stdio support for our language server client"), - 'required_version': PEXPECT_REQVER}, - {'modname': "pickleshare", - 'package_name': "pickleshare", - 'features': _("Cache the list of installed Python modules"), - 'required_version': PICKLESHARE_REQVER}, - {'modname': "psutil", - 'package_name': "psutil", - 'features': _("CPU and memory usage info in the status bar"), - 'required_version': PSUTIL_REQVER}, - {'modname': "pygments", - 'package_name': "pygments", - 'features': _("Syntax highlighting for a lot of file types in the Editor"), - 'required_version': PYGMENTS_REQVER}, - {'modname': "pylint", - 'package_name': "pylint", - 'features': _("Static code analysis"), - 'required_version': PYLINT_REQVER}, - {'modname': 'pylsp', - 'package_name': 'python-lsp-server', - 'features': _("Code completion and linting for the Editor"), - 'required_version': PYLSP_REQVER}, - {'modname': 'pylsp_black', - 'package_name': 'python-lsp-black', - 'features': _("Autoformat Python files in the Editor with the Black " - "package"), - 'required_version': PYLSP_BLACK_REQVER}, - {'modname': 'pyls_spyder', - 'package_name': 'pyls-spyder', - 'features': _('Spyder plugin for the Python LSP Server'), - 'required_version': PYLS_SPYDER_REQVER}, - {'modname': "xdg", - 'package_name': "pyxdg", - 'features': _("Parse desktop files on Linux"), - 'required_version': PYXDG_REQVER, - 'display': sys.platform.startswith('linux')}, - {'modname': "zmq", - 'package_name': "pyzmq", - 'features': _("Client for the language server protocol (LSP)"), - 'required_version': PYZMQ_REQVER}, - {'modname': "qdarkstyle", - 'package_name': "qdarkstyle", - 'features': _("Dark style for the entire interface"), - 'required_version': QDARKSTYLE_REQVER}, - {'modname': "qstylizer", - 'package_name': "qstylizer", - 'features': _("Customize Qt stylesheets"), - 'required_version': QSTYLIZER_REQVER}, - {'modname': "qtawesome", - 'package_name': "qtawesome", - 'features': _("Icon theme based on FontAwesome and Material Design icons"), - 'required_version': QTAWESOME_REQVER}, - {'modname': "qtconsole", - 'package_name': "qtconsole", - 'features': _("Main package for the IPython console"), - 'required_version': QTCONSOLE_REQVER}, - {'modname': "qtpy", - 'package_name': "qtpy", - 'features': _("Abstraction layer for Python Qt bindings."), - 'required_version': QTPY_REQVER}, - {'modname': "rtree", - 'package_name': "rtree", - 'features': _("Fast access to code snippets regions"), - 'required_version': RTREE_REQVER}, - {'modname': "setuptools", - 'package_name': "setuptools", - 'features': _("Determine package version"), - 'required_version': SETUPTOOLS_REQVER}, - {'modname': "sphinx", - 'package_name': "sphinx", - 'features': _("Show help for objects in the Editor and Consoles in a dedicated pane"), - 'required_version': SPHINX_REQVER}, - {'modname': "spyder_kernels", - 'package_name': "spyder-kernels", - 'features': _("Jupyter kernels for the Spyder console"), - 'required_version': SPYDER_KERNELS_REQVER}, - {'modname': 'textdistance', - 'package_name': "textdistance", - 'features': _('Compute distances between strings'), - 'required_version': TEXTDISTANCE_REQVER}, - {'modname': "three_merge", - 'package_name': "three-merge", - 'features': _("3-way merge algorithm to merge document changes"), - 'required_version': THREE_MERGE_REQVER}, - {'modname': "watchdog", - 'package_name': "watchdog", - 'features': _("Watch file changes on project directories"), - 'required_version': WATCHDOG_REQVER}, -] - - -# Optional dependencies -DESCRIPTIONS += [ - {'modname': "cython", - 'package_name': "cython", - 'features': _("Run Cython files in the IPython Console"), - 'required_version': CYTHON_REQVER, - 'kind': OPTIONAL}, - {'modname': "matplotlib", - 'package_name': "matplotlib", - 'features': _("2D/3D plotting in the IPython console"), - 'required_version': MATPLOTLIB_REQVER, - 'kind': OPTIONAL}, - {'modname': "numpy", - 'package_name': "numpy", - 'features': _("View and edit two and three dimensional arrays in the Variable Explorer"), - 'required_version': NUMPY_REQVER, - 'kind': OPTIONAL}, - {'modname': 'pandas', - 'package_name': 'pandas', - 'features': _("View and edit DataFrames and Series in the Variable Explorer"), - 'required_version': PANDAS_REQVER, - 'kind': OPTIONAL}, - {'modname': "scipy", - 'package_name': "scipy", - 'features': _("Import Matlab workspace files in the Variable Explorer"), - 'required_version': SCIPY_REQVER, - 'kind': OPTIONAL}, - {'modname': "sympy", - 'package_name': "sympy", - 'features': _("Symbolic mathematics in the IPython Console"), - 'required_version': SYMPY_REQVER, - 'kind': OPTIONAL} -] - - -# ============================================================================= -# Code -# ============================================================================= -class Dependency(object): - """Spyder's dependency - - version may starts with =, >=, > or < to specify the exact requirement ; - multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0')""" - - OK = 'OK' - NOK = 'NOK' - - def __init__(self, modname, package_name, features, required_version, - installed_version=None, kind=MANDATORY): - self.modname = modname - self.package_name = package_name - self.features = features - self.required_version = required_version - self.kind = kind - - # Although this is not necessarily the case, it's customary that a - # package's distribution name be it's name on PyPI with hyphens - # replaced by underscores. - # Example: - # * Package name: python-lsp-black. - # * Distribution name: python_lsp_black - self.distribution_name = self.package_name.replace('-', '_') - - if installed_version is None: - try: - self.installed_version = programs.get_module_version(modname) - if not self.installed_version: - # Use get_package_version and the distribution name - # because there are cases for which the version can't - # be obtained from the module (e.g. pylsp_black). - self.installed_version = programs.get_package_version( - self.distribution_name) - except Exception: - # NOTE: Don't add any exception type here! - # Modules can fail to import in several ways besides - # ImportError - self.installed_version = None - else: - self.installed_version = installed_version - - def check(self): - """Check if dependency is installed""" - if self.required_version: - installed = programs.is_module_installed( - self.modname, - self.required_version, - distribution_name=self.distribution_name - ) - return installed - else: - return True - - def get_installed_version(self): - """Return dependency status (string)""" - if self.check(): - return '%s (%s)' % (self.installed_version, self.OK) - else: - return '%s (%s)' % (self.installed_version, self.NOK) - - def get_status(self): - """Return dependency status (string)""" - if self.check(): - return self.OK - else: - return self.NOK - - -DEPENDENCIES = [] - - -def add(modname, package_name, features, required_version, - installed_version=None, kind=MANDATORY): - """Add Spyder dependency""" - global DEPENDENCIES - for dependency in DEPENDENCIES: - # Avoid showing an unnecessary error when running our tests. - if running_in_ci() and 'spyder_boilerplate' in modname: - continue - - if dependency.modname == modname: - raise ValueError( - f"Dependency has already been registered: {modname}") - - DEPENDENCIES += [Dependency(modname, package_name, features, - required_version, - installed_version, kind)] - - -def check(modname): - """Check if required dependency is installed""" - for dependency in DEPENDENCIES: - if dependency.modname == modname: - return dependency.check() - else: - raise RuntimeError("Unknown dependency %s" % modname) - - -def status(deps=DEPENDENCIES, linesep=os.linesep): - """Return a status of dependencies.""" - maxwidth = 0 - data = [] - - # Find maximum width - for dep in deps: - title = dep.modname - if dep.required_version is not None: - title += ' ' + dep.required_version - - maxwidth = max([maxwidth, len(title)]) - dep_order = {MANDATORY: '0', OPTIONAL: '1', PLUGIN: '2'} - order_dep = {'0': MANDATORY, '1': OPTIONAL, '2': PLUGIN} - data.append([dep_order[dep.kind], title, dep.get_installed_version()]) - - # Construct text and sort by kind and name - maxwidth += 1 - text = "" - prev_order = '-1' - for order, title, version in sorted( - data, key=lambda x: x[0] + x[1].lower()): - if order != prev_order: - name = order_dep[order] - if name == MANDATORY: - text += f'# {name.capitalize()}:{linesep}' - else: - text += f'{linesep}# {name.capitalize()}:{linesep}' - prev_order = order - - text += f'{title.ljust(maxwidth)}: {version}{linesep}' - - # Remove spurious linesep when reporting deps to Github - if not linesep == '
': - text = text[:-1] - - return text - - -def missing_dependencies(): - """Return the status of missing dependencies (if any)""" - missing_deps = [] - for dependency in DEPENDENCIES: - if dependency.kind != OPTIONAL and not dependency.check(): - missing_deps.append(dependency) - - if missing_deps: - return status(deps=missing_deps, linesep='
') - else: - return "" - - -def declare_dependencies(): - for dep in DESCRIPTIONS: - if dep.get('display', True): - add(dep['modname'], dep['package_name'], - dep['features'], dep['required_version'], - kind=dep.get('kind', MANDATORY)) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Module checking Spyder runtime dependencies""" + +# Standard library imports +import os +import os.path as osp +import sys + +# Local imports +from spyder.config.base import _, is_pynsist, running_in_ci, running_in_mac_app +from spyder.utils import programs + +HERE = osp.dirname(osp.abspath(__file__)) + +# ============================================================================= +# Kind of dependency +# ============================================================================= +MANDATORY = 'mandatory' +OPTIONAL = 'optional' +PLUGIN = 'spyder plugins' + + +# ============================================================================= +# Versions +# ============================================================================= +# Hard dependencies +APPLAUNCHSERVICES_REQVER = '>=0.3.0' +ATOMICWRITES_REQVER = '>=1.2.0' +CHARDET_REQVER = '>=2.0.0' +CLOUDPICKLE_REQVER = '>=0.5.0' +COOKIECUTTER_REQVER = '>=1.6.0' +DIFF_MATCH_PATCH_REQVER = '>=20181111' +# None for pynsist install for now +# (check way to add dist.info/egg.info from packages without wheels available) +INTERVALTREE_REQVER = None if is_pynsist() else '>=3.0.2' +IPYTHON_REQVER = ">=7.31.1;<8.0.0" +JEDI_REQVER = '>=0.17.2;<0.19.0' +JELLYFISH_REQVER = '>=0.7' +JSONSCHEMA_REQVER = '>=3.2.0' +KEYRING_REQVER = '>=17.0.0' +NBCONVERT_REQVER = '>=4.0' +NUMPYDOC_REQVER = '>=0.6.0' +PARAMIKO_REQVER = '>=2.4.0' +PARSO_REQVER = '>=0.7.0;<0.9.0' +PEXPECT_REQVER = '>=4.4.0' +PICKLESHARE_REQVER = '>=0.4' +PSUTIL_REQVER = '>=5.3' +PYGMENTS_REQVER = '>=2.0' +PYLINT_REQVER = '>=2.5.0;<3.0' +PYLSP_REQVER = '>=1.5.0;<1.6.0' +PYLSP_BLACK_REQVER = '>=1.2.0' +PYLS_SPYDER_REQVER = '>=0.4.0' +PYXDG_REQVER = '>=0.26' +PYZMQ_REQVER = '>=22.1.0' +QDARKSTYLE_REQVER = '>=3.0.2;<3.1.0' +QSTYLIZER_REQVER = '>=0.1.10' +QTAWESOME_REQVER = '>=1.0.2' +QTCONSOLE_REQVER = '>=5.3.0;<5.4.0' +QTPY_REQVER = '>=2.1.0' +RTREE_REQVER = '>=0.9.7' +SETUPTOOLS_REQVER = '>=49.6.0' +SPHINX_REQVER = '>=0.6.6' +SPYDER_KERNELS_REQVER = '>=2.3.2;<2.4.0' +TEXTDISTANCE_REQVER = '>=4.2.0' +THREE_MERGE_REQVER = '>=0.1.1' +# None for pynsist install for now +# (check way to add dist.info/egg.info from packages without wheels available) +WATCHDOG_REQVER = None if is_pynsist() else '>=0.10.3' + + +# Optional dependencies +CYTHON_REQVER = '>=0.21' +MATPLOTLIB_REQVER = '>=3.0.0' +NUMPY_REQVER = '>=1.7' +PANDAS_REQVER = '>=1.1.1' +SCIPY_REQVER = '>=0.17.0' +SYMPY_REQVER = '>=0.7.3' + + +# ============================================================================= +# Descriptions +# NOTE: We declare our dependencies in **alphabetical** order +# If some dependencies are limited to some systems only, add a 'display' key. +# See 'applaunchservices' for an example. +# ============================================================================= +# List of descriptions +DESCRIPTIONS = [ + {'modname': "applaunchservices", + 'package_name': "applaunchservices", + 'features': _("Notify macOS that Spyder can open Python files"), + 'required_version': APPLAUNCHSERVICES_REQVER, + 'display': sys.platform == "darwin" and not running_in_mac_app()}, + {'modname': "atomicwrites", + 'package_name': "atomicwrites", + 'features': _("Atomic file writes in the Editor"), + 'required_version': ATOMICWRITES_REQVER}, + {'modname': "chardet", + 'package_name': "chardet", + 'features': _("Character encoding auto-detection for the Editor"), + 'required_version': CHARDET_REQVER}, + {'modname': "cloudpickle", + 'package_name': "cloudpickle", + 'features': _("Handle communications between kernel and frontend"), + 'required_version': CLOUDPICKLE_REQVER}, + {'modname': "cookiecutter", + 'package_name': "cookiecutter", + 'features': _("Create projects from cookiecutter templates"), + 'required_version': COOKIECUTTER_REQVER}, + {'modname': "diff_match_patch", + 'package_name': "diff-match-patch", + 'features': _("Compute text file diff changes during edition"), + 'required_version': DIFF_MATCH_PATCH_REQVER}, + {'modname': "intervaltree", + 'package_name': "intervaltree", + 'features': _("Compute folding range nesting levels"), + 'required_version': INTERVALTREE_REQVER}, + {'modname': "IPython", + 'package_name': "IPython", + 'features': _("IPython interactive python environment"), + 'required_version': IPYTHON_REQVER}, + {'modname': "jedi", + 'package_name': "jedi", + 'features': _("Main backend for the Python Language Server"), + 'required_version': JEDI_REQVER}, + {'modname': "jellyfish", + 'package_name': "jellyfish", + 'features': _("Optimize algorithms for folding"), + 'required_version': JELLYFISH_REQVER}, + {'modname': 'jsonschema', + 'package_name': 'jsonschema', + 'features': _('Verify if snippets files are valid'), + 'required_version': JSONSCHEMA_REQVER}, + {'modname': "keyring", + 'package_name': "keyring", + 'features': _("Save Github credentials to report internal " + "errors securely"), + 'required_version': KEYRING_REQVER}, + {'modname': "nbconvert", + 'package_name': "nbconvert", + 'features': _("Manipulate Jupyter notebooks in the Editor"), + 'required_version': NBCONVERT_REQVER}, + {'modname': "numpydoc", + 'package_name': "numpydoc", + 'features': _("Improve code completion for objects that use Numpy docstrings"), + 'required_version': NUMPYDOC_REQVER}, + {'modname': "paramiko", + 'package_name': "paramiko", + 'features': _("Connect to remote kernels through SSH"), + 'required_version': PARAMIKO_REQVER, + 'display': os.name == 'nt'}, + {'modname': "parso", + 'package_name': "parso", + 'features': _("Python parser that supports error recovery and " + "round-trip parsing"), + 'required_version': PARSO_REQVER}, + {'modname': "pexpect", + 'package_name': "pexpect", + 'features': _("Stdio support for our language server client"), + 'required_version': PEXPECT_REQVER}, + {'modname': "pickleshare", + 'package_name': "pickleshare", + 'features': _("Cache the list of installed Python modules"), + 'required_version': PICKLESHARE_REQVER}, + {'modname': "psutil", + 'package_name': "psutil", + 'features': _("CPU and memory usage info in the status bar"), + 'required_version': PSUTIL_REQVER}, + {'modname': "pygments", + 'package_name': "pygments", + 'features': _("Syntax highlighting for a lot of file types in the Editor"), + 'required_version': PYGMENTS_REQVER}, + {'modname': "pylint", + 'package_name': "pylint", + 'features': _("Static code analysis"), + 'required_version': PYLINT_REQVER}, + {'modname': 'pylsp', + 'package_name': 'python-lsp-server', + 'features': _("Code completion and linting for the Editor"), + 'required_version': PYLSP_REQVER}, + {'modname': 'pylsp_black', + 'package_name': 'python-lsp-black', + 'features': _("Autoformat Python files in the Editor with the Black " + "package"), + 'required_version': PYLSP_BLACK_REQVER}, + {'modname': 'pyls_spyder', + 'package_name': 'pyls-spyder', + 'features': _('Spyder plugin for the Python LSP Server'), + 'required_version': PYLS_SPYDER_REQVER}, + {'modname': "xdg", + 'package_name': "pyxdg", + 'features': _("Parse desktop files on Linux"), + 'required_version': PYXDG_REQVER, + 'display': sys.platform.startswith('linux')}, + {'modname': "zmq", + 'package_name': "pyzmq", + 'features': _("Client for the language server protocol (LSP)"), + 'required_version': PYZMQ_REQVER}, + {'modname': "qdarkstyle", + 'package_name': "qdarkstyle", + 'features': _("Dark style for the entire interface"), + 'required_version': QDARKSTYLE_REQVER}, + {'modname': "qstylizer", + 'package_name': "qstylizer", + 'features': _("Customize Qt stylesheets"), + 'required_version': QSTYLIZER_REQVER}, + {'modname': "qtawesome", + 'package_name': "qtawesome", + 'features': _("Icon theme based on FontAwesome and Material Design icons"), + 'required_version': QTAWESOME_REQVER}, + {'modname': "qtconsole", + 'package_name': "qtconsole", + 'features': _("Main package for the IPython console"), + 'required_version': QTCONSOLE_REQVER}, + {'modname': "qtpy", + 'package_name': "qtpy", + 'features': _("Abstraction layer for Python Qt bindings."), + 'required_version': QTPY_REQVER}, + {'modname': "rtree", + 'package_name': "rtree", + 'features': _("Fast access to code snippets regions"), + 'required_version': RTREE_REQVER}, + {'modname': "setuptools", + 'package_name': "setuptools", + 'features': _("Determine package version"), + 'required_version': SETUPTOOLS_REQVER}, + {'modname': "sphinx", + 'package_name': "sphinx", + 'features': _("Show help for objects in the Editor and Consoles in a dedicated pane"), + 'required_version': SPHINX_REQVER}, + {'modname': "spyder_kernels", + 'package_name': "spyder-kernels", + 'features': _("Jupyter kernels for the Spyder console"), + 'required_version': SPYDER_KERNELS_REQVER}, + {'modname': 'textdistance', + 'package_name': "textdistance", + 'features': _('Compute distances between strings'), + 'required_version': TEXTDISTANCE_REQVER}, + {'modname': "three_merge", + 'package_name': "three-merge", + 'features': _("3-way merge algorithm to merge document changes"), + 'required_version': THREE_MERGE_REQVER}, + {'modname': "watchdog", + 'package_name': "watchdog", + 'features': _("Watch file changes on project directories"), + 'required_version': WATCHDOG_REQVER}, +] + + +# Optional dependencies +DESCRIPTIONS += [ + {'modname': "cython", + 'package_name': "cython", + 'features': _("Run Cython files in the IPython Console"), + 'required_version': CYTHON_REQVER, + 'kind': OPTIONAL}, + {'modname': "matplotlib", + 'package_name': "matplotlib", + 'features': _("2D/3D plotting in the IPython console"), + 'required_version': MATPLOTLIB_REQVER, + 'kind': OPTIONAL}, + {'modname': "numpy", + 'package_name': "numpy", + 'features': _("View and edit two and three dimensional arrays in the Variable Explorer"), + 'required_version': NUMPY_REQVER, + 'kind': OPTIONAL}, + {'modname': 'pandas', + 'package_name': 'pandas', + 'features': _("View and edit DataFrames and Series in the Variable Explorer"), + 'required_version': PANDAS_REQVER, + 'kind': OPTIONAL}, + {'modname': "scipy", + 'package_name': "scipy", + 'features': _("Import Matlab workspace files in the Variable Explorer"), + 'required_version': SCIPY_REQVER, + 'kind': OPTIONAL}, + {'modname': "sympy", + 'package_name': "sympy", + 'features': _("Symbolic mathematics in the IPython Console"), + 'required_version': SYMPY_REQVER, + 'kind': OPTIONAL} +] + + +# ============================================================================= +# Code +# ============================================================================= +class Dependency(object): + """Spyder's dependency + + version may starts with =, >=, > or < to specify the exact requirement ; + multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0')""" + + OK = 'OK' + NOK = 'NOK' + + def __init__(self, modname, package_name, features, required_version, + installed_version=None, kind=MANDATORY): + self.modname = modname + self.package_name = package_name + self.features = features + self.required_version = required_version + self.kind = kind + + # Although this is not necessarily the case, it's customary that a + # package's distribution name be it's name on PyPI with hyphens + # replaced by underscores. + # Example: + # * Package name: python-lsp-black. + # * Distribution name: python_lsp_black + self.distribution_name = self.package_name.replace('-', '_') + + if installed_version is None: + try: + self.installed_version = programs.get_module_version(modname) + if not self.installed_version: + # Use get_package_version and the distribution name + # because there are cases for which the version can't + # be obtained from the module (e.g. pylsp_black). + self.installed_version = programs.get_package_version( + self.distribution_name) + except Exception: + # NOTE: Don't add any exception type here! + # Modules can fail to import in several ways besides + # ImportError + self.installed_version = None + else: + self.installed_version = installed_version + + def check(self): + """Check if dependency is installed""" + if self.required_version: + installed = programs.is_module_installed( + self.modname, + self.required_version, + distribution_name=self.distribution_name + ) + return installed + else: + return True + + def get_installed_version(self): + """Return dependency status (string)""" + if self.check(): + return '%s (%s)' % (self.installed_version, self.OK) + else: + return '%s (%s)' % (self.installed_version, self.NOK) + + def get_status(self): + """Return dependency status (string)""" + if self.check(): + return self.OK + else: + return self.NOK + + +DEPENDENCIES = [] + + +def add(modname, package_name, features, required_version, + installed_version=None, kind=MANDATORY): + """Add Spyder dependency""" + global DEPENDENCIES + for dependency in DEPENDENCIES: + # Avoid showing an unnecessary error when running our tests. + if running_in_ci() and 'spyder_boilerplate' in modname: + continue + + if dependency.modname == modname: + raise ValueError( + f"Dependency has already been registered: {modname}") + + DEPENDENCIES += [Dependency(modname, package_name, features, + required_version, + installed_version, kind)] + + +def check(modname): + """Check if required dependency is installed""" + for dependency in DEPENDENCIES: + if dependency.modname == modname: + return dependency.check() + else: + raise RuntimeError("Unknown dependency %s" % modname) + + +def status(deps=DEPENDENCIES, linesep=os.linesep): + """Return a status of dependencies.""" + maxwidth = 0 + data = [] + + # Find maximum width + for dep in deps: + title = dep.modname + if dep.required_version is not None: + title += ' ' + dep.required_version + + maxwidth = max([maxwidth, len(title)]) + dep_order = {MANDATORY: '0', OPTIONAL: '1', PLUGIN: '2'} + order_dep = {'0': MANDATORY, '1': OPTIONAL, '2': PLUGIN} + data.append([dep_order[dep.kind], title, dep.get_installed_version()]) + + # Construct text and sort by kind and name + maxwidth += 1 + text = "" + prev_order = '-1' + for order, title, version in sorted( + data, key=lambda x: x[0] + x[1].lower()): + if order != prev_order: + name = order_dep[order] + if name == MANDATORY: + text += f'# {name.capitalize()}:{linesep}' + else: + text += f'{linesep}# {name.capitalize()}:{linesep}' + prev_order = order + + text += f'{title.ljust(maxwidth)}: {version}{linesep}' + + # Remove spurious linesep when reporting deps to Github + if not linesep == '
': + text = text[:-1] + + return text + + +def missing_dependencies(): + """Return the status of missing dependencies (if any)""" + missing_deps = [] + for dependency in DEPENDENCIES: + if dependency.kind != OPTIONAL and not dependency.check(): + missing_deps.append(dependency) + + if missing_deps: + return status(deps=missing_deps, linesep='
') + else: + return "" + + +def declare_dependencies(): + for dep in DESCRIPTIONS: + if dep.get('display', True): + add(dep['modname'], dep['package_name'], + dep['features'], dep['required_version'], + kind=dep.get('kind', MANDATORY)) diff --git a/spyder/otherplugins.py b/spyder/otherplugins.py index dba86f52fcc..0e3fae3e5c0 100644 --- a/spyder/otherplugins.py +++ b/spyder/otherplugins.py @@ -1,128 +1,128 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Spyder third-party plugins configuration management. -""" - -# Standard library imports -import importlib -import logging -import os -import os.path as osp -import sys -import traceback - -# Local imports -from spyder.config.base import get_conf_path -from spyder.py3compat import to_text_string - - -# Constants -logger = logging.getLogger(__name__) -USER_PLUGIN_DIR = "plugins" -PLUGIN_PREFIX = "spyder_" -IO_PREFIX = PLUGIN_PREFIX + "io_" - - -def get_spyderplugins_mods(io=False): - """Import modules from plugins package and return the list""" - # Create user directory - user_plugin_path = osp.join(get_conf_path(), USER_PLUGIN_DIR) - if not osp.isdir(user_plugin_path): - os.makedirs(user_plugin_path) - - modlist, modnames = [], [] - - # The user plugins directory is given the priority when looking for modules - for plugin_path in [user_plugin_path] + sys.path: - _get_spyderplugins(plugin_path, io, modnames, modlist) - return modlist - - -def _get_spyderplugins(plugin_path, is_io, modnames, modlist): - """Scan the directory `plugin_path` for plugin packages and loads them.""" - if not osp.isdir(plugin_path): - return - - for name in os.listdir(plugin_path): - # This is needed in order to register the spyder_io_hdf5 plugin. - # See spyder-ide/spyder#4487. - # Is this a Spyder plugin? - if not name.startswith(PLUGIN_PREFIX): - continue - - # Ensure right type of plugin - if is_io and not name.startswith(IO_PREFIX): - continue - - # Skip names that end in certain suffixes - forbidden_suffixes = ['dist-info', 'egg.info', 'egg-info', 'egg-link', - 'kernels', 'boilerplate'] - if any([name.endswith(s) for s in forbidden_suffixes]): - continue - - # Import the plugin - _import_plugin(name, plugin_path, modnames, modlist) - - -def _import_plugin(module_name, plugin_path, modnames, modlist): - """Import the plugin `module_name` from `plugin_path`, add it to `modlist` - and adds its name to `modnames`. - """ - if module_name in modnames: - return - try: - # First add a mock module with the LOCALEPATH attribute so that the - # helper method can find the locale on import - mock = _ModuleMock() - mock.LOCALEPATH = osp.join(plugin_path, module_name, 'locale') - sys.modules[module_name] = mock - - if osp.isdir(osp.join(plugin_path, module_name)): - module = _import_module_from_path(module_name, plugin_path) - else: - module = None - - # Then restore the actual loaded module instead of the mock - if module and getattr(module, 'PLUGIN_CLASS', False): - sys.modules[module_name] = module - modlist.append(module) - modnames.append(module_name) - except Exception as e: - sys.stderr.write("ERROR: 3rd party plugin import failed for " - "`{0}`\n".format(module_name)) - traceback.print_exc(file=sys.stderr) - - -def _import_module_from_path(module_name, plugin_path): - """Imports `module_name` from `plugin_path`. - - Return None if no module is found. - """ - module = None - try: - spec = importlib.machinery.PathFinder.find_spec( - module_name, - [plugin_path]) - - if spec: - module = spec.loader.load_module(module_name) - except Exception as err: - debug_message = ("plugin: '{module_name}' load failed with `{err}`" - "").format(module_name=module_name, - err=to_text_string(err)) - logger.debug(debug_message) - - return module - - -class _ModuleMock(): - """This mock module is added to sys.modules on plugin load to add the - location of the LOCALEDATA so that the module loads succesfully. - Once loaded the module is replaced by the actual loaded module object. - """ - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Spyder third-party plugins configuration management. +""" + +# Standard library imports +import importlib +import logging +import os +import os.path as osp +import sys +import traceback + +# Local imports +from spyder.config.base import get_conf_path +from spyder.py3compat import to_text_string + + +# Constants +logger = logging.getLogger(__name__) +USER_PLUGIN_DIR = "plugins" +PLUGIN_PREFIX = "spyder_" +IO_PREFIX = PLUGIN_PREFIX + "io_" + + +def get_spyderplugins_mods(io=False): + """Import modules from plugins package and return the list""" + # Create user directory + user_plugin_path = osp.join(get_conf_path(), USER_PLUGIN_DIR) + if not osp.isdir(user_plugin_path): + os.makedirs(user_plugin_path) + + modlist, modnames = [], [] + + # The user plugins directory is given the priority when looking for modules + for plugin_path in [user_plugin_path] + sys.path: + _get_spyderplugins(plugin_path, io, modnames, modlist) + return modlist + + +def _get_spyderplugins(plugin_path, is_io, modnames, modlist): + """Scan the directory `plugin_path` for plugin packages and loads them.""" + if not osp.isdir(plugin_path): + return + + for name in os.listdir(plugin_path): + # This is needed in order to register the spyder_io_hdf5 plugin. + # See spyder-ide/spyder#4487. + # Is this a Spyder plugin? + if not name.startswith(PLUGIN_PREFIX): + continue + + # Ensure right type of plugin + if is_io and not name.startswith(IO_PREFIX): + continue + + # Skip names that end in certain suffixes + forbidden_suffixes = ['dist-info', 'egg.info', 'egg-info', 'egg-link', + 'kernels', 'boilerplate'] + if any([name.endswith(s) for s in forbidden_suffixes]): + continue + + # Import the plugin + _import_plugin(name, plugin_path, modnames, modlist) + + +def _import_plugin(module_name, plugin_path, modnames, modlist): + """Import the plugin `module_name` from `plugin_path`, add it to `modlist` + and adds its name to `modnames`. + """ + if module_name in modnames: + return + try: + # First add a mock module with the LOCALEPATH attribute so that the + # helper method can find the locale on import + mock = _ModuleMock() + mock.LOCALEPATH = osp.join(plugin_path, module_name, 'locale') + sys.modules[module_name] = mock + + if osp.isdir(osp.join(plugin_path, module_name)): + module = _import_module_from_path(module_name, plugin_path) + else: + module = None + + # Then restore the actual loaded module instead of the mock + if module and getattr(module, 'PLUGIN_CLASS', False): + sys.modules[module_name] = module + modlist.append(module) + modnames.append(module_name) + except Exception as e: + sys.stderr.write("ERROR: 3rd party plugin import failed for " + "`{0}`\n".format(module_name)) + traceback.print_exc(file=sys.stderr) + + +def _import_module_from_path(module_name, plugin_path): + """Imports `module_name` from `plugin_path`. + + Return None if no module is found. + """ + module = None + try: + spec = importlib.machinery.PathFinder.find_spec( + module_name, + [plugin_path]) + + if spec: + module = spec.loader.load_module(module_name) + except Exception as err: + debug_message = ("plugin: '{module_name}' load failed with `{err}`" + "").format(module_name=module_name, + err=to_text_string(err)) + logger.debug(debug_message) + + return module + + +class _ModuleMock(): + """This mock module is added to sys.modules on plugin load to add the + location of the LOCALEDATA so that the module loads succesfully. + Once loaded the module is replaced by the actual loaded module object. + """ + pass diff --git a/spyder/pil_patch.py b/spyder/pil_patch.py index 0609f056dbd..c661fe2ea81 100644 --- a/spyder/pil_patch.py +++ b/spyder/pil_patch.py @@ -1,61 +1,61 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -r""" -Patching PIL (Python Imaging Library) to avoid triggering the error: -AccessInit: hash collision: 3 for both 1 and 1 - -This error is occurring because of a bug in the PIL import mechanism. - -How to reproduce this bug in a standard Python interpreter outside Spyder? -By importing PIL by two different mechanisms - -Example on Windows: -=============================================================================== -C:\Python27\Lib\site-packages>python -Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 -Type "help", "copyright", "credits" or "license" for more information. ->>> import Image ->>> from PIL import Image -AccessInit: hash collision: 3 for both 1 and 1 -=============================================================================== - -Another example on Windows (actually that's the same, but this is the exact -case encountered with Spyder when the global working directory is the -site-packages directory): -=============================================================================== -C:\Python27\Lib\site-packages>python -Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 -Type "help", "copyright", "credits" or "license" for more information. ->>> import scipy ->>> from pylab import * -AccessInit: hash collision: 3 for both 1 and 1 -=============================================================================== - -The solution to this fix is the following patch: -=============================================================================== -C:\Python27\Lib\site-packages>python -Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win -32 -Type "help", "copyright", "credits" or "license" for more information. ->>> import Image ->>> import PIL ->>> PIL.Image = Image ->>> from PIL import Image ->>> -=============================================================================== -""" - -try: - # For Pillow compatibility - from PIL import Image - import PIL - PIL.Image = Image -except ImportError: - # For PIL - import Image - import PIL - PIL.Image = Image +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +r""" +Patching PIL (Python Imaging Library) to avoid triggering the error: +AccessInit: hash collision: 3 for both 1 and 1 + +This error is occurring because of a bug in the PIL import mechanism. + +How to reproduce this bug in a standard Python interpreter outside Spyder? +By importing PIL by two different mechanisms + +Example on Windows: +=============================================================================== +C:\Python27\Lib\site-packages>python +Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 +Type "help", "copyright", "credits" or "license" for more information. +>>> import Image +>>> from PIL import Image +AccessInit: hash collision: 3 for both 1 and 1 +=============================================================================== + +Another example on Windows (actually that's the same, but this is the exact +case encountered with Spyder when the global working directory is the +site-packages directory): +=============================================================================== +C:\Python27\Lib\site-packages>python +Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win32 +Type "help", "copyright", "credits" or "license" for more information. +>>> import scipy +>>> from pylab import * +AccessInit: hash collision: 3 for both 1 and 1 +=============================================================================== + +The solution to this fix is the following patch: +=============================================================================== +C:\Python27\Lib\site-packages>python +Python 2.7.2 (default, Jun 12 2011, 15:08:59) [MSC v.1500 32 bit (Intel)] on win +32 +Type "help", "copyright", "credits" or "license" for more information. +>>> import Image +>>> import PIL +>>> PIL.Image = Image +>>> from PIL import Image +>>> +=============================================================================== +""" + +try: + # For Pillow compatibility + from PIL import Image + import PIL + PIL.Image = Image +except ImportError: + # For PIL + import Image + import PIL + PIL.Image = Image diff --git a/spyder/plugins/breakpoints/api.py b/spyder/plugins/breakpoints/api.py index fe356dbbe79..c00f01b80e0 100644 --- a/spyder/plugins/breakpoints/api.py +++ b/spyder/plugins/breakpoints/api.py @@ -1,14 +1,14 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Breakpoints Plugin API. -""" - -# Local imports -from spyder.plugins.breakpoints.plugin import BreakpointsActions -from spyder.plugins.breakpoints.widgets.main_widget import ( - BreakpointTableViewActions) +# -*- coding: utf-8 -*- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Breakpoints Plugin API. +""" + +# Local imports +from spyder.plugins.breakpoints.plugin import BreakpointsActions +from spyder.plugins.breakpoints.widgets.main_widget import ( + BreakpointTableViewActions) diff --git a/spyder/plugins/breakpoints/plugin.py b/spyder/plugins/breakpoints/plugin.py index 66ec9b8d0b4..144e5b7ab85 100644 --- a/spyder/plugins/breakpoints/plugin.py +++ b/spyder/plugins/breakpoints/plugin.py @@ -1,209 +1,209 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Breakpoint Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.breakpoints.widgets.main_widget import BreakpointWidget -from spyder.plugins.mainmenu.api import ApplicationMenus - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -class BreakpointsActions: - ListBreakpoints = 'list_breakpoints_action' - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Breakpoints(SpyderDockablePlugin): - """ - Breakpoint list Plugin. - """ - NAME = 'breakpoints' - REQUIRES = [Plugins.Editor] - OPTIONAL = [Plugins.MainMenu] - TABIFY = [Plugins.Help] - WIDGET_CLASS = BreakpointWidget - CONF_SECTION = NAME - CONF_FILE = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_clear_all_breakpoints_requested = Signal() - """ - This signal is emitted to send a request to clear all assigned - breakpoints. - """ - - sig_clear_breakpoint_requested = Signal(str, int) - """ - This signal is emitted to send a request to clear a single breakpoint. - - Parameters - ---------- - filename: str - The path to filename containing the breakpoint. - line_number: int - The line number of the breakpoint. - """ - - sig_edit_goto_requested = Signal(str, int, str) - """ - Send a request to open a file in the editor at a given row and word. - - Parameters - ---------- - filename: str - The path to the filename containing the breakpoint. - line_number: int - The line number of the breakpoint. - word: str - Text `word` to select on given `line_number`. - """ - - sig_conditional_breakpoint_requested = Signal() - """ - Send a request to set/edit a condition on a single selected breakpoint. - """ - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Breakpoints") - - def get_description(self): - return _("Manage code breakpoints in a unified pane.") - - def get_icon(self): - return self.create_icon('breakpoints') - - def on_initialize(self): - widget = self.get_widget() - - widget.sig_clear_all_breakpoints_requested.connect( - self.sig_clear_all_breakpoints_requested) - widget.sig_clear_breakpoint_requested.connect( - self.sig_clear_breakpoint_requested) - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_conditional_breakpoint_requested.connect( - self.sig_conditional_breakpoint_requested) - - self.create_action( - BreakpointsActions.ListBreakpoints, - _("List breakpoints"), - triggered=lambda: self.switch_to_plugin(), - icon=self.get_icon(), - ) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - list_action = self.get_action(BreakpointsActions.ListBreakpoints) - - # TODO: change name of this signal on editor - editor.breakpoints_saved.connect(self.set_data) - widget.sig_clear_all_breakpoints_requested.connect( - editor.clear_all_breakpoints) - widget.sig_clear_breakpoint_requested.connect(editor.clear_breakpoint) - widget.sig_edit_goto_requested.connect(editor.load) - widget.sig_conditional_breakpoint_requested.connect( - editor.set_or_edit_conditional_breakpoint) - - # TODO: Fix location once the sections are defined - editor.pythonfile_dependent_actions += [list_action] - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - list_action = self.get_action(BreakpointsActions.ListBreakpoints) - mainmenu.add_item_to_application_menu( - list_action, menu_id=ApplicationMenus.Debug) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - list_action = self.get_action(BreakpointsActions.ListBreakpoints) - - editor.breakpoints_saved.disconnect(self.set_data) - widget.sig_clear_all_breakpoints_requested.disconnect( - editor.clear_all_breakpoints) - widget.sig_clear_breakpoint_requested.disconnect( - editor.clear_breakpoint) - widget.sig_edit_goto_requested.disconnect(editor.load) - widget.sig_conditional_breakpoint_requested.disconnect( - editor.set_or_edit_conditional_breakpoint) - - editor.pythonfile_dependent_actions.remove(list_action) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - BreakpointsActions.ListBreakpoints, menu_id=ApplicationMenus.Debug) - - # --- Private API - # ------------------------------------------------------------------------ - def _load_data(self): - """ - Load breakpoint data from configuration file. - """ - breakpoints_dict = self.get_conf( - 'breakpoints', - default={}, - section='run', - ) - for filename in list(breakpoints_dict.keys()): - if not osp.isfile(filename): - breakpoints_dict.pop(filename) - continue - # Make sure we don't have the same file under different names - new_filename = osp.normcase(filename) - if new_filename != filename: - bp = breakpoints_dict.pop(filename) - if new_filename in breakpoints_dict: - breakpoints_dict[new_filename].extend(bp) - else: - breakpoints_dict[new_filename] = bp - - return breakpoints_dict - - # --- Public API - # ------------------------------------------------------------------------ - def set_data(self, data=None): - """ - Set breakpoint data on widget. - - Parameters - ---------- - data: dict, optional - Breakpoint data to use. If None, data from the configuration - will be loaded. Default is None. - """ - if data is None: - data = self._load_data() - - self.get_widget().set_data(data) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Breakpoint Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.breakpoints.widgets.main_widget import BreakpointWidget +from spyder.plugins.mainmenu.api import ApplicationMenus + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +class BreakpointsActions: + ListBreakpoints = 'list_breakpoints_action' + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Breakpoints(SpyderDockablePlugin): + """ + Breakpoint list Plugin. + """ + NAME = 'breakpoints' + REQUIRES = [Plugins.Editor] + OPTIONAL = [Plugins.MainMenu] + TABIFY = [Plugins.Help] + WIDGET_CLASS = BreakpointWidget + CONF_SECTION = NAME + CONF_FILE = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_clear_all_breakpoints_requested = Signal() + """ + This signal is emitted to send a request to clear all assigned + breakpoints. + """ + + sig_clear_breakpoint_requested = Signal(str, int) + """ + This signal is emitted to send a request to clear a single breakpoint. + + Parameters + ---------- + filename: str + The path to filename containing the breakpoint. + line_number: int + The line number of the breakpoint. + """ + + sig_edit_goto_requested = Signal(str, int, str) + """ + Send a request to open a file in the editor at a given row and word. + + Parameters + ---------- + filename: str + The path to the filename containing the breakpoint. + line_number: int + The line number of the breakpoint. + word: str + Text `word` to select on given `line_number`. + """ + + sig_conditional_breakpoint_requested = Signal() + """ + Send a request to set/edit a condition on a single selected breakpoint. + """ + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Breakpoints") + + def get_description(self): + return _("Manage code breakpoints in a unified pane.") + + def get_icon(self): + return self.create_icon('breakpoints') + + def on_initialize(self): + widget = self.get_widget() + + widget.sig_clear_all_breakpoints_requested.connect( + self.sig_clear_all_breakpoints_requested) + widget.sig_clear_breakpoint_requested.connect( + self.sig_clear_breakpoint_requested) + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_conditional_breakpoint_requested.connect( + self.sig_conditional_breakpoint_requested) + + self.create_action( + BreakpointsActions.ListBreakpoints, + _("List breakpoints"), + triggered=lambda: self.switch_to_plugin(), + icon=self.get_icon(), + ) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + list_action = self.get_action(BreakpointsActions.ListBreakpoints) + + # TODO: change name of this signal on editor + editor.breakpoints_saved.connect(self.set_data) + widget.sig_clear_all_breakpoints_requested.connect( + editor.clear_all_breakpoints) + widget.sig_clear_breakpoint_requested.connect(editor.clear_breakpoint) + widget.sig_edit_goto_requested.connect(editor.load) + widget.sig_conditional_breakpoint_requested.connect( + editor.set_or_edit_conditional_breakpoint) + + # TODO: Fix location once the sections are defined + editor.pythonfile_dependent_actions += [list_action] + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + list_action = self.get_action(BreakpointsActions.ListBreakpoints) + mainmenu.add_item_to_application_menu( + list_action, menu_id=ApplicationMenus.Debug) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + list_action = self.get_action(BreakpointsActions.ListBreakpoints) + + editor.breakpoints_saved.disconnect(self.set_data) + widget.sig_clear_all_breakpoints_requested.disconnect( + editor.clear_all_breakpoints) + widget.sig_clear_breakpoint_requested.disconnect( + editor.clear_breakpoint) + widget.sig_edit_goto_requested.disconnect(editor.load) + widget.sig_conditional_breakpoint_requested.disconnect( + editor.set_or_edit_conditional_breakpoint) + + editor.pythonfile_dependent_actions.remove(list_action) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + BreakpointsActions.ListBreakpoints, menu_id=ApplicationMenus.Debug) + + # --- Private API + # ------------------------------------------------------------------------ + def _load_data(self): + """ + Load breakpoint data from configuration file. + """ + breakpoints_dict = self.get_conf( + 'breakpoints', + default={}, + section='run', + ) + for filename in list(breakpoints_dict.keys()): + if not osp.isfile(filename): + breakpoints_dict.pop(filename) + continue + # Make sure we don't have the same file under different names + new_filename = osp.normcase(filename) + if new_filename != filename: + bp = breakpoints_dict.pop(filename) + if new_filename in breakpoints_dict: + breakpoints_dict[new_filename].extend(bp) + else: + breakpoints_dict[new_filename] = bp + + return breakpoints_dict + + # --- Public API + # ------------------------------------------------------------------------ + def set_data(self, data=None): + """ + Set breakpoint data on widget. + + Parameters + ---------- + data: dict, optional + Breakpoint data to use. If None, data from the configuration + will be loaded. Default is None. + """ + if data is None: + data = self._load_data() + + self.get_widget().set_data(data) diff --git a/spyder/plugins/breakpoints/widgets/main_widget.py b/spyder/plugins/breakpoints/widgets/main_widget.py index 9126d037545..2596e5f5776 100644 --- a/spyder/plugins/breakpoints/widgets/main_widget.py +++ b/spyder/plugins/breakpoints/widgets/main_widget.py @@ -1,430 +1,430 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# based loosley on pylintgui.py by Pierre Raybaut -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Breakpoint widget. -""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import sys - -# Third party imports -from qtpy import PYQT5 -from qtpy.compat import to_qvariant -from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal -from qtpy.QtWidgets import QItemDelegate, QTableView, QVBoxLayout - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import (PluginMainWidgetMenus, - PluginMainWidget) -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.utils.sourcecode import disambiguate_fname - - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -COLUMN_COUNT = 4 -EXTRA_COLUMNS = 1 -COL_FILE, COL_LINE, COL_CONDITION, COL_BLANK, COL_FULL = list( - range(COLUMN_COUNT + EXTRA_COLUMNS)) -COLUMN_HEADERS = (_("File"), _("Line"), _("Condition"), ("")) - - -class BreakpointTableViewActions: - # Triggers - ClearAllBreakpoints = 'clear_all_breakpoints_action' - ClearBreakpoint = 'clear_breakpoint_action' - EditBreakpoint = 'edit_breakpoint_action' - - -# --- Widgets -# ---------------------------------------------------------------------------- -class BreakpointTableModel(QAbstractTableModel): - """ - Table model for breakpoints dictionary. - """ - - def __init__(self, parent, data): - super().__init__(parent) - - self._data = {} if data is None else data - self.breakpoints = None - - self.set_data(self._data) - - def set_data(self, data): - """ - Set model data. - - Parameters - ---------- - data: dict - Breakpoint data to use. - """ - self._data = data - self.breakpoints = [] - files = [] - # Generate list of filenames with active breakpoints - for key in data: - if data[key] and key not in files: - files.append(key) - - # Insert items - for key in files: - for item in data[key]: - # Store full file name in last position, which is not shown - self.breakpoints.append((disambiguate_fname(files, key), - item[0], item[1], "", key)) - self.reset() - - def rowCount(self, qindex=QModelIndex()): - """ - Array row number. - """ - return len(self.breakpoints) - - def columnCount(self, qindex=QModelIndex()): - """ - Array column count. - """ - return COLUMN_COUNT - - def sort(self, column, order=Qt.DescendingOrder): - """ - Overriding sort method. - """ - if column == COL_FILE: - self.breakpoints.sort(key=lambda breakp: int(breakp[COL_LINE])) - self.breakpoints.sort(key=lambda breakp: breakp[COL_FILE]) - elif column == COL_LINE: - pass - elif column == COL_CONDITION: - pass - elif column == COL_BLANK: - pass - - self.reset() - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """ - Overriding method headerData. - """ - if role != Qt.DisplayRole: - return to_qvariant() - - i_column = int(section) - if orientation == Qt.Horizontal: - return to_qvariant(COLUMN_HEADERS[i_column]) - else: - return to_qvariant() - - def get_value(self, index): - """ - Return current value. - """ - return self.breakpoints[index.row()][index.column()] - - def data(self, index, role=Qt.DisplayRole): - """ - Return data at table index. - """ - if not index.isValid(): - return to_qvariant() - - if role == Qt.DisplayRole: - value = self.get_value(index) - return to_qvariant(value) - elif role == Qt.TextAlignmentRole: - if index.column() == COL_LINE: - # Align line number right - return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) - else: - return to_qvariant(int(Qt.AlignLeft | Qt.AlignVCenter)) - elif role == Qt.ToolTipRole: - if index.column() == COL_FILE: - # Return full file name (in last position) - value = self.breakpoints[index.row()][COL_FULL] - return to_qvariant(value) - else: - return to_qvariant() - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class BreakpointDelegate(QItemDelegate): - - def __init__(self, parent=None): - super().__init__(parent) - - -class BreakpointTableView(QTableView, SpyderWidgetMixin): - """ - Table to display code breakpoints. - """ - - # Signals - sig_clear_all_breakpoints_requested = Signal() - sig_clear_breakpoint_requested = Signal(str, int) - sig_edit_goto_requested = Signal(str, int, str) - sig_conditional_breakpoint_requested = Signal() - - def __init__(self, parent, data): - if PYQT5: - super().__init__(parent, class_parent=parent) - else: - QTableView.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - # Widgets - self.model = BreakpointTableModel(self, data) - self.delegate = BreakpointDelegate(self) - - # Setup - self.setSortingEnabled(False) - self.setSelectionBehavior(self.SelectRows) - self.setSelectionMode(self.SingleSelection) - self.setModel(self.model) - self.setItemDelegate(self.delegate) - self.adjust_columns() - self.columnAt(0) - self.horizontalHeader().setStretchLastSection(True) - - # --- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - def setup(self): - clear_all_action = self.create_action( - BreakpointTableViewActions.ClearAllBreakpoints, - _("Clear breakpoints in all files"), - triggered=self.sig_clear_all_breakpoints_requested, - ) - clear_action = self.create_action( - BreakpointTableViewActions.ClearBreakpoint, - _("Clear selected breakpoint"), - triggered=self.clear_breakpoints, - ) - edit_action = self.create_action( - BreakpointTableViewActions.EditBreakpoint, - _("Edit selected breakpoint"), - triggered=self.edit_breakpoints, - ) - - self.popup_menu = self.create_menu(PluginMainWidgetMenus.Context) - for item in [clear_all_action, clear_action, edit_action]: - self.add_item_to_menu(item, menu=self.popup_menu) - - # --- Qt overrides - # ------------------------------------------------------------------------ - def contextMenuEvent(self, event): - """ - Override Qt method. - """ - c_row = self.indexAt(event.pos()).row() - enabled = bool(self.model.breakpoints) and c_row is not None - clear_action = self.get_action( - BreakpointTableViewActions.ClearBreakpoint) - edit_action = self.get_action( - BreakpointTableViewActions.EditBreakpoint) - clear_action.setEnabled(enabled) - edit_action.setEnabled(enabled) - - self.popup_menu.popup(event.globalPos()) - event.accept() - - def mouseDoubleClickEvent(self, event): - """ - Override Qt method. - """ - index_clicked = self.indexAt(event.pos()) - if self.model.breakpoints: - c_row = index_clicked.row() - filename = self.model.breakpoints[c_row][COL_FULL] - line_number_str = self.model.breakpoints[c_row][COL_LINE] - - self.sig_edit_goto_requested.emit( - filename, int(line_number_str), '') - - if index_clicked.column() == COL_CONDITION: - self.sig_conditional_breakpoint_requested.emit() - - # --- API - # ------------------------------------------------------------------------ - def set_data(self, data): - """ - Set the model breakpoint data dictionary. - - Parameters - ---------- - data: dict - Breakpoint data to use. - """ - self.model.set_data(data) - self.adjust_columns() - self.sortByColumn(COL_FILE, Qt.DescendingOrder) - - def adjust_columns(self): - """ - Resize three first columns to contents. - """ - for col in range(COLUMN_COUNT - 1): - self.resizeColumnToContents(col) - - def clear_breakpoints(self): - """ - Clear selected row breakpoint. - """ - rows = self.selectionModel().selectedRows() - if rows and self.model.breakpoints: - c_row = rows[0].row() - filename = self.model.breakpoints[c_row][COL_FULL] - lineno = int(self.model.breakpoints[c_row][COL_LINE]) - - self.sig_clear_breakpoint_requested.emit(filename, lineno) - - def edit_breakpoints(self): - """ - Edit selected row breakpoint condition. - """ - rows = self.selectionModel().selectedRows() - if rows and self.model.breakpoints: - c_row = rows[0].row() - filename = self.model.breakpoints[c_row][COL_FULL] - lineno = int(self.model.breakpoints[c_row][COL_LINE]) - - self.sig_edit_goto_requested.emit(filename, lineno, '') - self.sig_conditional_breakpoint_requested.emit() - - -class BreakpointWidget(PluginMainWidget): - """ - Breakpoints widget. - """ - - # --- Signals - # ------------------------------------------------------------------------ - sig_clear_all_breakpoints_requested = Signal() - """ - This signal is emitted to send a request to clear all assigned - breakpoints. - """ - - sig_clear_breakpoint_requested = Signal(str, int) - """ - This signal is emitted to send a request to clear a single breakpoint. - - Parameters - ---------- - filename: str - The path to filename cotaining the breakpoint. - line_number: int - The line number of the breakpoint. - """ - - sig_edit_goto_requested = Signal(str, int, str) - """ - Send a request to open a file in the editor at a given row and word. - - Parameters - ---------- - filename: str - The path to the filename containing the breakpoint. - line_number: int - The line number of the breakpoint. - word: str - Text `word` to select on given `line_number`. - """ - - sig_conditional_breakpoint_requested = Signal() - """ - Send a request to set/edit a condition on a single selected breakpoint. - """ - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent=parent) - - # Widgets - self.breakpoints_table = BreakpointTableView(self, {}) - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.breakpoints_table) - self.setLayout(layout) - - # Signals - bpt = self.breakpoints_table - bpt.sig_clear_all_breakpoints_requested.connect( - self.sig_clear_all_breakpoints_requested) - bpt.sig_clear_breakpoint_requested.connect( - self.sig_clear_breakpoint_requested) - bpt.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - bpt.sig_conditional_breakpoint_requested.connect( - self.sig_conditional_breakpoint_requested) - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Breakpoints') - - def get_focus_widget(self): - return self.breakpoints_table - - def setup(self): - self.breakpoints_table.setup() - - def update_actions(self): - rows = self.breakpoints_table.selectionModel().selectedRows() - c_row = rows[0] if rows else None - - enabled = (bool(self.breakpoints_table.model.breakpoints) - and c_row is not None) - clear_action = self.get_action( - BreakpointTableViewActions.ClearBreakpoint) - edit_action = self.get_action( - BreakpointTableViewActions.EditBreakpoint) - clear_action.setEnabled(enabled) - edit_action.setEnabled(enabled) - - # --- Public API - # ------------------------------------------------------------------------ - def set_data(self, data): - """ - Set breakpoint data on widget. - - Parameters - ---------- - data: dict - Breakpoint data to use. - """ - self.breakpoints_table.set_data(data) - - -# ============================================================================= -# Tests -# ============================================================================= -def test(): - """Run breakpoint widget test.""" - from spyder.utils.qthelpers import qapplication - - app = qapplication() - widget = BreakpointWidget() - widget.show() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# based loosley on pylintgui.py by Pierre Raybaut +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Breakpoint widget. +""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import sys + +# Third party imports +from qtpy import PYQT5 +from qtpy.compat import to_qvariant +from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal +from qtpy.QtWidgets import QItemDelegate, QTableView, QVBoxLayout + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import (PluginMainWidgetMenus, + PluginMainWidget) +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.utils.sourcecode import disambiguate_fname + + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +COLUMN_COUNT = 4 +EXTRA_COLUMNS = 1 +COL_FILE, COL_LINE, COL_CONDITION, COL_BLANK, COL_FULL = list( + range(COLUMN_COUNT + EXTRA_COLUMNS)) +COLUMN_HEADERS = (_("File"), _("Line"), _("Condition"), ("")) + + +class BreakpointTableViewActions: + # Triggers + ClearAllBreakpoints = 'clear_all_breakpoints_action' + ClearBreakpoint = 'clear_breakpoint_action' + EditBreakpoint = 'edit_breakpoint_action' + + +# --- Widgets +# ---------------------------------------------------------------------------- +class BreakpointTableModel(QAbstractTableModel): + """ + Table model for breakpoints dictionary. + """ + + def __init__(self, parent, data): + super().__init__(parent) + + self._data = {} if data is None else data + self.breakpoints = None + + self.set_data(self._data) + + def set_data(self, data): + """ + Set model data. + + Parameters + ---------- + data: dict + Breakpoint data to use. + """ + self._data = data + self.breakpoints = [] + files = [] + # Generate list of filenames with active breakpoints + for key in data: + if data[key] and key not in files: + files.append(key) + + # Insert items + for key in files: + for item in data[key]: + # Store full file name in last position, which is not shown + self.breakpoints.append((disambiguate_fname(files, key), + item[0], item[1], "", key)) + self.reset() + + def rowCount(self, qindex=QModelIndex()): + """ + Array row number. + """ + return len(self.breakpoints) + + def columnCount(self, qindex=QModelIndex()): + """ + Array column count. + """ + return COLUMN_COUNT + + def sort(self, column, order=Qt.DescendingOrder): + """ + Overriding sort method. + """ + if column == COL_FILE: + self.breakpoints.sort(key=lambda breakp: int(breakp[COL_LINE])) + self.breakpoints.sort(key=lambda breakp: breakp[COL_FILE]) + elif column == COL_LINE: + pass + elif column == COL_CONDITION: + pass + elif column == COL_BLANK: + pass + + self.reset() + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """ + Overriding method headerData. + """ + if role != Qt.DisplayRole: + return to_qvariant() + + i_column = int(section) + if orientation == Qt.Horizontal: + return to_qvariant(COLUMN_HEADERS[i_column]) + else: + return to_qvariant() + + def get_value(self, index): + """ + Return current value. + """ + return self.breakpoints[index.row()][index.column()] + + def data(self, index, role=Qt.DisplayRole): + """ + Return data at table index. + """ + if not index.isValid(): + return to_qvariant() + + if role == Qt.DisplayRole: + value = self.get_value(index) + return to_qvariant(value) + elif role == Qt.TextAlignmentRole: + if index.column() == COL_LINE: + # Align line number right + return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) + else: + return to_qvariant(int(Qt.AlignLeft | Qt.AlignVCenter)) + elif role == Qt.ToolTipRole: + if index.column() == COL_FILE: + # Return full file name (in last position) + value = self.breakpoints[index.row()][COL_FULL] + return to_qvariant(value) + else: + return to_qvariant() + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class BreakpointDelegate(QItemDelegate): + + def __init__(self, parent=None): + super().__init__(parent) + + +class BreakpointTableView(QTableView, SpyderWidgetMixin): + """ + Table to display code breakpoints. + """ + + # Signals + sig_clear_all_breakpoints_requested = Signal() + sig_clear_breakpoint_requested = Signal(str, int) + sig_edit_goto_requested = Signal(str, int, str) + sig_conditional_breakpoint_requested = Signal() + + def __init__(self, parent, data): + if PYQT5: + super().__init__(parent, class_parent=parent) + else: + QTableView.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + # Widgets + self.model = BreakpointTableModel(self, data) + self.delegate = BreakpointDelegate(self) + + # Setup + self.setSortingEnabled(False) + self.setSelectionBehavior(self.SelectRows) + self.setSelectionMode(self.SingleSelection) + self.setModel(self.model) + self.setItemDelegate(self.delegate) + self.adjust_columns() + self.columnAt(0) + self.horizontalHeader().setStretchLastSection(True) + + # --- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + def setup(self): + clear_all_action = self.create_action( + BreakpointTableViewActions.ClearAllBreakpoints, + _("Clear breakpoints in all files"), + triggered=self.sig_clear_all_breakpoints_requested, + ) + clear_action = self.create_action( + BreakpointTableViewActions.ClearBreakpoint, + _("Clear selected breakpoint"), + triggered=self.clear_breakpoints, + ) + edit_action = self.create_action( + BreakpointTableViewActions.EditBreakpoint, + _("Edit selected breakpoint"), + triggered=self.edit_breakpoints, + ) + + self.popup_menu = self.create_menu(PluginMainWidgetMenus.Context) + for item in [clear_all_action, clear_action, edit_action]: + self.add_item_to_menu(item, menu=self.popup_menu) + + # --- Qt overrides + # ------------------------------------------------------------------------ + def contextMenuEvent(self, event): + """ + Override Qt method. + """ + c_row = self.indexAt(event.pos()).row() + enabled = bool(self.model.breakpoints) and c_row is not None + clear_action = self.get_action( + BreakpointTableViewActions.ClearBreakpoint) + edit_action = self.get_action( + BreakpointTableViewActions.EditBreakpoint) + clear_action.setEnabled(enabled) + edit_action.setEnabled(enabled) + + self.popup_menu.popup(event.globalPos()) + event.accept() + + def mouseDoubleClickEvent(self, event): + """ + Override Qt method. + """ + index_clicked = self.indexAt(event.pos()) + if self.model.breakpoints: + c_row = index_clicked.row() + filename = self.model.breakpoints[c_row][COL_FULL] + line_number_str = self.model.breakpoints[c_row][COL_LINE] + + self.sig_edit_goto_requested.emit( + filename, int(line_number_str), '') + + if index_clicked.column() == COL_CONDITION: + self.sig_conditional_breakpoint_requested.emit() + + # --- API + # ------------------------------------------------------------------------ + def set_data(self, data): + """ + Set the model breakpoint data dictionary. + + Parameters + ---------- + data: dict + Breakpoint data to use. + """ + self.model.set_data(data) + self.adjust_columns() + self.sortByColumn(COL_FILE, Qt.DescendingOrder) + + def adjust_columns(self): + """ + Resize three first columns to contents. + """ + for col in range(COLUMN_COUNT - 1): + self.resizeColumnToContents(col) + + def clear_breakpoints(self): + """ + Clear selected row breakpoint. + """ + rows = self.selectionModel().selectedRows() + if rows and self.model.breakpoints: + c_row = rows[0].row() + filename = self.model.breakpoints[c_row][COL_FULL] + lineno = int(self.model.breakpoints[c_row][COL_LINE]) + + self.sig_clear_breakpoint_requested.emit(filename, lineno) + + def edit_breakpoints(self): + """ + Edit selected row breakpoint condition. + """ + rows = self.selectionModel().selectedRows() + if rows and self.model.breakpoints: + c_row = rows[0].row() + filename = self.model.breakpoints[c_row][COL_FULL] + lineno = int(self.model.breakpoints[c_row][COL_LINE]) + + self.sig_edit_goto_requested.emit(filename, lineno, '') + self.sig_conditional_breakpoint_requested.emit() + + +class BreakpointWidget(PluginMainWidget): + """ + Breakpoints widget. + """ + + # --- Signals + # ------------------------------------------------------------------------ + sig_clear_all_breakpoints_requested = Signal() + """ + This signal is emitted to send a request to clear all assigned + breakpoints. + """ + + sig_clear_breakpoint_requested = Signal(str, int) + """ + This signal is emitted to send a request to clear a single breakpoint. + + Parameters + ---------- + filename: str + The path to filename cotaining the breakpoint. + line_number: int + The line number of the breakpoint. + """ + + sig_edit_goto_requested = Signal(str, int, str) + """ + Send a request to open a file in the editor at a given row and word. + + Parameters + ---------- + filename: str + The path to the filename containing the breakpoint. + line_number: int + The line number of the breakpoint. + word: str + Text `word` to select on given `line_number`. + """ + + sig_conditional_breakpoint_requested = Signal() + """ + Send a request to set/edit a condition on a single selected breakpoint. + """ + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent=parent) + + # Widgets + self.breakpoints_table = BreakpointTableView(self, {}) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.breakpoints_table) + self.setLayout(layout) + + # Signals + bpt = self.breakpoints_table + bpt.sig_clear_all_breakpoints_requested.connect( + self.sig_clear_all_breakpoints_requested) + bpt.sig_clear_breakpoint_requested.connect( + self.sig_clear_breakpoint_requested) + bpt.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + bpt.sig_conditional_breakpoint_requested.connect( + self.sig_conditional_breakpoint_requested) + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Breakpoints') + + def get_focus_widget(self): + return self.breakpoints_table + + def setup(self): + self.breakpoints_table.setup() + + def update_actions(self): + rows = self.breakpoints_table.selectionModel().selectedRows() + c_row = rows[0] if rows else None + + enabled = (bool(self.breakpoints_table.model.breakpoints) + and c_row is not None) + clear_action = self.get_action( + BreakpointTableViewActions.ClearBreakpoint) + edit_action = self.get_action( + BreakpointTableViewActions.EditBreakpoint) + clear_action.setEnabled(enabled) + edit_action.setEnabled(enabled) + + # --- Public API + # ------------------------------------------------------------------------ + def set_data(self, data): + """ + Set breakpoint data on widget. + + Parameters + ---------- + data: dict + Breakpoint data to use. + """ + self.breakpoints_table.set_data(data) + + +# ============================================================================= +# Tests +# ============================================================================= +def test(): + """Run breakpoint widget test.""" + from spyder.utils.qthelpers import qapplication + + app = qapplication() + widget = BreakpointWidget() + widget.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/console/api.py b/spyder/plugins/console/api.py index a5ee23322e3..c6e0c42ef28 100644 --- a/spyder/plugins/console/api.py +++ b/spyder/plugins/console/api.py @@ -1,18 +1,18 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Console Plugin API. -""" - -# Local imports -from spyder.plugins.console.widgets.main_widget import ( - ConsoleWidgetActions, ConsoleWidgetInternalSettingsSubMenuSections, - ConsoleWidgetMenus, ConsoleWidgetOptionsMenuSections) - - -class ConsoleActions: - SpyderReportAction = "spyder_report_action" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Console Plugin API. +""" + +# Local imports +from spyder.plugins.console.widgets.main_widget import ( + ConsoleWidgetActions, ConsoleWidgetInternalSettingsSubMenuSections, + ConsoleWidgetMenus, ConsoleWidgetOptionsMenuSections) + + +class ConsoleActions: + SpyderReportAction = "spyder_report_action" diff --git a/spyder/plugins/console/plugin.py b/spyder/plugins/console/plugin.py index 066f6687657..baea3227a03 100644 --- a/spyder/plugins/console/plugin.py +++ b/spyder/plugins/console/plugin.py @@ -1,269 +1,269 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Internal Console Plugin. -""" - -# Standard library imports -import logging - -# Third party imports -from qtpy.QtCore import Signal, Slot -from qtpy.QtGui import QIcon - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import DEV -from spyder.plugins.console.widgets.main_widget import ( - ConsoleWidget, ConsoleWidgetActions) -from spyder.plugins.mainmenu.api import ApplicationMenus, FileMenuSections - -# Localization -_ = get_translation('spyder') - -# Logging -logger = logging.getLogger(__name__) - - -class Console(SpyderDockablePlugin): - """ - Console widget - """ - NAME = 'internal_console' - WIDGET_CLASS = ConsoleWidget - OPTIONAL = [Plugins.MainMenu] - CONF_SECTION = NAME - CONF_FILE = False - TABIFY = [Plugins.IPythonConsole, Plugins.History] - CAN_BE_DISABLED = False - RAISE_AND_FOCUS = True - - # --- Signals - # ------------------------------------------------------------------------ - sig_focus_changed = Signal() # TODO: I think this is not being used now? - - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - sig_refreshed = Signal() - """This signal is emitted when the interpreter buffer is flushed.""" - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Example `{'name': str, 'ignore_unknown': bool}`. - """ - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Internal console') - - def get_icon(self): - return QIcon() - - def get_description(self): - return _('Internal console running Spyder.') - - def on_initialize(self): - widget = self.get_widget() - - # Signals - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_focus_changed.connect(self.sig_focus_changed) - widget.sig_quit_requested.connect(self.sig_quit_requested) - widget.sig_refreshed.connect(self.sig_refreshed) - widget.sig_help_requested.connect(self.sig_help_requested) - - # Crash handling - previous_crash = self.get_conf( - 'previous_crash', - default='', - section='main', - ) - - if previous_crash: - error_data = dict( - text=previous_crash, - is_traceback=True, - title="Segmentation fault crash", - label=_("

Spyder crashed during last session

"), - steps=_("Please provide any additional information you " - "might have about the crash."), - ) - widget.handle_exception(error_data) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - widget = self.get_widget() - mainmenu = self.get_plugin(Plugins.MainMenu) - - # Actions - mainmenu.add_item_to_application_menu( - widget.quit_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Restart) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - ConsoleWidgetActions.Quit, - menu_id=ApplicationMenus.File) - - def update_font(self): - font = self.get_font() - self.get_widget().set_font(font) - - def on_close(self, cancelable=False): - self.get_widget().dialog_manager.close_all() - return True - - def on_mainwindow_visible(self): - self.set_exit_function(self.main.closing) - - # Hide this plugin when not in development so that people don't - # use it instead of the IPython console - if DEV is None: - self.toggle_view_action.setChecked(False) - self.dockwidget.hide() - - # --- API - # ------------------------------------------------------------------------ - @Slot() - def report_issue(self): - """Report an issue with the SpyderErrorDialog.""" - self.get_widget().report_issue() - - @property - def error_dialog(self): - """ - Error dialog attribute accesor. - """ - return self.get_widget().error_dlg - - def close_error_dialog(self): - """ - Close the error dialog if visible. - """ - self.get_widget().close_error_dlg() - - def exit_interpreter(self): - """ - Exit the internal console interpreter. - - This is equivalent to requesting the main application to quit. - """ - self.get_widget().exit_interpreter() - - def execute_lines(self, lines): - """ - Execute the given `lines` of code in the internal console. - """ - self.get_widget().execute_lines(lines) - - def get_sys_path(self): - """ - Return the system path of the internal console. - """ - return self.get_widget().get_sys_path() - - @Slot(dict) - def handle_exception(self, error_data, sender=None): - """ - Handle any exception that occurs during Spyder usage. - - Parameters - ---------- - error_data: dict - The dictionary containing error data. The expected keys are: - >>> error_data= { - "text": str, - "is_traceback": bool, - "repo": str, - "title": str, - "label": str, - "steps": str, - } - - Notes - ----- - The `is_traceback` key indicates if `text` contains plain text or a - Python error traceback. - - The `title` and `repo` keys indicate how the error data should - customize the report dialog and Github error submission. - - The `label` and `steps` keys allow customizing the content of the - error dialog. - """ - if sender is None: - sender = self.sender() - self.get_widget().handle_exception( - error_data, - sender=sender - ) - - def quit(self): - """ - Send the quit request to the main application. - """ - self.sig_quit_requested.emit() - - def restore_stds(self): - """ - Restore stdout and stderr when using open file dialogs. - """ - self.get_widget().restore_stds() - - def redirect_stds(self): - """ - Redirect stdout and stderr when using open file dialogs. - """ - self.get_widget().redirect_stds() - - def set_exit_function(self, func): - """ - Set the callback function to execute when the `exit_interpreter` is - called. - """ - self.get_widget().set_exit_function(func) - - def start_interpreter(self, namespace): - """ - Start the internal console interpreter. - - Stdin and stdout are now redirected through the internal console. - """ - widget = self.get_widget() - widget.start_interpreter(namespace) - - def set_namespace_item(self, name, value): - """ - Add an object to the namespace dictionary of the internal console. - """ - self.get_widget().set_namespace_item(name, value) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Internal Console Plugin. +""" + +# Standard library imports +import logging + +# Third party imports +from qtpy.QtCore import Signal, Slot +from qtpy.QtGui import QIcon + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import DEV +from spyder.plugins.console.widgets.main_widget import ( + ConsoleWidget, ConsoleWidgetActions) +from spyder.plugins.mainmenu.api import ApplicationMenus, FileMenuSections + +# Localization +_ = get_translation('spyder') + +# Logging +logger = logging.getLogger(__name__) + + +class Console(SpyderDockablePlugin): + """ + Console widget + """ + NAME = 'internal_console' + WIDGET_CLASS = ConsoleWidget + OPTIONAL = [Plugins.MainMenu] + CONF_SECTION = NAME + CONF_FILE = False + TABIFY = [Plugins.IPythonConsole, Plugins.History] + CAN_BE_DISABLED = False + RAISE_AND_FOCUS = True + + # --- Signals + # ------------------------------------------------------------------------ + sig_focus_changed = Signal() # TODO: I think this is not being used now? + + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + sig_refreshed = Signal() + """This signal is emitted when the interpreter buffer is flushed.""" + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Example `{'name': str, 'ignore_unknown': bool}`. + """ + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Internal console') + + def get_icon(self): + return QIcon() + + def get_description(self): + return _('Internal console running Spyder.') + + def on_initialize(self): + widget = self.get_widget() + + # Signals + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_focus_changed.connect(self.sig_focus_changed) + widget.sig_quit_requested.connect(self.sig_quit_requested) + widget.sig_refreshed.connect(self.sig_refreshed) + widget.sig_help_requested.connect(self.sig_help_requested) + + # Crash handling + previous_crash = self.get_conf( + 'previous_crash', + default='', + section='main', + ) + + if previous_crash: + error_data = dict( + text=previous_crash, + is_traceback=True, + title="Segmentation fault crash", + label=_("

Spyder crashed during last session

"), + steps=_("Please provide any additional information you " + "might have about the crash."), + ) + widget.handle_exception(error_data) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + widget = self.get_widget() + mainmenu = self.get_plugin(Plugins.MainMenu) + + # Actions + mainmenu.add_item_to_application_menu( + widget.quit_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Restart) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + ConsoleWidgetActions.Quit, + menu_id=ApplicationMenus.File) + + def update_font(self): + font = self.get_font() + self.get_widget().set_font(font) + + def on_close(self, cancelable=False): + self.get_widget().dialog_manager.close_all() + return True + + def on_mainwindow_visible(self): + self.set_exit_function(self.main.closing) + + # Hide this plugin when not in development so that people don't + # use it instead of the IPython console + if DEV is None: + self.toggle_view_action.setChecked(False) + self.dockwidget.hide() + + # --- API + # ------------------------------------------------------------------------ + @Slot() + def report_issue(self): + """Report an issue with the SpyderErrorDialog.""" + self.get_widget().report_issue() + + @property + def error_dialog(self): + """ + Error dialog attribute accesor. + """ + return self.get_widget().error_dlg + + def close_error_dialog(self): + """ + Close the error dialog if visible. + """ + self.get_widget().close_error_dlg() + + def exit_interpreter(self): + """ + Exit the internal console interpreter. + + This is equivalent to requesting the main application to quit. + """ + self.get_widget().exit_interpreter() + + def execute_lines(self, lines): + """ + Execute the given `lines` of code in the internal console. + """ + self.get_widget().execute_lines(lines) + + def get_sys_path(self): + """ + Return the system path of the internal console. + """ + return self.get_widget().get_sys_path() + + @Slot(dict) + def handle_exception(self, error_data, sender=None): + """ + Handle any exception that occurs during Spyder usage. + + Parameters + ---------- + error_data: dict + The dictionary containing error data. The expected keys are: + >>> error_data= { + "text": str, + "is_traceback": bool, + "repo": str, + "title": str, + "label": str, + "steps": str, + } + + Notes + ----- + The `is_traceback` key indicates if `text` contains plain text or a + Python error traceback. + + The `title` and `repo` keys indicate how the error data should + customize the report dialog and Github error submission. + + The `label` and `steps` keys allow customizing the content of the + error dialog. + """ + if sender is None: + sender = self.sender() + self.get_widget().handle_exception( + error_data, + sender=sender + ) + + def quit(self): + """ + Send the quit request to the main application. + """ + self.sig_quit_requested.emit() + + def restore_stds(self): + """ + Restore stdout and stderr when using open file dialogs. + """ + self.get_widget().restore_stds() + + def redirect_stds(self): + """ + Redirect stdout and stderr when using open file dialogs. + """ + self.get_widget().redirect_stds() + + def set_exit_function(self, func): + """ + Set the callback function to execute when the `exit_interpreter` is + called. + """ + self.get_widget().set_exit_function(func) + + def start_interpreter(self, namespace): + """ + Start the internal console interpreter. + + Stdin and stdout are now redirected through the internal console. + """ + widget = self.get_widget() + widget.start_interpreter(namespace) + + def set_namespace_item(self, name, value): + """ + Add an object to the namespace dictionary of the internal console. + """ + self.get_widget().set_namespace_item(name, value) diff --git a/spyder/plugins/console/utils/ansihandler.py b/spyder/plugins/console/utils/ansihandler.py index f343669f4a8..093ef63b457 100644 --- a/spyder/plugins/console/utils/ansihandler.py +++ b/spyder/plugins/console/utils/ansihandler.py @@ -1,115 +1,115 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Terminal emulation tools""" - -import os - -class ANSIEscapeCodeHandler(object): - """ANSI Escape sequences handler""" - if os.name == 'nt': - # Windows terminal colors: - ANSI_COLORS = ( # Normal, Bright/Light - ('#000000', '#808080'), # 0: black - ('#800000', '#ff0000'), # 1: red - ('#008000', '#00ff00'), # 2: green - ('#808000', '#ffff00'), # 3: yellow - ('#000080', '#0000ff'), # 4: blue - ('#800080', '#ff00ff'), # 5: magenta - ('#008080', '#00ffff'), # 6: cyan - ('#c0c0c0', '#ffffff'), # 7: white - ) - elif os.name == 'mac': - # Terminal.app colors: - ANSI_COLORS = ( # Normal, Bright/Light - ('#000000', '#818383'), # 0: black - ('#C23621', '#FC391F'), # 1: red - ('#25BC24', '#25BC24'), # 2: green - ('#ADAD27', '#EAEC23'), # 3: yellow - ('#492EE1', '#5833FF'), # 4: blue - ('#D338D3', '#F935F8'), # 5: magenta - ('#33BBC8', '#14F0F0'), # 6: cyan - ('#CBCCCD', '#E9EBEB'), # 7: white - ) - else: - # xterm colors: - ANSI_COLORS = ( # Normal, Bright/Light - ('#000000', '#7F7F7F'), # 0: black - ('#CD0000', '#ff0000'), # 1: red - ('#00CD00', '#00ff00'), # 2: green - ('#CDCD00', '#ffff00'), # 3: yellow - ('#0000EE', '#5C5CFF'), # 4: blue - ('#CD00CD', '#ff00ff'), # 5: magenta - ('#00CDCD', '#00ffff'), # 6: cyan - ('#E5E5E5', '#ffffff'), # 7: white - ) - def __init__(self): - self.intensity = 0 - self.italic = None - self.bold = None - self.underline = None - self.foreground_color = None - self.background_color = None - self.default_foreground_color = 30 - self.default_background_color = 47 - - def set_code(self, code): - assert isinstance(code, int) - if code == 0: - # Reset all settings - self.reset() - elif code == 1: - # Text color intensity - self.intensity = 1 - # The following line is commented because most terminals won't - # change the font weight, against ANSI standard recommendation: -# self.bold = True - elif code == 3: - # Italic on - self.italic = True - elif code == 4: - # Underline simple - self.underline = True - elif code == 22: - # Normal text color intensity - self.intensity = 0 - self.bold = False - elif code == 23: - # No italic - self.italic = False - elif code == 24: - # No underline - self.underline = False - elif code >= 30 and code <= 37: - # Text color - self.foreground_color = code - elif code == 39: - # Default text color - self.foreground_color = self.default_foreground_color - elif code >= 40 and code <= 47: - # Background color - self.background_color = code - elif code == 49: - # Default background color - self.background_color = self.default_background_color - self.set_style() - - def set_style(self): - """ - Set font style with the following attributes: - 'foreground_color', 'background_color', 'italic', - 'bold' and 'underline' - """ - raise NotImplementedError - - def reset(self): - self.current_format = None - self.intensity = 0 - self.italic = False - self.bold = False - self.underline = False - self.foreground_color = None - self.background_color = None +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Terminal emulation tools""" + +import os + +class ANSIEscapeCodeHandler(object): + """ANSI Escape sequences handler""" + if os.name == 'nt': + # Windows terminal colors: + ANSI_COLORS = ( # Normal, Bright/Light + ('#000000', '#808080'), # 0: black + ('#800000', '#ff0000'), # 1: red + ('#008000', '#00ff00'), # 2: green + ('#808000', '#ffff00'), # 3: yellow + ('#000080', '#0000ff'), # 4: blue + ('#800080', '#ff00ff'), # 5: magenta + ('#008080', '#00ffff'), # 6: cyan + ('#c0c0c0', '#ffffff'), # 7: white + ) + elif os.name == 'mac': + # Terminal.app colors: + ANSI_COLORS = ( # Normal, Bright/Light + ('#000000', '#818383'), # 0: black + ('#C23621', '#FC391F'), # 1: red + ('#25BC24', '#25BC24'), # 2: green + ('#ADAD27', '#EAEC23'), # 3: yellow + ('#492EE1', '#5833FF'), # 4: blue + ('#D338D3', '#F935F8'), # 5: magenta + ('#33BBC8', '#14F0F0'), # 6: cyan + ('#CBCCCD', '#E9EBEB'), # 7: white + ) + else: + # xterm colors: + ANSI_COLORS = ( # Normal, Bright/Light + ('#000000', '#7F7F7F'), # 0: black + ('#CD0000', '#ff0000'), # 1: red + ('#00CD00', '#00ff00'), # 2: green + ('#CDCD00', '#ffff00'), # 3: yellow + ('#0000EE', '#5C5CFF'), # 4: blue + ('#CD00CD', '#ff00ff'), # 5: magenta + ('#00CDCD', '#00ffff'), # 6: cyan + ('#E5E5E5', '#ffffff'), # 7: white + ) + def __init__(self): + self.intensity = 0 + self.italic = None + self.bold = None + self.underline = None + self.foreground_color = None + self.background_color = None + self.default_foreground_color = 30 + self.default_background_color = 47 + + def set_code(self, code): + assert isinstance(code, int) + if code == 0: + # Reset all settings + self.reset() + elif code == 1: + # Text color intensity + self.intensity = 1 + # The following line is commented because most terminals won't + # change the font weight, against ANSI standard recommendation: +# self.bold = True + elif code == 3: + # Italic on + self.italic = True + elif code == 4: + # Underline simple + self.underline = True + elif code == 22: + # Normal text color intensity + self.intensity = 0 + self.bold = False + elif code == 23: + # No italic + self.italic = False + elif code == 24: + # No underline + self.underline = False + elif code >= 30 and code <= 37: + # Text color + self.foreground_color = code + elif code == 39: + # Default text color + self.foreground_color = self.default_foreground_color + elif code >= 40 and code <= 47: + # Background color + self.background_color = code + elif code == 49: + # Default background color + self.background_color = self.default_background_color + self.set_style() + + def set_style(self): + """ + Set font style with the following attributes: + 'foreground_color', 'background_color', 'italic', + 'bold' and 'underline' + """ + raise NotImplementedError + + def reset(self): + self.current_format = None + self.intensity = 0 + self.italic = False + self.bold = False + self.underline = False + self.foreground_color = None + self.background_color = None diff --git a/spyder/plugins/console/utils/interpreter.py b/spyder/plugins/console/utils/interpreter.py index 9e063ec93e6..84ed99a34d8 100644 --- a/spyder/plugins/console/utils/interpreter.py +++ b/spyder/plugins/console/utils/interpreter.py @@ -1,335 +1,335 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shell Interpreter""" - -from __future__ import print_function - -import sys -import atexit -import threading -import ctypes -import os -import re -import os.path as osp -import pydoc -from code import InteractiveConsole - -from spyder_kernels.utils.dochelpers import isdefined - -# Local imports: -from spyder.utils import encoding, programs -from spyder.py3compat import is_text_string -from spyder.utils.misc import remove_backslashes, getcwd_or_home - -# Force Python to search modules in the current directory first: -sys.path.insert(0, '') - - -def guess_filename(filename): - """Guess filename""" - if osp.isfile(filename): - return filename - if not filename.endswith('.py'): - filename += '.py' - for path in [getcwd_or_home()] + sys.path: - fname = osp.join(path, filename) - if osp.isfile(fname): - return fname - elif osp.isfile(fname+'.py'): - return fname+'.py' - elif osp.isfile(fname+'.pyw'): - return fname+'.pyw' - return filename - -class Interpreter(InteractiveConsole, threading.Thread): - """Interpreter, executed in a separate thread""" - p1 = ">>> " - p2 = "... " - def __init__(self, namespace=None, exitfunc=None, - Output=None, WidgetProxy=None, debug=False): - """ - namespace: locals send to InteractiveConsole object - commands: list of commands executed at startup - """ - InteractiveConsole.__init__(self, namespace) - threading.Thread.__init__(self) - - self._id = None - - self.exit_flag = False - self.debug = debug - - # Execution Status - self.more = False - - if exitfunc is not None: - atexit.register(exitfunc) - - self.namespace = self.locals - self.namespace['__name__'] = '__main__' - self.namespace['execfile'] = self.execfile - self.namespace['runfile'] = self.runfile - self.namespace['raw_input'] = self.raw_input_replacement - self.namespace['help'] = self.help_replacement - - # Capture all interactive input/output - self.initial_stdout = sys.stdout - self.initial_stderr = sys.stderr - self.initial_stdin = sys.stdin - - # Create communication pipes - pr, pw = os.pipe() - self.stdin_read = os.fdopen(pr, "r") - self.stdin_write = os.fdopen(pw, "wb", 0) - self.stdout_write = Output() - self.stderr_write = Output() - - self.input_condition = threading.Condition() - self.widget_proxy = WidgetProxy(self.input_condition) - - self.redirect_stds() - - - #------ Standard input/output - def redirect_stds(self): - """Redirects stds""" - if not self.debug: - sys.stdout = self.stdout_write - sys.stderr = self.stderr_write - sys.stdin = self.stdin_read - - def restore_stds(self): - """Restore stds""" - if not self.debug: - sys.stdout = self.initial_stdout - sys.stderr = self.initial_stderr - sys.stdin = self.initial_stdin - - def raw_input_replacement(self, prompt=''): - """For raw_input builtin function emulation""" - self.widget_proxy.wait_input(prompt) - self.input_condition.acquire() - while not self.widget_proxy.data_available(): - self.input_condition.wait() - inp = self.widget_proxy.input_data - self.input_condition.release() - return inp - - def help_replacement(self, text=None, interactive=False): - """For help builtin function emulation""" - if text is not None and not interactive: - return pydoc.help(text) - elif text is None: - pyver = "%d.%d" % (sys.version_info[0], sys.version_info[1]) - self.write(""" -Welcome to Python %s! This is the online help utility. - -If this is your first time using Python, you should definitely check out -the tutorial on the Internet at https://www.python.org/about/gettingstarted/ - -Enter the name of any module, keyword, or topic to get help on writing -Python programs and using Python modules. To quit this help utility and -return to the interpreter, just type "quit". - -To get a list of available modules, keywords, or topics, type "modules", -"keywords", or "topics". Each module also comes with a one-line summary -of what it does; to list the modules whose summaries contain a given word -such as "spam", type "modules spam". -""" % pyver) - else: - text = text.strip() - try: - eval("pydoc.help(%s)" % text) - except (NameError, SyntaxError): - print("no Python documentation found for '%r'" % text) # spyder: test-skip - self.write(os.linesep) - self.widget_proxy.new_prompt("help> ") - inp = self.raw_input_replacement() - if inp.strip(): - self.help_replacement(inp, interactive=True) - else: - self.write(""" -You are now leaving help and returning to the Python interpreter. -If you want to ask for help on a particular object directly from the -interpreter, you can type "help(object)". Executing "help('string')" -has the same effect as typing a particular string at the help> prompt. -""") - - def run_command(self, cmd, new_prompt=True): - """Run command in interpreter""" - if cmd == 'exit()': - self.exit_flag = True - self.write('\n') - return - # -- Special commands type I - # (transformed into commands executed in the interpreter) - # ? command - special_pattern = r"^%s (?:r\')?(?:u\')?\"?\'?([a-zA-Z0-9_\.]+)" - run_match = re.match(special_pattern % 'run', cmd) - help_match = re.match(r'^([a-zA-Z0-9_\.]+)\?$', cmd) - cd_match = re.match(r"^\!cd \"?\'?([a-zA-Z0-9_ \.]+)", cmd) - if help_match: - cmd = 'help(%s)' % help_match.group(1) - # run command - elif run_match: - filename = guess_filename(run_match.groups()[0]) - cmd = "runfile('%s', args=None)" % remove_backslashes(filename) - # !cd system command - elif cd_match: - cmd = 'import os; os.chdir(r"%s")' % cd_match.groups()[0].strip() - # -- End of Special commands type I - - # -- Special commands type II - # (don't need code execution in interpreter) - xedit_match = re.match(special_pattern % 'xedit', cmd) - edit_match = re.match(special_pattern % 'edit', cmd) - clear_match = re.match(r"^clear ([a-zA-Z0-9_, ]+)", cmd) - # (external) edit command - if xedit_match: - filename = guess_filename(xedit_match.groups()[0]) - self.widget_proxy.edit(filename, external_editor=True) - # local edit command - elif edit_match: - filename = guess_filename(edit_match.groups()[0]) - if osp.isfile(filename): - self.widget_proxy.edit(filename) - else: - self.stderr_write.write( - "No such file or directory: %s\n" % filename) - # remove reference (equivalent to MATLAB's clear command) - elif clear_match: - varnames = clear_match.groups()[0].replace(' ', '').split(',') - for varname in varnames: - try: - self.namespace.pop(varname) - except KeyError: - pass - # Execute command - elif cmd.startswith('!'): - # System ! command - pipe = programs.run_shell_command(cmd[1:]) - txt_out = encoding.transcode( pipe.stdout.read().decode() ) - txt_err = encoding.transcode( pipe.stderr.read().decode().rstrip() ) - if txt_err: - self.stderr_write.write(txt_err) - if txt_out: - self.stdout_write.write(txt_out) - self.stdout_write.write('\n') - self.more = False - # -- End of Special commands type II - else: - # Command executed in the interpreter -# self.widget_proxy.set_readonly(True) - self.more = self.push(cmd) -# self.widget_proxy.set_readonly(False) - - if new_prompt: - self.widget_proxy.new_prompt(self.p2 if self.more else self.p1) - if not self.more: - self.resetbuffer() - - def run(self): - """Wait for input and run it""" - while not self.exit_flag: - self.run_line() - - def run_line(self): - line = self.stdin_read.readline() - if self.exit_flag: - return - # Remove last character which is always '\n': - self.run_command(line[:-1]) - - def get_thread_id(self): - """Return thread id""" - if self._id is None: - for thread_id, obj in list(threading._active.items()): - if obj is self: - self._id = thread_id - return self._id - - def raise_keyboard_interrupt(self): - if self.isAlive(): - ctypes.pythonapi.PyThreadState_SetAsyncExc(self.get_thread_id(), - ctypes.py_object(KeyboardInterrupt)) - return True - else: - return False - - - def closing(self): - """Actions to be done before restarting this interpreter""" - pass - - def execfile(self, filename): - """Exec filename""" - source = open(filename, 'r').read() - try: - try: - name = filename.encode('ascii') - except UnicodeEncodeError: - name = '' - code = compile(source, name, "exec") - except (OverflowError, SyntaxError): - InteractiveConsole.showsyntaxerror(self, filename) - else: - self.runcode(code) - - def runfile(self, filename, args=None): - """ - Run filename - args: command line arguments (string) - """ - if args is not None and not is_text_string(args): - raise TypeError("expected a character buffer object") - self.namespace['__file__'] = filename - sys.argv = [filename] - if args is not None: - for arg in args.split(): - sys.argv.append(arg) - self.execfile(filename) - sys.argv = [''] - self.namespace.pop('__file__') - - def eval(self, text): - """ - Evaluate text and return (obj, valid) - where *obj* is the object represented by *text* - and *valid* is True if object evaluation did not raise any exception - """ - assert is_text_string(text) - try: - return eval(text, self.locals), True - except: - return None, False - - def is_defined(self, objtxt, force_import=False): - """Return True if object is defined""" - return isdefined(objtxt, force_import=force_import, - namespace=self.locals) - - #=========================================================================== - # InteractiveConsole API - #=========================================================================== - def push(self, line): - """ - Push a line of source text to the interpreter - - The line should not have a trailing newline; it may have internal - newlines. The line is appended to a buffer and the interpreter’s - runsource() method is called with the concatenated contents of the - buffer as source. If this indicates that the command was executed - or invalid, the buffer is reset; otherwise, the command is incomplete, - and the buffer is left as it was after the line was appended. - The return value is True if more input is required, False if the line - was dealt with in some way (this is the same as runsource()). - """ - return InteractiveConsole.push(self, "#coding=utf-8\n" + line) - - def resetbuffer(self): - """Remove any unhandled source text from the input buffer""" - InteractiveConsole.resetbuffer(self) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shell Interpreter""" + +from __future__ import print_function + +import sys +import atexit +import threading +import ctypes +import os +import re +import os.path as osp +import pydoc +from code import InteractiveConsole + +from spyder_kernels.utils.dochelpers import isdefined + +# Local imports: +from spyder.utils import encoding, programs +from spyder.py3compat import is_text_string +from spyder.utils.misc import remove_backslashes, getcwd_or_home + +# Force Python to search modules in the current directory first: +sys.path.insert(0, '') + + +def guess_filename(filename): + """Guess filename""" + if osp.isfile(filename): + return filename + if not filename.endswith('.py'): + filename += '.py' + for path in [getcwd_or_home()] + sys.path: + fname = osp.join(path, filename) + if osp.isfile(fname): + return fname + elif osp.isfile(fname+'.py'): + return fname+'.py' + elif osp.isfile(fname+'.pyw'): + return fname+'.pyw' + return filename + +class Interpreter(InteractiveConsole, threading.Thread): + """Interpreter, executed in a separate thread""" + p1 = ">>> " + p2 = "... " + def __init__(self, namespace=None, exitfunc=None, + Output=None, WidgetProxy=None, debug=False): + """ + namespace: locals send to InteractiveConsole object + commands: list of commands executed at startup + """ + InteractiveConsole.__init__(self, namespace) + threading.Thread.__init__(self) + + self._id = None + + self.exit_flag = False + self.debug = debug + + # Execution Status + self.more = False + + if exitfunc is not None: + atexit.register(exitfunc) + + self.namespace = self.locals + self.namespace['__name__'] = '__main__' + self.namespace['execfile'] = self.execfile + self.namespace['runfile'] = self.runfile + self.namespace['raw_input'] = self.raw_input_replacement + self.namespace['help'] = self.help_replacement + + # Capture all interactive input/output + self.initial_stdout = sys.stdout + self.initial_stderr = sys.stderr + self.initial_stdin = sys.stdin + + # Create communication pipes + pr, pw = os.pipe() + self.stdin_read = os.fdopen(pr, "r") + self.stdin_write = os.fdopen(pw, "wb", 0) + self.stdout_write = Output() + self.stderr_write = Output() + + self.input_condition = threading.Condition() + self.widget_proxy = WidgetProxy(self.input_condition) + + self.redirect_stds() + + + #------ Standard input/output + def redirect_stds(self): + """Redirects stds""" + if not self.debug: + sys.stdout = self.stdout_write + sys.stderr = self.stderr_write + sys.stdin = self.stdin_read + + def restore_stds(self): + """Restore stds""" + if not self.debug: + sys.stdout = self.initial_stdout + sys.stderr = self.initial_stderr + sys.stdin = self.initial_stdin + + def raw_input_replacement(self, prompt=''): + """For raw_input builtin function emulation""" + self.widget_proxy.wait_input(prompt) + self.input_condition.acquire() + while not self.widget_proxy.data_available(): + self.input_condition.wait() + inp = self.widget_proxy.input_data + self.input_condition.release() + return inp + + def help_replacement(self, text=None, interactive=False): + """For help builtin function emulation""" + if text is not None and not interactive: + return pydoc.help(text) + elif text is None: + pyver = "%d.%d" % (sys.version_info[0], sys.version_info[1]) + self.write(""" +Welcome to Python %s! This is the online help utility. + +If this is your first time using Python, you should definitely check out +the tutorial on the Internet at https://www.python.org/about/gettingstarted/ + +Enter the name of any module, keyword, or topic to get help on writing +Python programs and using Python modules. To quit this help utility and +return to the interpreter, just type "quit". + +To get a list of available modules, keywords, or topics, type "modules", +"keywords", or "topics". Each module also comes with a one-line summary +of what it does; to list the modules whose summaries contain a given word +such as "spam", type "modules spam". +""" % pyver) + else: + text = text.strip() + try: + eval("pydoc.help(%s)" % text) + except (NameError, SyntaxError): + print("no Python documentation found for '%r'" % text) # spyder: test-skip + self.write(os.linesep) + self.widget_proxy.new_prompt("help> ") + inp = self.raw_input_replacement() + if inp.strip(): + self.help_replacement(inp, interactive=True) + else: + self.write(""" +You are now leaving help and returning to the Python interpreter. +If you want to ask for help on a particular object directly from the +interpreter, you can type "help(object)". Executing "help('string')" +has the same effect as typing a particular string at the help> prompt. +""") + + def run_command(self, cmd, new_prompt=True): + """Run command in interpreter""" + if cmd == 'exit()': + self.exit_flag = True + self.write('\n') + return + # -- Special commands type I + # (transformed into commands executed in the interpreter) + # ? command + special_pattern = r"^%s (?:r\')?(?:u\')?\"?\'?([a-zA-Z0-9_\.]+)" + run_match = re.match(special_pattern % 'run', cmd) + help_match = re.match(r'^([a-zA-Z0-9_\.]+)\?$', cmd) + cd_match = re.match(r"^\!cd \"?\'?([a-zA-Z0-9_ \.]+)", cmd) + if help_match: + cmd = 'help(%s)' % help_match.group(1) + # run command + elif run_match: + filename = guess_filename(run_match.groups()[0]) + cmd = "runfile('%s', args=None)" % remove_backslashes(filename) + # !cd system command + elif cd_match: + cmd = 'import os; os.chdir(r"%s")' % cd_match.groups()[0].strip() + # -- End of Special commands type I + + # -- Special commands type II + # (don't need code execution in interpreter) + xedit_match = re.match(special_pattern % 'xedit', cmd) + edit_match = re.match(special_pattern % 'edit', cmd) + clear_match = re.match(r"^clear ([a-zA-Z0-9_, ]+)", cmd) + # (external) edit command + if xedit_match: + filename = guess_filename(xedit_match.groups()[0]) + self.widget_proxy.edit(filename, external_editor=True) + # local edit command + elif edit_match: + filename = guess_filename(edit_match.groups()[0]) + if osp.isfile(filename): + self.widget_proxy.edit(filename) + else: + self.stderr_write.write( + "No such file or directory: %s\n" % filename) + # remove reference (equivalent to MATLAB's clear command) + elif clear_match: + varnames = clear_match.groups()[0].replace(' ', '').split(',') + for varname in varnames: + try: + self.namespace.pop(varname) + except KeyError: + pass + # Execute command + elif cmd.startswith('!'): + # System ! command + pipe = programs.run_shell_command(cmd[1:]) + txt_out = encoding.transcode( pipe.stdout.read().decode() ) + txt_err = encoding.transcode( pipe.stderr.read().decode().rstrip() ) + if txt_err: + self.stderr_write.write(txt_err) + if txt_out: + self.stdout_write.write(txt_out) + self.stdout_write.write('\n') + self.more = False + # -- End of Special commands type II + else: + # Command executed in the interpreter +# self.widget_proxy.set_readonly(True) + self.more = self.push(cmd) +# self.widget_proxy.set_readonly(False) + + if new_prompt: + self.widget_proxy.new_prompt(self.p2 if self.more else self.p1) + if not self.more: + self.resetbuffer() + + def run(self): + """Wait for input and run it""" + while not self.exit_flag: + self.run_line() + + def run_line(self): + line = self.stdin_read.readline() + if self.exit_flag: + return + # Remove last character which is always '\n': + self.run_command(line[:-1]) + + def get_thread_id(self): + """Return thread id""" + if self._id is None: + for thread_id, obj in list(threading._active.items()): + if obj is self: + self._id = thread_id + return self._id + + def raise_keyboard_interrupt(self): + if self.isAlive(): + ctypes.pythonapi.PyThreadState_SetAsyncExc(self.get_thread_id(), + ctypes.py_object(KeyboardInterrupt)) + return True + else: + return False + + + def closing(self): + """Actions to be done before restarting this interpreter""" + pass + + def execfile(self, filename): + """Exec filename""" + source = open(filename, 'r').read() + try: + try: + name = filename.encode('ascii') + except UnicodeEncodeError: + name = '' + code = compile(source, name, "exec") + except (OverflowError, SyntaxError): + InteractiveConsole.showsyntaxerror(self, filename) + else: + self.runcode(code) + + def runfile(self, filename, args=None): + """ + Run filename + args: command line arguments (string) + """ + if args is not None and not is_text_string(args): + raise TypeError("expected a character buffer object") + self.namespace['__file__'] = filename + sys.argv = [filename] + if args is not None: + for arg in args.split(): + sys.argv.append(arg) + self.execfile(filename) + sys.argv = [''] + self.namespace.pop('__file__') + + def eval(self, text): + """ + Evaluate text and return (obj, valid) + where *obj* is the object represented by *text* + and *valid* is True if object evaluation did not raise any exception + """ + assert is_text_string(text) + try: + return eval(text, self.locals), True + except: + return None, False + + def is_defined(self, objtxt, force_import=False): + """Return True if object is defined""" + return isdefined(objtxt, force_import=force_import, + namespace=self.locals) + + #=========================================================================== + # InteractiveConsole API + #=========================================================================== + def push(self, line): + """ + Push a line of source text to the interpreter + + The line should not have a trailing newline; it may have internal + newlines. The line is appended to a buffer and the interpreter’s + runsource() method is called with the concatenated contents of the + buffer as source. If this indicates that the command was executed + or invalid, the buffer is reset; otherwise, the command is incomplete, + and the buffer is left as it was after the line was appended. + The return value is True if more input is required, False if the line + was dealt with in some way (this is the same as runsource()). + """ + return InteractiveConsole.push(self, "#coding=utf-8\n" + line) + + def resetbuffer(self): + """Remove any unhandled source text from the input buffer""" + InteractiveConsole.resetbuffer(self) diff --git a/spyder/plugins/console/widgets/__init__.py b/spyder/plugins/console/widgets/__init__.py index 97000e1ad2c..6d0892f15fe 100644 --- a/spyder/plugins/console/widgets/__init__.py +++ b/spyder/plugins/console/widgets/__init__.py @@ -1,10 +1,10 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -spyder.plugins.console.widgets -============================== -""" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.plugins.console.widgets +============================== +""" diff --git a/spyder/plugins/console/widgets/internalshell.py b/spyder/plugins/console/widgets/internalshell.py index 3b9a73beb20..05919cf9998 100644 --- a/spyder/plugins/console/widgets/internalshell.py +++ b/spyder/plugins/console/widgets/internalshell.py @@ -1,494 +1,494 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Internal shell widget : PythonShellWidget + Interpreter""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -#FIXME: Internal shell MT: for i in range(100000): print i -> bug - -# Standard library imports -from time import time -import os -import threading - -# Third party imports -from qtpy.QtCore import QEventLoop, QObject, Signal, Slot -from qtpy.QtWidgets import QMessageBox -from spyder_kernels.utils.dochelpers import (getargtxt, getdoc, getobjdir, - getsource) - -# Local imports -from spyder import get_versions -from spyder.api.translations import get_translation -from spyder.plugins.console.utils.interpreter import Interpreter -from spyder.py3compat import (builtins, to_binary_string, - to_text_string) -from spyder.utils.icon_manager import ima -from spyder.utils import programs -from spyder.utils.misc import get_error_match, getcwd_or_home -from spyder.utils.qthelpers import create_action -from spyder.plugins.console.widgets.shell import PythonShellWidget -from spyder.plugins.variableexplorer.widgets.objecteditor import oedit -from spyder.config.base import get_conf_path, get_debug_level - - -# Localization -_ = get_translation('spyder') -builtins.oedit = oedit - - -def create_banner(message): - """Create internal shell banner""" - if message is None: - versions = get_versions() - return 'Python %s %dbits [%s]'\ - % (versions['python'], versions['bitness'], versions['system']) - else: - return message - - -class SysOutput(QObject): - """Handle standard I/O queue""" - data_avail = Signal() - - def __init__(self): - QObject.__init__(self) - self.queue = [] - self.lock = threading.Lock() - - def write(self, val): - self.lock.acquire() - self.queue.append(val) - self.lock.release() - self.data_avail.emit() - - def empty_queue(self): - self.lock.acquire() - s = "".join(self.queue) - self.queue = [] - self.lock.release() - return s - - # We need to add this method to fix spyder-ide/spyder#1789. - def flush(self): - pass - - # This is needed to fix spyder-ide/spyder#2984. - @property - def closed(self): - return False - -class WidgetProxyData(object): - pass - -class WidgetProxy(QObject): - """Handle Shell widget refresh signal""" - - sig_new_prompt = Signal(str) - sig_set_readonly = Signal(bool) - sig_edit = Signal(str, bool) - sig_wait_input = Signal(str) - - def __init__(self, input_condition): - QObject.__init__(self) - - # External editor - self._gotoline = None - self._path = None - self.input_data = None - self.input_condition = input_condition - - def new_prompt(self, prompt): - self.sig_new_prompt.emit(prompt) - - def set_readonly(self, state): - self.sig_set_readonly.emit(state) - - def edit(self, filename, external_editor=False): - self.sig_edit.emit(filename, external_editor) - - def data_available(self): - """Return True if input data is available""" - return self.input_data is not WidgetProxyData - - def wait_input(self, prompt=''): - self.input_data = WidgetProxyData - self.sig_wait_input.emit(prompt) - - def end_input(self, cmd): - self.input_condition.acquire() - self.input_data = cmd - self.input_condition.notify() - self.input_condition.release() - - -class InternalShell(PythonShellWidget): - """Shell base widget: link between PythonShellWidget and Interpreter""" - - # --- Signals - - # This signal is emitted when the buffer is flushed - sig_refreshed = Signal() - - # Request to show a status message on the main window - sig_show_status_requested = Signal(str) - - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = Signal(str) - - # TODO: I think this is not being used now? - sig_focus_changed = Signal() - - def __init__(self, parent=None, commands=[], message=None, - max_line_count=300, exitfunc=None, profile=False, - multithreaded=True): - super().__init__(parent, get_conf_path('history_internal.py'), - profile=profile) - - self.multithreaded = multithreaded - self.setMaximumBlockCount(max_line_count) - - # Allow raw_input support: - self.input_loop = None - self.input_mode = False - - # KeyboardInterrupt support - self.interrupted = False # used only for not-multithreaded mode - self.sig_keyboard_interrupt.connect(self.keyboard_interrupt) - - # Code completion / calltips - # keyboard events management - self.eventqueue = [] - - # Init interpreter - self.exitfunc = exitfunc - self.commands = commands - self.message = message - self.interpreter = None - - # Clear status bar - self.sig_show_status_requested.emit('') - - # Embedded shell -- requires the monitor (which installs the - # 'open_in_spyder' function in builtins) - if hasattr(builtins, 'open_in_spyder'): - self.sig_go_to_error_requested.connect( - self.open_with_external_spyder) - - #------ Interpreter - def start_interpreter(self, namespace): - """Start Python interpreter.""" - self.clear() - - if self.interpreter is not None: - self.interpreter.closing() - - self.interpreter = Interpreter(namespace, self.exitfunc, - SysOutput, WidgetProxy, - get_debug_level()) - self.interpreter.stdout_write.data_avail.connect(self.stdout_avail) - self.interpreter.stderr_write.data_avail.connect(self.stderr_avail) - self.interpreter.widget_proxy.sig_set_readonly.connect(self.setReadOnly) - self.interpreter.widget_proxy.sig_new_prompt.connect(self.new_prompt) - self.interpreter.widget_proxy.sig_edit.connect(self.edit_script) - self.interpreter.widget_proxy.sig_wait_input.connect(self.wait_input) - - if self.multithreaded: - self.interpreter.start() - - # Interpreter banner - banner = create_banner(self.message) - self.write(banner, prompt=True) - - # Initial commands - for cmd in self.commands: - self.run_command(cmd, history=False, new_prompt=False) - - # First prompt - self.new_prompt(self.interpreter.p1) - self.sig_refreshed.emit() - - return self.interpreter - - def exit_interpreter(self): - """Exit interpreter""" - self.interpreter.exit_flag = True - if self.multithreaded: - self.interpreter.stdin_write.write(to_binary_string('\n')) - self.interpreter.restore_stds() - - def edit_script(self, filename, external_editor): - filename = to_text_string(filename) - if external_editor: - self.external_editor(filename) - else: - self.parent().edit_script(filename) - - def stdout_avail(self): - """Data is available in stdout, let's empty the queue and write it!""" - data = self.interpreter.stdout_write.empty_queue() - if data: - self.write(data) - - def stderr_avail(self): - """Data is available in stderr, let's empty the queue and write it!""" - data = self.interpreter.stderr_write.empty_queue() - if data: - self.write(data, error=True) - self.flush(error=True) - - - #------Raw input support - def wait_input(self, prompt=''): - """Wait for input (raw_input support)""" - self.new_prompt(prompt) - self.setFocus() - self.input_mode = True - self.input_loop = QEventLoop(None) - self.input_loop.exec_() - self.input_loop = None - - def end_input(self, cmd): - """End of wait_input mode""" - self.input_mode = False - self.input_loop.exit() - self.interpreter.widget_proxy.end_input(cmd) - - - #----- Menus, actions, ... - def setup_context_menu(self): - """Reimplement PythonShellWidget method""" - PythonShellWidget.setup_context_menu(self) - self.help_action = create_action(self, _("Help..."), - icon=ima.icon('DialogHelpButton'), - triggered=self.help) - self.menu.addAction(self.help_action) - - @Slot() - def help(self): - """Help on Spyder console""" - QMessageBox.about(self, _("Help"), - """%s -

%s
edit foobar.py -

%s
xedit foobar.py -

%s
run foobar.py -

%s
clear x, y -

%s
!ls -

%s
object? -

%s
result = oedit(object) - """ % (_('Shell special commands:'), - _('Internal editor:'), - _('External editor:'), - _('Run script:'), - _('Remove references:'), - _('System commands:'), - _('Python help:'), - _('GUI-based editor:'))) - - - #------ External editing - def open_with_external_spyder(self, text): - """Load file in external Spyder's editor, if available - This method is used only for embedded consoles - (could also be useful if we ever implement the magic %edit command)""" - match = get_error_match(to_text_string(text)) - if match: - fname, lnb = match.groups() - builtins.open_in_spyder(fname, int(lnb)) - - def set_external_editor(self, path, gotoline): - """Set external editor path and gotoline option.""" - self._path = path - self._gotoline = gotoline - - def external_editor(self, filename, goto=-1): - """ - Edit in an external editor. - - Recommended: SciTE (e.g. to go to line where an error did occur). - """ - editor_path = self._path - goto_option = self._gotoline - - if os.path.isfile(editor_path): - try: - args = [filename] - if goto > 0 and goto_option: - args.append('%s%d'.format(goto_option, goto)) - - programs.run_program(editor_path, args) - except OSError: - self.write_error("External editor was not found:" - " %s\n" % editor_path) - - #------ I/O - def flush(self, error=False, prompt=False): - """Reimplement ShellBaseWidget method""" - PythonShellWidget.flush(self, error=error, prompt=prompt) - if self.interrupted: - self.interrupted = False - raise KeyboardInterrupt - - - #------ Clear terminal - def clear_terminal(self): - """Reimplement ShellBaseWidget method""" - self.clear() - self.new_prompt(self.interpreter.p2 if self.interpreter.more else self.interpreter.p1) - - - #------ Keyboard events - def on_enter(self, command): - """on_enter""" - if self.profile: - # Simple profiling test - t0 = time() - for _ in range(10): - self.execute_command(command) - self.insert_text(u"\n<Δt>=%dms\n" % (1e2*(time()-t0))) - self.new_prompt(self.interpreter.p1) - else: - self.execute_command(command) - self.__flush_eventqueue() - - def keyPressEvent(self, event): - """ - Reimplement Qt Method - Enhanced keypress event handler - """ - if self.preprocess_keyevent(event): - # Event was accepted in self.preprocess_keyevent - return - self.postprocess_keyevent(event) - - def __flush_eventqueue(self): - """Flush keyboard event queue""" - while self.eventqueue: - past_event = self.eventqueue.pop(0) - self.postprocess_keyevent(past_event) - - #------ Command execution - def keyboard_interrupt(self): - """Simulate keyboard interrupt""" - if self.multithreaded: - self.interpreter.raise_keyboard_interrupt() - else: - if self.interpreter.more: - self.write_error("\nKeyboardInterrupt\n") - self.interpreter.more = False - self.new_prompt(self.interpreter.p1) - self.interpreter.resetbuffer() - else: - self.interrupted = True - - def execute_lines(self, lines): - """ - Execute a set of lines as multiple command - lines: multiple lines of text to be executed as single commands - """ - for line in lines.splitlines(): - stripped_line = line.strip() - if stripped_line.startswith('#'): - continue - self.write(line+os.linesep, flush=True) - self.execute_command(line+"\n") - self.flush() - - def execute_command(self, cmd): - """ - Execute a command - cmd: one-line command only, with '\n' at the end - """ - if self.input_mode: - self.end_input(cmd) - return - if cmd.endswith('\n'): - cmd = cmd[:-1] - # cls command - if cmd == 'cls': - self.clear_terminal() - return - self.run_command(cmd) - - def run_command(self, cmd, history=True, new_prompt=True): - """Run command in interpreter""" - if not cmd: - cmd = '' - else: - if history: - self.add_to_history(cmd) - if not self.multithreaded: - if 'input' not in cmd: - self.interpreter.stdin_write.write( - to_binary_string(cmd + '\n')) - self.interpreter.run_line() - self.sig_refreshed.emit() - else: - self.write(_('In order to use commands like "raw_input" ' - 'or "input" run Spyder with the multithread ' - 'option (--multithread) from a system terminal'), - error=True) - else: - self.interpreter.stdin_write.write(to_binary_string(cmd + '\n')) - - - #------ Code completion / Calltips - def _eval(self, text): - """Is text a valid object?""" - return self.interpreter.eval(text) - - def get_dir(self, objtxt): - """Return dir(object)""" - obj, valid = self._eval(objtxt) - if valid: - return getobjdir(obj) - - def get_globals_keys(self): - """Return shell globals() keys""" - return list(self.interpreter.namespace.keys()) - - def get_cdlistdir(self): - """Return shell current directory list dir""" - return os.listdir(getcwd_or_home()) - - def iscallable(self, objtxt): - """Is object callable?""" - obj, valid = self._eval(objtxt) - if valid: - return callable(obj) - - def get_arglist(self, objtxt): - """Get func/method argument list""" - obj, valid = self._eval(objtxt) - if valid: - return getargtxt(obj) - - def get__doc__(self, objtxt): - """Get object __doc__""" - obj, valid = self._eval(objtxt) - if valid: - return obj.__doc__ - - def get_doc(self, objtxt): - """Get object documentation dictionary""" - obj, valid = self._eval(objtxt) - if valid: - return getdoc(obj) - - def get_source(self, objtxt): - """Get object source""" - obj, valid = self._eval(objtxt) - if valid: - return getsource(obj) - - def is_defined(self, objtxt, force_import=False): - """Return True if object is defined""" - return self.interpreter.is_defined(objtxt, force_import) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Internal shell widget : PythonShellWidget + Interpreter""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +#FIXME: Internal shell MT: for i in range(100000): print i -> bug + +# Standard library imports +from time import time +import os +import threading + +# Third party imports +from qtpy.QtCore import QEventLoop, QObject, Signal, Slot +from qtpy.QtWidgets import QMessageBox +from spyder_kernels.utils.dochelpers import (getargtxt, getdoc, getobjdir, + getsource) + +# Local imports +from spyder import get_versions +from spyder.api.translations import get_translation +from spyder.plugins.console.utils.interpreter import Interpreter +from spyder.py3compat import (builtins, to_binary_string, + to_text_string) +from spyder.utils.icon_manager import ima +from spyder.utils import programs +from spyder.utils.misc import get_error_match, getcwd_or_home +from spyder.utils.qthelpers import create_action +from spyder.plugins.console.widgets.shell import PythonShellWidget +from spyder.plugins.variableexplorer.widgets.objecteditor import oedit +from spyder.config.base import get_conf_path, get_debug_level + + +# Localization +_ = get_translation('spyder') +builtins.oedit = oedit + + +def create_banner(message): + """Create internal shell banner""" + if message is None: + versions = get_versions() + return 'Python %s %dbits [%s]'\ + % (versions['python'], versions['bitness'], versions['system']) + else: + return message + + +class SysOutput(QObject): + """Handle standard I/O queue""" + data_avail = Signal() + + def __init__(self): + QObject.__init__(self) + self.queue = [] + self.lock = threading.Lock() + + def write(self, val): + self.lock.acquire() + self.queue.append(val) + self.lock.release() + self.data_avail.emit() + + def empty_queue(self): + self.lock.acquire() + s = "".join(self.queue) + self.queue = [] + self.lock.release() + return s + + # We need to add this method to fix spyder-ide/spyder#1789. + def flush(self): + pass + + # This is needed to fix spyder-ide/spyder#2984. + @property + def closed(self): + return False + +class WidgetProxyData(object): + pass + +class WidgetProxy(QObject): + """Handle Shell widget refresh signal""" + + sig_new_prompt = Signal(str) + sig_set_readonly = Signal(bool) + sig_edit = Signal(str, bool) + sig_wait_input = Signal(str) + + def __init__(self, input_condition): + QObject.__init__(self) + + # External editor + self._gotoline = None + self._path = None + self.input_data = None + self.input_condition = input_condition + + def new_prompt(self, prompt): + self.sig_new_prompt.emit(prompt) + + def set_readonly(self, state): + self.sig_set_readonly.emit(state) + + def edit(self, filename, external_editor=False): + self.sig_edit.emit(filename, external_editor) + + def data_available(self): + """Return True if input data is available""" + return self.input_data is not WidgetProxyData + + def wait_input(self, prompt=''): + self.input_data = WidgetProxyData + self.sig_wait_input.emit(prompt) + + def end_input(self, cmd): + self.input_condition.acquire() + self.input_data = cmd + self.input_condition.notify() + self.input_condition.release() + + +class InternalShell(PythonShellWidget): + """Shell base widget: link between PythonShellWidget and Interpreter""" + + # --- Signals + + # This signal is emitted when the buffer is flushed + sig_refreshed = Signal() + + # Request to show a status message on the main window + sig_show_status_requested = Signal(str) + + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = Signal(str) + + # TODO: I think this is not being used now? + sig_focus_changed = Signal() + + def __init__(self, parent=None, commands=[], message=None, + max_line_count=300, exitfunc=None, profile=False, + multithreaded=True): + super().__init__(parent, get_conf_path('history_internal.py'), + profile=profile) + + self.multithreaded = multithreaded + self.setMaximumBlockCount(max_line_count) + + # Allow raw_input support: + self.input_loop = None + self.input_mode = False + + # KeyboardInterrupt support + self.interrupted = False # used only for not-multithreaded mode + self.sig_keyboard_interrupt.connect(self.keyboard_interrupt) + + # Code completion / calltips + # keyboard events management + self.eventqueue = [] + + # Init interpreter + self.exitfunc = exitfunc + self.commands = commands + self.message = message + self.interpreter = None + + # Clear status bar + self.sig_show_status_requested.emit('') + + # Embedded shell -- requires the monitor (which installs the + # 'open_in_spyder' function in builtins) + if hasattr(builtins, 'open_in_spyder'): + self.sig_go_to_error_requested.connect( + self.open_with_external_spyder) + + #------ Interpreter + def start_interpreter(self, namespace): + """Start Python interpreter.""" + self.clear() + + if self.interpreter is not None: + self.interpreter.closing() + + self.interpreter = Interpreter(namespace, self.exitfunc, + SysOutput, WidgetProxy, + get_debug_level()) + self.interpreter.stdout_write.data_avail.connect(self.stdout_avail) + self.interpreter.stderr_write.data_avail.connect(self.stderr_avail) + self.interpreter.widget_proxy.sig_set_readonly.connect(self.setReadOnly) + self.interpreter.widget_proxy.sig_new_prompt.connect(self.new_prompt) + self.interpreter.widget_proxy.sig_edit.connect(self.edit_script) + self.interpreter.widget_proxy.sig_wait_input.connect(self.wait_input) + + if self.multithreaded: + self.interpreter.start() + + # Interpreter banner + banner = create_banner(self.message) + self.write(banner, prompt=True) + + # Initial commands + for cmd in self.commands: + self.run_command(cmd, history=False, new_prompt=False) + + # First prompt + self.new_prompt(self.interpreter.p1) + self.sig_refreshed.emit() + + return self.interpreter + + def exit_interpreter(self): + """Exit interpreter""" + self.interpreter.exit_flag = True + if self.multithreaded: + self.interpreter.stdin_write.write(to_binary_string('\n')) + self.interpreter.restore_stds() + + def edit_script(self, filename, external_editor): + filename = to_text_string(filename) + if external_editor: + self.external_editor(filename) + else: + self.parent().edit_script(filename) + + def stdout_avail(self): + """Data is available in stdout, let's empty the queue and write it!""" + data = self.interpreter.stdout_write.empty_queue() + if data: + self.write(data) + + def stderr_avail(self): + """Data is available in stderr, let's empty the queue and write it!""" + data = self.interpreter.stderr_write.empty_queue() + if data: + self.write(data, error=True) + self.flush(error=True) + + + #------Raw input support + def wait_input(self, prompt=''): + """Wait for input (raw_input support)""" + self.new_prompt(prompt) + self.setFocus() + self.input_mode = True + self.input_loop = QEventLoop(None) + self.input_loop.exec_() + self.input_loop = None + + def end_input(self, cmd): + """End of wait_input mode""" + self.input_mode = False + self.input_loop.exit() + self.interpreter.widget_proxy.end_input(cmd) + + + #----- Menus, actions, ... + def setup_context_menu(self): + """Reimplement PythonShellWidget method""" + PythonShellWidget.setup_context_menu(self) + self.help_action = create_action(self, _("Help..."), + icon=ima.icon('DialogHelpButton'), + triggered=self.help) + self.menu.addAction(self.help_action) + + @Slot() + def help(self): + """Help on Spyder console""" + QMessageBox.about(self, _("Help"), + """%s +

%s
edit foobar.py +

%s
xedit foobar.py +

%s
run foobar.py +

%s
clear x, y +

%s
!ls +

%s
object? +

%s
result = oedit(object) + """ % (_('Shell special commands:'), + _('Internal editor:'), + _('External editor:'), + _('Run script:'), + _('Remove references:'), + _('System commands:'), + _('Python help:'), + _('GUI-based editor:'))) + + + #------ External editing + def open_with_external_spyder(self, text): + """Load file in external Spyder's editor, if available + This method is used only for embedded consoles + (could also be useful if we ever implement the magic %edit command)""" + match = get_error_match(to_text_string(text)) + if match: + fname, lnb = match.groups() + builtins.open_in_spyder(fname, int(lnb)) + + def set_external_editor(self, path, gotoline): + """Set external editor path and gotoline option.""" + self._path = path + self._gotoline = gotoline + + def external_editor(self, filename, goto=-1): + """ + Edit in an external editor. + + Recommended: SciTE (e.g. to go to line where an error did occur). + """ + editor_path = self._path + goto_option = self._gotoline + + if os.path.isfile(editor_path): + try: + args = [filename] + if goto > 0 and goto_option: + args.append('%s%d'.format(goto_option, goto)) + + programs.run_program(editor_path, args) + except OSError: + self.write_error("External editor was not found:" + " %s\n" % editor_path) + + #------ I/O + def flush(self, error=False, prompt=False): + """Reimplement ShellBaseWidget method""" + PythonShellWidget.flush(self, error=error, prompt=prompt) + if self.interrupted: + self.interrupted = False + raise KeyboardInterrupt + + + #------ Clear terminal + def clear_terminal(self): + """Reimplement ShellBaseWidget method""" + self.clear() + self.new_prompt(self.interpreter.p2 if self.interpreter.more else self.interpreter.p1) + + + #------ Keyboard events + def on_enter(self, command): + """on_enter""" + if self.profile: + # Simple profiling test + t0 = time() + for _ in range(10): + self.execute_command(command) + self.insert_text(u"\n<Δt>=%dms\n" % (1e2*(time()-t0))) + self.new_prompt(self.interpreter.p1) + else: + self.execute_command(command) + self.__flush_eventqueue() + + def keyPressEvent(self, event): + """ + Reimplement Qt Method + Enhanced keypress event handler + """ + if self.preprocess_keyevent(event): + # Event was accepted in self.preprocess_keyevent + return + self.postprocess_keyevent(event) + + def __flush_eventqueue(self): + """Flush keyboard event queue""" + while self.eventqueue: + past_event = self.eventqueue.pop(0) + self.postprocess_keyevent(past_event) + + #------ Command execution + def keyboard_interrupt(self): + """Simulate keyboard interrupt""" + if self.multithreaded: + self.interpreter.raise_keyboard_interrupt() + else: + if self.interpreter.more: + self.write_error("\nKeyboardInterrupt\n") + self.interpreter.more = False + self.new_prompt(self.interpreter.p1) + self.interpreter.resetbuffer() + else: + self.interrupted = True + + def execute_lines(self, lines): + """ + Execute a set of lines as multiple command + lines: multiple lines of text to be executed as single commands + """ + for line in lines.splitlines(): + stripped_line = line.strip() + if stripped_line.startswith('#'): + continue + self.write(line+os.linesep, flush=True) + self.execute_command(line+"\n") + self.flush() + + def execute_command(self, cmd): + """ + Execute a command + cmd: one-line command only, with '\n' at the end + """ + if self.input_mode: + self.end_input(cmd) + return + if cmd.endswith('\n'): + cmd = cmd[:-1] + # cls command + if cmd == 'cls': + self.clear_terminal() + return + self.run_command(cmd) + + def run_command(self, cmd, history=True, new_prompt=True): + """Run command in interpreter""" + if not cmd: + cmd = '' + else: + if history: + self.add_to_history(cmd) + if not self.multithreaded: + if 'input' not in cmd: + self.interpreter.stdin_write.write( + to_binary_string(cmd + '\n')) + self.interpreter.run_line() + self.sig_refreshed.emit() + else: + self.write(_('In order to use commands like "raw_input" ' + 'or "input" run Spyder with the multithread ' + 'option (--multithread) from a system terminal'), + error=True) + else: + self.interpreter.stdin_write.write(to_binary_string(cmd + '\n')) + + + #------ Code completion / Calltips + def _eval(self, text): + """Is text a valid object?""" + return self.interpreter.eval(text) + + def get_dir(self, objtxt): + """Return dir(object)""" + obj, valid = self._eval(objtxt) + if valid: + return getobjdir(obj) + + def get_globals_keys(self): + """Return shell globals() keys""" + return list(self.interpreter.namespace.keys()) + + def get_cdlistdir(self): + """Return shell current directory list dir""" + return os.listdir(getcwd_or_home()) + + def iscallable(self, objtxt): + """Is object callable?""" + obj, valid = self._eval(objtxt) + if valid: + return callable(obj) + + def get_arglist(self, objtxt): + """Get func/method argument list""" + obj, valid = self._eval(objtxt) + if valid: + return getargtxt(obj) + + def get__doc__(self, objtxt): + """Get object __doc__""" + obj, valid = self._eval(objtxt) + if valid: + return obj.__doc__ + + def get_doc(self, objtxt): + """Get object documentation dictionary""" + obj, valid = self._eval(objtxt) + if valid: + return getdoc(obj) + + def get_source(self, objtxt): + """Get object source""" + obj, valid = self._eval(objtxt) + if valid: + return getsource(obj) + + def is_defined(self, objtxt, force_import=False): + """Return True if object is defined""" + return self.interpreter.is_defined(objtxt, force_import) diff --git a/spyder/plugins/console/widgets/shell.py b/spyder/plugins/console/widgets/shell.py index 7f307023a66..5458957f68e 100644 --- a/spyder/plugins/console/widgets/shell.py +++ b/spyder/plugins/console/widgets/shell.py @@ -1,1064 +1,1064 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shell widgets: base, python and terminal""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import keyword -import locale -import os -import os.path as osp -import re -import sys -import time - -# Third party imports -from qtpy.compat import getsavefilename -from qtpy.QtCore import Property, QCoreApplication, Qt, QTimer, Signal, Slot -from qtpy.QtGui import QKeySequence, QTextCharFormat, QTextCursor -from qtpy.QtWidgets import QApplication, QMenu, QToolTip - -# Local import -from spyder.config.base import _, get_conf_path, get_debug_level, STDERR -from spyder.config.manager import CONF -from spyder.py3compat import (builtins, is_string, is_text_string, - PY3, str_lower, to_text_string) -from spyder.utils import encoding -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import (add_actions, create_action, keybinding, - restore_keyevent) -from spyder.widgets.mixins import (GetHelpMixin, SaveHistoryMixin, - TracebackLinksMixin, BrowseHistoryMixin) -from spyder.plugins.console.widgets.console import ConsoleBaseWidget - - -# Maximum number of lines to load -MAX_LINES = 1000 - - -class ShellBaseWidget(ConsoleBaseWidget, SaveHistoryMixin, - BrowseHistoryMixin): - """ - Shell base widget - """ - - sig_redirect_stdio_requested = Signal(bool) - sig_keyboard_interrupt = Signal() - execute = Signal(str) - sig_append_to_history_requested = Signal(str, str) - - def __init__(self, parent, history_filename, profile=False, - initial_message=None, default_foreground_color=None, - error_foreground_color=None, traceback_foreground_color=None, - prompt_foreground_color=None, background_color=None): - """ - parent : specifies the parent widget - """ - ConsoleBaseWidget.__init__(self, parent) - SaveHistoryMixin.__init__(self, history_filename) - BrowseHistoryMixin.__init__(self) - - # Prompt position: tuple (line, index) - self.current_prompt_pos = None - self.new_input_line = True - - # History - assert is_text_string(history_filename) - self.history = self.load_history() - - # Session - self.historylog_filename = CONF.get('main', 'historylog_filename', - get_conf_path('history.log')) - - # Context menu - self.menu = None - self.setup_context_menu() - - # Simple profiling test - self.profile = profile - - # Buffer to increase performance of write/flush operations - self.__buffer = [] - if initial_message: - self.__buffer.append(initial_message) - - self.__timestamp = 0.0 - self.__flushtimer = QTimer(self) - self.__flushtimer.setSingleShot(True) - self.__flushtimer.timeout.connect(self.flush) - - # Give focus to widget - self.setFocus() - - # Cursor width - self.setCursorWidth(CONF.get('main', 'cursor/width')) - - # Adjustments to completion_widget to use it here - self.completion_widget.currentRowChanged.disconnect() - - def toggle_wrap_mode(self, enable): - """Enable/disable wrap mode""" - self.set_wrap_mode('character' if enable else None) - - def set_font(self, font): - """Set shell styles font""" - self.setFont(font) - self.set_pythonshell_font(font) - cursor = self.textCursor() - cursor.select(QTextCursor.Document) - charformat = QTextCharFormat() - charformat.setFontFamily(font.family()) - charformat.setFontPointSize(font.pointSize()) - cursor.mergeCharFormat(charformat) - - - #------ Context menu - def setup_context_menu(self): - """Setup shell context menu""" - self.menu = QMenu(self) - self.cut_action = create_action(self, _("Cut"), - shortcut=keybinding('Cut'), - icon=ima.icon('editcut'), - triggered=self.cut) - self.copy_action = create_action(self, _("Copy"), - shortcut=keybinding('Copy'), - icon=ima.icon('editcopy'), - triggered=self.copy) - paste_action = create_action(self, _("Paste"), - shortcut=keybinding('Paste'), - icon=ima.icon('editpaste'), - triggered=self.paste) - save_action = create_action(self, _("Save history log..."), - icon=ima.icon('filesave'), - tip=_("Save current history log (i.e. all " - "inputs and outputs) in a text file"), - triggered=self.save_historylog) - self.delete_action = create_action(self, _("Delete"), - shortcut=keybinding('Delete'), - icon=ima.icon('editdelete'), - triggered=self.delete) - selectall_action = create_action(self, _("Select All"), - shortcut=keybinding('SelectAll'), - icon=ima.icon('selectall'), - triggered=self.selectAll) - add_actions(self.menu, (self.cut_action, self.copy_action, - paste_action, self.delete_action, None, - selectall_action, None, save_action) ) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - state = self.has_selected_text() - self.copy_action.setEnabled(state) - self.cut_action.setEnabled(state) - self.delete_action.setEnabled(state) - self.menu.popup(event.globalPos()) - event.accept() - - - #------ Input buffer - def get_current_line_from_cursor(self): - return self.get_text('cursor', 'eof') - - def _select_input(self): - """Select current line (without selecting console prompt)""" - line, index = self.get_position('eof') - if self.current_prompt_pos is None: - pline, pindex = line, index - else: - pline, pindex = self.current_prompt_pos - self.setSelection(pline, pindex, line, index) - - @Slot() - def clear_terminal(self): - """ - Clear terminal window - Child classes reimplement this method to write prompt - """ - self.clear() - - # The buffer being edited - def _set_input_buffer(self, text): - """Set input buffer""" - if self.current_prompt_pos is not None: - self.replace_text(self.current_prompt_pos, 'eol', text) - else: - self.insert(text) - self.set_cursor_position('eof') - - def _get_input_buffer(self): - """Return input buffer""" - input_buffer = '' - if self.current_prompt_pos is not None: - input_buffer = self.get_text(self.current_prompt_pos, 'eol') - input_buffer = input_buffer.replace(os.linesep, '\n') - return input_buffer - - input_buffer = Property("QString", _get_input_buffer, _set_input_buffer) - - - #------ Prompt - def new_prompt(self, prompt): - """ - Print a new prompt and save its (line, index) position - """ - if self.get_cursor_line_column()[1] != 0: - self.write('\n') - self.write(prompt, prompt=True) - # now we update our cursor giving end of prompt - self.current_prompt_pos = self.get_position('cursor') - self.ensureCursorVisible() - self.new_input_line = False - - def check_selection(self): - """ - Check if selected text is r/w, - otherwise remove read-only parts of selection - """ - if self.current_prompt_pos is None: - self.set_cursor_position('eof') - else: - self.truncate_selection(self.current_prompt_pos) - - - #------ Copy / Keyboard interrupt - @Slot() - def copy(self): - """Copy text to clipboard... or keyboard interrupt""" - if self.has_selected_text(): - ConsoleBaseWidget.copy(self) - elif not sys.platform == 'darwin': - self.interrupt() - - def interrupt(self): - """Keyboard interrupt""" - self.sig_keyboard_interrupt.emit() - - @Slot() - def cut(self): - """Cut text""" - self.check_selection() - if self.has_selected_text(): - ConsoleBaseWidget.cut(self) - - @Slot() - def delete(self): - """Remove selected text""" - self.check_selection() - if self.has_selected_text(): - ConsoleBaseWidget.remove_selected_text(self) - - @Slot() - def save_historylog(self): - """Save current history log (all text in console)""" - title = _("Save history log") - self.sig_redirect_stdio_requested.emit(False) - filename, _selfilter = getsavefilename(self, title, - self.historylog_filename, "%s (*.log)" % _("History logs")) - self.sig_redirect_stdio_requested.emit(True) - if filename: - filename = osp.normpath(filename) - try: - encoding.write(to_text_string(self.get_text_with_eol()), - filename) - self.historylog_filename = filename - CONF.set('main', 'historylog_filename', filename) - except EnvironmentError: - pass - - #------ Basic keypress event handler - def on_enter(self, command): - """on_enter""" - self.execute_command(command) - - def execute_command(self, command): - self.execute.emit(command) - self.add_to_history(command) - self.new_input_line = True - - def on_new_line(self): - """On new input line""" - self.set_cursor_position('eof') - self.current_prompt_pos = self.get_position('cursor') - self.new_input_line = False - - @Slot() - def paste(self): - """Reimplemented slot to handle multiline paste action""" - if self.new_input_line: - self.on_new_line() - ConsoleBaseWidget.paste(self) - - def keyPressEvent(self, event): - """ - Reimplement Qt Method - Basic keypress event handler - (reimplemented in InternalShell to add more sophisticated features) - """ - if self.preprocess_keyevent(event): - # Event was accepted in self.preprocess_keyevent - return - self.postprocess_keyevent(event) - - def preprocess_keyevent(self, event): - """Pre-process keypress event: - return True if event is accepted, false otherwise""" - # Copy must be done first to be able to copy read-only text parts - # (otherwise, right below, we would remove selection - # if not on current line) - ctrl = event.modifiers() & Qt.ControlModifier - meta = event.modifiers() & Qt.MetaModifier # meta=ctrl in OSX - if event.key() == Qt.Key_C and \ - ((Qt.MetaModifier | Qt.ControlModifier) & event.modifiers()): - if meta and sys.platform == 'darwin': - self.interrupt() - elif ctrl: - self.copy() - event.accept() - return True - - if self.new_input_line and ( len(event.text()) or event.key() in \ - (Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right) ): - self.on_new_line() - - return False - - def postprocess_keyevent(self, event): - """Post-process keypress event: - in InternalShell, this is method is called when shell is ready""" - event, text, key, ctrl, shift = restore_keyevent(event) - - # Is cursor on the last line? and after prompt? - if len(text): - #XXX: Shouldn't it be: `if len(unicode(text).strip(os.linesep))` ? - if self.has_selected_text(): - self.check_selection() - self.restrict_cursor_position(self.current_prompt_pos, 'eof') - - cursor_position = self.get_position('cursor') - - if key in (Qt.Key_Return, Qt.Key_Enter): - if self.is_cursor_on_last_line(): - self._key_enter() - # add and run selection - else: - self.insert_text(self.get_selected_text(), at_end=True) - - elif key == Qt.Key_Insert and not shift and not ctrl: - self.setOverwriteMode(not self.overwriteMode()) - - elif key == Qt.Key_Delete: - if self.has_selected_text(): - self.check_selection() - self.remove_selected_text() - elif self.is_cursor_on_last_line(): - self.stdkey_clear() - - elif key == Qt.Key_Backspace: - self._key_backspace(cursor_position) - - elif key == Qt.Key_Tab: - self._key_tab() - - elif key == Qt.Key_Space and ctrl: - self._key_ctrl_space() - - elif key == Qt.Key_Left: - if self.current_prompt_pos == cursor_position: - # Avoid moving cursor on prompt - return - method = self.extend_selection_to_next if shift \ - else self.move_cursor_to_next - method('word' if ctrl else 'character', direction='left') - - elif key == Qt.Key_Right: - if self.is_cursor_at_end(): - return - method = self.extend_selection_to_next if shift \ - else self.move_cursor_to_next - method('word' if ctrl else 'character', direction='right') - - elif (key == Qt.Key_Home) or ((key == Qt.Key_Up) and ctrl): - self._key_home(shift, ctrl) - - elif (key == Qt.Key_End) or ((key == Qt.Key_Down) and ctrl): - self._key_end(shift, ctrl) - - elif key == Qt.Key_Up: - if not self.is_cursor_on_last_line(): - self.set_cursor_position('eof') - y_cursor = self.get_coordinates(cursor_position)[1] - y_prompt = self.get_coordinates(self.current_prompt_pos)[1] - if y_cursor > y_prompt: - self.stdkey_up(shift) - else: - self.browse_history(backward=True) - - elif key == Qt.Key_Down: - if not self.is_cursor_on_last_line(): - self.set_cursor_position('eof') - y_cursor = self.get_coordinates(cursor_position)[1] - y_end = self.get_coordinates('eol')[1] - if y_cursor < y_end: - self.stdkey_down(shift) - else: - self.browse_history(backward=False) - - elif key in (Qt.Key_PageUp, Qt.Key_PageDown): - #XXX: Find a way to do this programmatically instead of calling - # widget keyhandler (this won't work if the *event* is coming from - # the event queue - i.e. if the busy buffer is ever implemented) - ConsoleBaseWidget.keyPressEvent(self, event) - - elif key == Qt.Key_Escape and shift: - self.clear_line() - - elif key == Qt.Key_Escape: - self._key_escape() - - elif key == Qt.Key_L and ctrl: - self.clear_terminal() - - elif key == Qt.Key_V and ctrl: - self.paste() - - elif key == Qt.Key_X and ctrl: - self.cut() - - elif key == Qt.Key_Z and ctrl: - self.undo() - - elif key == Qt.Key_Y and ctrl: - self.redo() - - elif key == Qt.Key_A and ctrl: - self.selectAll() - - elif key == Qt.Key_Question and not self.has_selected_text(): - self._key_question(text) - - elif key == Qt.Key_ParenLeft and not self.has_selected_text(): - self._key_parenleft(text) - - elif key == Qt.Key_Period and not self.has_selected_text(): - self._key_period(text) - - elif len(text) and not self.isReadOnly(): - self.hist_wholeline = False - self.insert_text(text) - self._key_other(text) - - else: - # Let the parent widget handle the key press event - ConsoleBaseWidget.keyPressEvent(self, event) - - - #------ Key handlers - def _key_enter(self): - command = self.input_buffer - self.insert_text('\n', at_end=True) - self.on_enter(command) - self.flush() - def _key_other(self, text): - raise NotImplementedError - def _key_backspace(self, cursor_position): - raise NotImplementedError - def _key_tab(self): - raise NotImplementedError - def _key_ctrl_space(self): - raise NotImplementedError - def _key_home(self, shift, ctrl): - if self.is_cursor_on_last_line(): - self.stdkey_home(shift, ctrl, self.current_prompt_pos) - def _key_end(self, shift, ctrl): - if self.is_cursor_on_last_line(): - self.stdkey_end(shift, ctrl) - def _key_pageup(self): - raise NotImplementedError - def _key_pagedown(self): - raise NotImplementedError - def _key_escape(self): - raise NotImplementedError - def _key_question(self, text): - raise NotImplementedError - def _key_parenleft(self, text): - raise NotImplementedError - def _key_period(self, text): - raise NotImplementedError - - - #------ History Management - def load_history(self): - """Load history from a .py file in user home directory""" - if osp.isfile(self.history_filename): - rawhistory, _ = encoding.readlines(self.history_filename) - rawhistory = [line.replace('\n', '') for line in rawhistory] - if rawhistory[1] != self.INITHISTORY[1]: - rawhistory[1] = self.INITHISTORY[1] - else: - rawhistory = self.INITHISTORY - history = [line for line in rawhistory \ - if line and not line.startswith('#')] - - # Truncating history to X entries: - while len(history) >= MAX_LINES: - del history[0] - while rawhistory[0].startswith('#'): - del rawhistory[0] - del rawhistory[0] - - # Saving truncated history: - try: - encoding.writelines(rawhistory, self.history_filename) - except EnvironmentError: - pass - - return history - - #------ Simulation standards input/output - def write_error(self, text): - """Simulate stderr""" - self.flush() - self.write(text, flush=True, error=True) - if get_debug_level(): - STDERR.write(text) - - def write(self, text, flush=False, error=False, prompt=False): - """Simulate stdout and stderr""" - if prompt: - self.flush() - if not is_string(text): - # This test is useful to discriminate QStrings from decoded str - text = to_text_string(text) - self.__buffer.append(text) - ts = time.time() - if flush or prompt: - self.flush(error=error, prompt=prompt) - elif ts - self.__timestamp > 0.05: - self.flush(error=error) - self.__timestamp = ts - # Timer to flush strings cached by last write() operation in series - self.__flushtimer.start(50) - - def flush(self, error=False, prompt=False): - """Flush buffer, write text to console""" - # Fix for spyder-ide/spyder#2452 - if PY3: - try: - text = "".join(self.__buffer) - except TypeError: - text = b"".join(self.__buffer) - try: - text = text.decode( locale.getdefaultlocale()[1] ) - except: - pass - else: - text = "".join(self.__buffer) - - self.__buffer = [] - self.insert_text(text, at_end=True, error=error, prompt=prompt) - - # The lines below are causing a hard crash when Qt generates - # internal warnings. We replaced them instead for self.update(), - # which prevents the crash. - # See spyder-ide/spyder#10893 - # QCoreApplication.processEvents() - # self.repaint() - self.update() - - # Clear input buffer: - self.new_input_line = True - - - #------ Text Insertion - def insert_text(self, text, at_end=False, error=False, prompt=False): - """ - Insert text at the current cursor position - or at the end of the command line - """ - if at_end: - # Insert text at the end of the command line - self.append_text_to_shell(text, error, prompt) - else: - # Insert text at current cursor position - ConsoleBaseWidget.insert_text(self, text) - - - #------ Re-implemented Qt Methods - def focusNextPrevChild(self, next): - """ - Reimplemented to stop Tab moving to the next window - """ - if next: - return False - return ConsoleBaseWidget.focusNextPrevChild(self, next) - - - #------ Drag and drop - def dragEnterEvent(self, event): - """Drag and Drop - Enter event""" - event.setAccepted(event.mimeData().hasFormat("text/plain")) - - def dragMoveEvent(self, event): - """Drag and Drop - Move event""" - if (event.mimeData().hasFormat("text/plain")): - event.setDropAction(Qt.MoveAction) - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - """Drag and Drop - Drop event""" - if (event.mimeData().hasFormat("text/plain")): - text = to_text_string(event.mimeData().text()) - if self.new_input_line: - self.on_new_line() - self.insert_text(text, at_end=True) - self.setFocus() - event.setDropAction(Qt.MoveAction) - event.accept() - else: - event.ignore() - - def drop_pathlist(self, pathlist): - """Drop path list""" - raise NotImplementedError - - -# Example how to debug complex interclass call chains: -# -# from spyder.utils.debug import log_methods_calls -# log_methods_calls('log.log', ShellBaseWidget) - -class PythonShellWidget(TracebackLinksMixin, ShellBaseWidget, - GetHelpMixin): - """Python shell widget""" - QT_CLASS = ShellBaseWidget - INITHISTORY = ['# -*- coding: utf-8 -*-', - '# *** Spyder Python Console History Log ***',] - SEPARATOR = '%s##---(%s)---' % (os.linesep*2, time.ctime()) - - # --- Signals - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = Signal(str) - - # Signal - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Example `{'name': str, 'ignore_unknown': bool}`. - """ - - def __init__(self, parent, history_filename, profile=False, initial_message=None): - ShellBaseWidget.__init__(self, parent, history_filename, - profile=profile, - initial_message=initial_message) - TracebackLinksMixin.__init__(self) - GetHelpMixin.__init__(self) - - # Local shortcuts - self.shortcuts = self.create_shortcuts() - - def create_shortcuts(self): - array_inline = CONF.config_shortcut( - lambda: self.enter_array_inline(), - context='array_builder', - name='enter array inline', - parent=self) - array_table = CONF.config_shortcut( - lambda: self.enter_array_table(), - context='array_builder', - name='enter array table', - parent=self) - inspectsc = CONF.config_shortcut( - self.inspect_current_object, - context='Console', - name='Inspect current object', - parent=self) - clear_line_sc = CONF.config_shortcut( - self.clear_line, - context='Console', - name="Clear line", - parent=self, - ) - clear_shell_sc = CONF.config_shortcut( - self.clear_terminal, - context='Console', - name="Clear shell", - parent=self, - ) - - return [inspectsc, array_inline, array_table, clear_line_sc, - clear_shell_sc] - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - #------ Context menu - def setup_context_menu(self): - """Reimplements ShellBaseWidget method""" - ShellBaseWidget.setup_context_menu(self) - self.copy_without_prompts_action = create_action( - self, - _("Copy without prompts"), - icon=ima.icon('copywop'), - triggered=self.copy_without_prompts) - - clear_line_action = create_action( - self, - _("Clear line"), - QKeySequence(CONF.get_shortcut('console', 'Clear line')), - icon=ima.icon('editdelete'), - tip=_("Clear line"), - triggered=self.clear_line) - - clear_action = create_action( - self, - _("Clear shell"), - QKeySequence(CONF.get_shortcut('console', 'Clear shell')), - icon=ima.icon('editclear'), - tip=_("Clear shell contents ('cls' command)"), - triggered=self.clear_terminal) - - add_actions(self.menu, (self.copy_without_prompts_action, - clear_line_action, clear_action)) - - def contextMenuEvent(self, event): - """Reimplements ShellBaseWidget method""" - state = self.has_selected_text() - self.copy_without_prompts_action.setEnabled(state) - ShellBaseWidget.contextMenuEvent(self, event) - - @Slot() - def copy_without_prompts(self): - """Copy text to clipboard without prompts""" - text = self.get_selected_text() - lines = text.split(os.linesep) - for index, line in enumerate(lines): - if line.startswith('>>> ') or line.startswith('... '): - lines[index] = line[4:] - text = os.linesep.join(lines) - QApplication.clipboard().setText(text) - - - #------ Key handlers - def postprocess_keyevent(self, event): - """Process keypress event""" - ShellBaseWidget.postprocess_keyevent(self, event) - - def _key_other(self, text): - """1 character key""" - if self.is_completion_widget_visible(): - self.completion_text += text - - def _key_backspace(self, cursor_position): - """Action for Backspace key""" - if self.has_selected_text(): - self.check_selection() - self.remove_selected_text() - elif self.current_prompt_pos == cursor_position: - # Avoid deleting prompt - return - elif self.is_cursor_on_last_line(): - self.stdkey_backspace() - if self.is_completion_widget_visible(): - # Removing only last character because if there was a selection - # the completion widget would have been canceled - self.completion_text = self.completion_text[:-1] - - def _key_tab(self): - """Action for TAB key""" - if self.is_cursor_on_last_line(): - empty_line = not self.get_current_line_to_cursor().strip() - if empty_line: - self.stdkey_tab() - else: - self.show_code_completion() - - def _key_ctrl_space(self): - """Action for Ctrl+Space""" - if not self.is_completion_widget_visible(): - self.show_code_completion() - - def _key_pageup(self): - """Action for PageUp key""" - pass - - def _key_pagedown(self): - """Action for PageDown key""" - pass - - def _key_escape(self): - """Action for ESCAPE key""" - if self.is_completion_widget_visible(): - self.hide_completion_widget() - - def _key_question(self, text): - """Action for '?'""" - if self.get_current_line_to_cursor(): - last_obj = self.get_last_obj() - if last_obj and not last_obj.isdigit(): - self.show_object_info(last_obj) - self.insert_text(text) - # In case calltip and completion are shown at the same time: - if self.is_completion_widget_visible(): - self.completion_text += '?' - - def _key_parenleft(self, text): - """Action for '('""" - self.hide_completion_widget() - if self.get_current_line_to_cursor(): - last_obj = self.get_last_obj() - if last_obj and not last_obj.isdigit(): - self.insert_text(text) - self.show_object_info(last_obj, call=True) - return - self.insert_text(text) - - def _key_period(self, text): - """Action for '.'""" - self.insert_text(text) - if self.codecompletion_auto: - # Enable auto-completion only if last token isn't a float - last_obj = self.get_last_obj() - if last_obj and not last_obj.isdigit(): - self.show_code_completion() - - - #------ Paste - def paste(self): - """Reimplemented slot to handle multiline paste action""" - text = to_text_string(QApplication.clipboard().text()) - if len(text.splitlines()) > 1: - # Multiline paste - if self.new_input_line: - self.on_new_line() - self.remove_selected_text() # Remove selection, eventually - end = self.get_current_line_from_cursor() - lines = self.get_current_line_to_cursor() + text + end - self.clear_line() - self.execute_lines(lines) - self.move_cursor(-len(end)) - else: - # Standard paste - ShellBaseWidget.paste(self) - - # ------ Code Completion / Calltips - # Methods implemented in child class: - # (e.g. InternalShell) - def get_dir(self, objtxt): - """Return dir(object)""" - raise NotImplementedError - - def get_globals_keys(self): - """Return shell globals() keys""" - raise NotImplementedError - - def get_cdlistdir(self): - """Return shell current directory list dir""" - raise NotImplementedError - - def iscallable(self, objtxt): - """Is object callable?""" - raise NotImplementedError - - def get_arglist(self, objtxt): - """Get func/method argument list""" - raise NotImplementedError - - def get__doc__(self, objtxt): - """Get object __doc__""" - raise NotImplementedError - - def get_doc(self, objtxt): - """Get object documentation dictionary""" - raise NotImplementedError - - def get_source(self, objtxt): - """Get object source""" - raise NotImplementedError - - def is_defined(self, objtxt, force_import=False): - """Return True if object is defined""" - raise NotImplementedError - - def show_completion_widget(self, textlist): - """Show completion widget""" - self.completion_widget.show_list( - textlist, automatic=False, position=None) - - def hide_completion_widget(self, focus_to_parent=True): - """Hide completion widget""" - self.completion_widget.hide(focus_to_parent=focus_to_parent) - - def show_completion_list(self, completions, completion_text=""): - """Display the possible completions""" - if not completions: - return - if not isinstance(completions[0], tuple): - completions = [(c, '') for c in completions] - if len(completions) == 1 and completions[0][0] == completion_text: - return - self.completion_text = completion_text - # Sorting completion list (entries starting with underscore are - # put at the end of the list): - underscore = set([(comp, t) for (comp, t) in completions - if comp.startswith('_')]) - - completions = sorted(set(completions) - underscore, - key=lambda x: str_lower(x[0])) - completions += sorted(underscore, key=lambda x: str_lower(x[0])) - self.show_completion_widget(completions) - - def show_code_completion(self): - """Display a completion list based on the current line""" - # Note: unicode conversion is needed only for ExternalShellBase - text = to_text_string(self.get_current_line_to_cursor()) - last_obj = self.get_last_obj() - if not text: - return - - obj_dir = self.get_dir(last_obj) - if last_obj and obj_dir and text.endswith('.'): - self.show_completion_list(obj_dir) - return - - # Builtins and globals - if not text.endswith('.') and last_obj \ - and re.match(r'[a-zA-Z_0-9]*$', last_obj): - b_k_g = dir(builtins)+self.get_globals_keys()+keyword.kwlist - for objname in b_k_g: - if objname.startswith(last_obj) and objname != last_obj: - self.show_completion_list(b_k_g, completion_text=last_obj) - return - else: - return - - # Looking for an incomplete completion - if last_obj is None: - last_obj = text - dot_pos = last_obj.rfind('.') - if dot_pos != -1: - if dot_pos == len(last_obj)-1: - completion_text = "" - else: - completion_text = last_obj[dot_pos+1:] - last_obj = last_obj[:dot_pos] - completions = self.get_dir(last_obj) - if completions is not None: - self.show_completion_list(completions, - completion_text=completion_text) - return - - # Looking for ' or ": filename completion - q_pos = max([text.rfind("'"), text.rfind('"')]) - if q_pos != -1: - completions = self.get_cdlistdir() - if completions: - self.show_completion_list(completions, - completion_text=text[q_pos+1:]) - return - - #------ Drag'n Drop - def drop_pathlist(self, pathlist): - """Drop path list""" - if pathlist: - files = ["r'%s'" % path for path in pathlist] - if len(files) == 1: - text = files[0] - else: - text = "[" + ", ".join(files) + "]" - if self.new_input_line: - self.on_new_line() - self.insert_text(text) - self.setFocus() - - -class TerminalWidget(ShellBaseWidget): - """ - Terminal widget - """ - COM = 'rem' if os.name == 'nt' else '#' - INITHISTORY = ['%s *** Spyder Terminal History Log ***' % COM, COM,] - SEPARATOR = '%s%s ---(%s)---' % (os.linesep*2, COM, time.ctime()) - - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = Signal(str) - - def __init__(self, parent, history_filename, profile=False): - ShellBaseWidget.__init__(self, parent, history_filename, profile) - - #------ Key handlers - def _key_other(self, text): - """1 character key""" - pass - - def _key_backspace(self, cursor_position): - """Action for Backspace key""" - if self.has_selected_text(): - self.check_selection() - self.remove_selected_text() - elif self.current_prompt_pos == cursor_position: - # Avoid deleting prompt - return - elif self.is_cursor_on_last_line(): - self.stdkey_backspace() - - def _key_tab(self): - """Action for TAB key""" - if self.is_cursor_on_last_line(): - self.stdkey_tab() - - def _key_ctrl_space(self): - """Action for Ctrl+Space""" - pass - - def _key_escape(self): - """Action for ESCAPE key""" - self.clear_line() - - def _key_question(self, text): - """Action for '?'""" - self.insert_text(text) - - def _key_parenleft(self, text): - """Action for '('""" - self.insert_text(text) - - def _key_period(self, text): - """Action for '.'""" - self.insert_text(text) - - - #------ Drag'n Drop - def drop_pathlist(self, pathlist): - """Drop path list""" - if pathlist: - files = ['"%s"' % path for path in pathlist] - if len(files) == 1: - text = files[0] - else: - text = " ".join(files) - if self.new_input_line: - self.on_new_line() - self.insert_text(text) - self.setFocus() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shell widgets: base, python and terminal""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import keyword +import locale +import os +import os.path as osp +import re +import sys +import time + +# Third party imports +from qtpy.compat import getsavefilename +from qtpy.QtCore import Property, QCoreApplication, Qt, QTimer, Signal, Slot +from qtpy.QtGui import QKeySequence, QTextCharFormat, QTextCursor +from qtpy.QtWidgets import QApplication, QMenu, QToolTip + +# Local import +from spyder.config.base import _, get_conf_path, get_debug_level, STDERR +from spyder.config.manager import CONF +from spyder.py3compat import (builtins, is_string, is_text_string, + PY3, str_lower, to_text_string) +from spyder.utils import encoding +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import (add_actions, create_action, keybinding, + restore_keyevent) +from spyder.widgets.mixins import (GetHelpMixin, SaveHistoryMixin, + TracebackLinksMixin, BrowseHistoryMixin) +from spyder.plugins.console.widgets.console import ConsoleBaseWidget + + +# Maximum number of lines to load +MAX_LINES = 1000 + + +class ShellBaseWidget(ConsoleBaseWidget, SaveHistoryMixin, + BrowseHistoryMixin): + """ + Shell base widget + """ + + sig_redirect_stdio_requested = Signal(bool) + sig_keyboard_interrupt = Signal() + execute = Signal(str) + sig_append_to_history_requested = Signal(str, str) + + def __init__(self, parent, history_filename, profile=False, + initial_message=None, default_foreground_color=None, + error_foreground_color=None, traceback_foreground_color=None, + prompt_foreground_color=None, background_color=None): + """ + parent : specifies the parent widget + """ + ConsoleBaseWidget.__init__(self, parent) + SaveHistoryMixin.__init__(self, history_filename) + BrowseHistoryMixin.__init__(self) + + # Prompt position: tuple (line, index) + self.current_prompt_pos = None + self.new_input_line = True + + # History + assert is_text_string(history_filename) + self.history = self.load_history() + + # Session + self.historylog_filename = CONF.get('main', 'historylog_filename', + get_conf_path('history.log')) + + # Context menu + self.menu = None + self.setup_context_menu() + + # Simple profiling test + self.profile = profile + + # Buffer to increase performance of write/flush operations + self.__buffer = [] + if initial_message: + self.__buffer.append(initial_message) + + self.__timestamp = 0.0 + self.__flushtimer = QTimer(self) + self.__flushtimer.setSingleShot(True) + self.__flushtimer.timeout.connect(self.flush) + + # Give focus to widget + self.setFocus() + + # Cursor width + self.setCursorWidth(CONF.get('main', 'cursor/width')) + + # Adjustments to completion_widget to use it here + self.completion_widget.currentRowChanged.disconnect() + + def toggle_wrap_mode(self, enable): + """Enable/disable wrap mode""" + self.set_wrap_mode('character' if enable else None) + + def set_font(self, font): + """Set shell styles font""" + self.setFont(font) + self.set_pythonshell_font(font) + cursor = self.textCursor() + cursor.select(QTextCursor.Document) + charformat = QTextCharFormat() + charformat.setFontFamily(font.family()) + charformat.setFontPointSize(font.pointSize()) + cursor.mergeCharFormat(charformat) + + + #------ Context menu + def setup_context_menu(self): + """Setup shell context menu""" + self.menu = QMenu(self) + self.cut_action = create_action(self, _("Cut"), + shortcut=keybinding('Cut'), + icon=ima.icon('editcut'), + triggered=self.cut) + self.copy_action = create_action(self, _("Copy"), + shortcut=keybinding('Copy'), + icon=ima.icon('editcopy'), + triggered=self.copy) + paste_action = create_action(self, _("Paste"), + shortcut=keybinding('Paste'), + icon=ima.icon('editpaste'), + triggered=self.paste) + save_action = create_action(self, _("Save history log..."), + icon=ima.icon('filesave'), + tip=_("Save current history log (i.e. all " + "inputs and outputs) in a text file"), + triggered=self.save_historylog) + self.delete_action = create_action(self, _("Delete"), + shortcut=keybinding('Delete'), + icon=ima.icon('editdelete'), + triggered=self.delete) + selectall_action = create_action(self, _("Select All"), + shortcut=keybinding('SelectAll'), + icon=ima.icon('selectall'), + triggered=self.selectAll) + add_actions(self.menu, (self.cut_action, self.copy_action, + paste_action, self.delete_action, None, + selectall_action, None, save_action) ) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + state = self.has_selected_text() + self.copy_action.setEnabled(state) + self.cut_action.setEnabled(state) + self.delete_action.setEnabled(state) + self.menu.popup(event.globalPos()) + event.accept() + + + #------ Input buffer + def get_current_line_from_cursor(self): + return self.get_text('cursor', 'eof') + + def _select_input(self): + """Select current line (without selecting console prompt)""" + line, index = self.get_position('eof') + if self.current_prompt_pos is None: + pline, pindex = line, index + else: + pline, pindex = self.current_prompt_pos + self.setSelection(pline, pindex, line, index) + + @Slot() + def clear_terminal(self): + """ + Clear terminal window + Child classes reimplement this method to write prompt + """ + self.clear() + + # The buffer being edited + def _set_input_buffer(self, text): + """Set input buffer""" + if self.current_prompt_pos is not None: + self.replace_text(self.current_prompt_pos, 'eol', text) + else: + self.insert(text) + self.set_cursor_position('eof') + + def _get_input_buffer(self): + """Return input buffer""" + input_buffer = '' + if self.current_prompt_pos is not None: + input_buffer = self.get_text(self.current_prompt_pos, 'eol') + input_buffer = input_buffer.replace(os.linesep, '\n') + return input_buffer + + input_buffer = Property("QString", _get_input_buffer, _set_input_buffer) + + + #------ Prompt + def new_prompt(self, prompt): + """ + Print a new prompt and save its (line, index) position + """ + if self.get_cursor_line_column()[1] != 0: + self.write('\n') + self.write(prompt, prompt=True) + # now we update our cursor giving end of prompt + self.current_prompt_pos = self.get_position('cursor') + self.ensureCursorVisible() + self.new_input_line = False + + def check_selection(self): + """ + Check if selected text is r/w, + otherwise remove read-only parts of selection + """ + if self.current_prompt_pos is None: + self.set_cursor_position('eof') + else: + self.truncate_selection(self.current_prompt_pos) + + + #------ Copy / Keyboard interrupt + @Slot() + def copy(self): + """Copy text to clipboard... or keyboard interrupt""" + if self.has_selected_text(): + ConsoleBaseWidget.copy(self) + elif not sys.platform == 'darwin': + self.interrupt() + + def interrupt(self): + """Keyboard interrupt""" + self.sig_keyboard_interrupt.emit() + + @Slot() + def cut(self): + """Cut text""" + self.check_selection() + if self.has_selected_text(): + ConsoleBaseWidget.cut(self) + + @Slot() + def delete(self): + """Remove selected text""" + self.check_selection() + if self.has_selected_text(): + ConsoleBaseWidget.remove_selected_text(self) + + @Slot() + def save_historylog(self): + """Save current history log (all text in console)""" + title = _("Save history log") + self.sig_redirect_stdio_requested.emit(False) + filename, _selfilter = getsavefilename(self, title, + self.historylog_filename, "%s (*.log)" % _("History logs")) + self.sig_redirect_stdio_requested.emit(True) + if filename: + filename = osp.normpath(filename) + try: + encoding.write(to_text_string(self.get_text_with_eol()), + filename) + self.historylog_filename = filename + CONF.set('main', 'historylog_filename', filename) + except EnvironmentError: + pass + + #------ Basic keypress event handler + def on_enter(self, command): + """on_enter""" + self.execute_command(command) + + def execute_command(self, command): + self.execute.emit(command) + self.add_to_history(command) + self.new_input_line = True + + def on_new_line(self): + """On new input line""" + self.set_cursor_position('eof') + self.current_prompt_pos = self.get_position('cursor') + self.new_input_line = False + + @Slot() + def paste(self): + """Reimplemented slot to handle multiline paste action""" + if self.new_input_line: + self.on_new_line() + ConsoleBaseWidget.paste(self) + + def keyPressEvent(self, event): + """ + Reimplement Qt Method + Basic keypress event handler + (reimplemented in InternalShell to add more sophisticated features) + """ + if self.preprocess_keyevent(event): + # Event was accepted in self.preprocess_keyevent + return + self.postprocess_keyevent(event) + + def preprocess_keyevent(self, event): + """Pre-process keypress event: + return True if event is accepted, false otherwise""" + # Copy must be done first to be able to copy read-only text parts + # (otherwise, right below, we would remove selection + # if not on current line) + ctrl = event.modifiers() & Qt.ControlModifier + meta = event.modifiers() & Qt.MetaModifier # meta=ctrl in OSX + if event.key() == Qt.Key_C and \ + ((Qt.MetaModifier | Qt.ControlModifier) & event.modifiers()): + if meta and sys.platform == 'darwin': + self.interrupt() + elif ctrl: + self.copy() + event.accept() + return True + + if self.new_input_line and ( len(event.text()) or event.key() in \ + (Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right) ): + self.on_new_line() + + return False + + def postprocess_keyevent(self, event): + """Post-process keypress event: + in InternalShell, this is method is called when shell is ready""" + event, text, key, ctrl, shift = restore_keyevent(event) + + # Is cursor on the last line? and after prompt? + if len(text): + #XXX: Shouldn't it be: `if len(unicode(text).strip(os.linesep))` ? + if self.has_selected_text(): + self.check_selection() + self.restrict_cursor_position(self.current_prompt_pos, 'eof') + + cursor_position = self.get_position('cursor') + + if key in (Qt.Key_Return, Qt.Key_Enter): + if self.is_cursor_on_last_line(): + self._key_enter() + # add and run selection + else: + self.insert_text(self.get_selected_text(), at_end=True) + + elif key == Qt.Key_Insert and not shift and not ctrl: + self.setOverwriteMode(not self.overwriteMode()) + + elif key == Qt.Key_Delete: + if self.has_selected_text(): + self.check_selection() + self.remove_selected_text() + elif self.is_cursor_on_last_line(): + self.stdkey_clear() + + elif key == Qt.Key_Backspace: + self._key_backspace(cursor_position) + + elif key == Qt.Key_Tab: + self._key_tab() + + elif key == Qt.Key_Space and ctrl: + self._key_ctrl_space() + + elif key == Qt.Key_Left: + if self.current_prompt_pos == cursor_position: + # Avoid moving cursor on prompt + return + method = self.extend_selection_to_next if shift \ + else self.move_cursor_to_next + method('word' if ctrl else 'character', direction='left') + + elif key == Qt.Key_Right: + if self.is_cursor_at_end(): + return + method = self.extend_selection_to_next if shift \ + else self.move_cursor_to_next + method('word' if ctrl else 'character', direction='right') + + elif (key == Qt.Key_Home) or ((key == Qt.Key_Up) and ctrl): + self._key_home(shift, ctrl) + + elif (key == Qt.Key_End) or ((key == Qt.Key_Down) and ctrl): + self._key_end(shift, ctrl) + + elif key == Qt.Key_Up: + if not self.is_cursor_on_last_line(): + self.set_cursor_position('eof') + y_cursor = self.get_coordinates(cursor_position)[1] + y_prompt = self.get_coordinates(self.current_prompt_pos)[1] + if y_cursor > y_prompt: + self.stdkey_up(shift) + else: + self.browse_history(backward=True) + + elif key == Qt.Key_Down: + if not self.is_cursor_on_last_line(): + self.set_cursor_position('eof') + y_cursor = self.get_coordinates(cursor_position)[1] + y_end = self.get_coordinates('eol')[1] + if y_cursor < y_end: + self.stdkey_down(shift) + else: + self.browse_history(backward=False) + + elif key in (Qt.Key_PageUp, Qt.Key_PageDown): + #XXX: Find a way to do this programmatically instead of calling + # widget keyhandler (this won't work if the *event* is coming from + # the event queue - i.e. if the busy buffer is ever implemented) + ConsoleBaseWidget.keyPressEvent(self, event) + + elif key == Qt.Key_Escape and shift: + self.clear_line() + + elif key == Qt.Key_Escape: + self._key_escape() + + elif key == Qt.Key_L and ctrl: + self.clear_terminal() + + elif key == Qt.Key_V and ctrl: + self.paste() + + elif key == Qt.Key_X and ctrl: + self.cut() + + elif key == Qt.Key_Z and ctrl: + self.undo() + + elif key == Qt.Key_Y and ctrl: + self.redo() + + elif key == Qt.Key_A and ctrl: + self.selectAll() + + elif key == Qt.Key_Question and not self.has_selected_text(): + self._key_question(text) + + elif key == Qt.Key_ParenLeft and not self.has_selected_text(): + self._key_parenleft(text) + + elif key == Qt.Key_Period and not self.has_selected_text(): + self._key_period(text) + + elif len(text) and not self.isReadOnly(): + self.hist_wholeline = False + self.insert_text(text) + self._key_other(text) + + else: + # Let the parent widget handle the key press event + ConsoleBaseWidget.keyPressEvent(self, event) + + + #------ Key handlers + def _key_enter(self): + command = self.input_buffer + self.insert_text('\n', at_end=True) + self.on_enter(command) + self.flush() + def _key_other(self, text): + raise NotImplementedError + def _key_backspace(self, cursor_position): + raise NotImplementedError + def _key_tab(self): + raise NotImplementedError + def _key_ctrl_space(self): + raise NotImplementedError + def _key_home(self, shift, ctrl): + if self.is_cursor_on_last_line(): + self.stdkey_home(shift, ctrl, self.current_prompt_pos) + def _key_end(self, shift, ctrl): + if self.is_cursor_on_last_line(): + self.stdkey_end(shift, ctrl) + def _key_pageup(self): + raise NotImplementedError + def _key_pagedown(self): + raise NotImplementedError + def _key_escape(self): + raise NotImplementedError + def _key_question(self, text): + raise NotImplementedError + def _key_parenleft(self, text): + raise NotImplementedError + def _key_period(self, text): + raise NotImplementedError + + + #------ History Management + def load_history(self): + """Load history from a .py file in user home directory""" + if osp.isfile(self.history_filename): + rawhistory, _ = encoding.readlines(self.history_filename) + rawhistory = [line.replace('\n', '') for line in rawhistory] + if rawhistory[1] != self.INITHISTORY[1]: + rawhistory[1] = self.INITHISTORY[1] + else: + rawhistory = self.INITHISTORY + history = [line for line in rawhistory \ + if line and not line.startswith('#')] + + # Truncating history to X entries: + while len(history) >= MAX_LINES: + del history[0] + while rawhistory[0].startswith('#'): + del rawhistory[0] + del rawhistory[0] + + # Saving truncated history: + try: + encoding.writelines(rawhistory, self.history_filename) + except EnvironmentError: + pass + + return history + + #------ Simulation standards input/output + def write_error(self, text): + """Simulate stderr""" + self.flush() + self.write(text, flush=True, error=True) + if get_debug_level(): + STDERR.write(text) + + def write(self, text, flush=False, error=False, prompt=False): + """Simulate stdout and stderr""" + if prompt: + self.flush() + if not is_string(text): + # This test is useful to discriminate QStrings from decoded str + text = to_text_string(text) + self.__buffer.append(text) + ts = time.time() + if flush or prompt: + self.flush(error=error, prompt=prompt) + elif ts - self.__timestamp > 0.05: + self.flush(error=error) + self.__timestamp = ts + # Timer to flush strings cached by last write() operation in series + self.__flushtimer.start(50) + + def flush(self, error=False, prompt=False): + """Flush buffer, write text to console""" + # Fix for spyder-ide/spyder#2452 + if PY3: + try: + text = "".join(self.__buffer) + except TypeError: + text = b"".join(self.__buffer) + try: + text = text.decode( locale.getdefaultlocale()[1] ) + except: + pass + else: + text = "".join(self.__buffer) + + self.__buffer = [] + self.insert_text(text, at_end=True, error=error, prompt=prompt) + + # The lines below are causing a hard crash when Qt generates + # internal warnings. We replaced them instead for self.update(), + # which prevents the crash. + # See spyder-ide/spyder#10893 + # QCoreApplication.processEvents() + # self.repaint() + self.update() + + # Clear input buffer: + self.new_input_line = True + + + #------ Text Insertion + def insert_text(self, text, at_end=False, error=False, prompt=False): + """ + Insert text at the current cursor position + or at the end of the command line + """ + if at_end: + # Insert text at the end of the command line + self.append_text_to_shell(text, error, prompt) + else: + # Insert text at current cursor position + ConsoleBaseWidget.insert_text(self, text) + + + #------ Re-implemented Qt Methods + def focusNextPrevChild(self, next): + """ + Reimplemented to stop Tab moving to the next window + """ + if next: + return False + return ConsoleBaseWidget.focusNextPrevChild(self, next) + + + #------ Drag and drop + def dragEnterEvent(self, event): + """Drag and Drop - Enter event""" + event.setAccepted(event.mimeData().hasFormat("text/plain")) + + def dragMoveEvent(self, event): + """Drag and Drop - Move event""" + if (event.mimeData().hasFormat("text/plain")): + event.setDropAction(Qt.MoveAction) + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + """Drag and Drop - Drop event""" + if (event.mimeData().hasFormat("text/plain")): + text = to_text_string(event.mimeData().text()) + if self.new_input_line: + self.on_new_line() + self.insert_text(text, at_end=True) + self.setFocus() + event.setDropAction(Qt.MoveAction) + event.accept() + else: + event.ignore() + + def drop_pathlist(self, pathlist): + """Drop path list""" + raise NotImplementedError + + +# Example how to debug complex interclass call chains: +# +# from spyder.utils.debug import log_methods_calls +# log_methods_calls('log.log', ShellBaseWidget) + +class PythonShellWidget(TracebackLinksMixin, ShellBaseWidget, + GetHelpMixin): + """Python shell widget""" + QT_CLASS = ShellBaseWidget + INITHISTORY = ['# -*- coding: utf-8 -*-', + '# *** Spyder Python Console History Log ***',] + SEPARATOR = '%s##---(%s)---' % (os.linesep*2, time.ctime()) + + # --- Signals + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = Signal(str) + + # Signal + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Example `{'name': str, 'ignore_unknown': bool}`. + """ + + def __init__(self, parent, history_filename, profile=False, initial_message=None): + ShellBaseWidget.__init__(self, parent, history_filename, + profile=profile, + initial_message=initial_message) + TracebackLinksMixin.__init__(self) + GetHelpMixin.__init__(self) + + # Local shortcuts + self.shortcuts = self.create_shortcuts() + + def create_shortcuts(self): + array_inline = CONF.config_shortcut( + lambda: self.enter_array_inline(), + context='array_builder', + name='enter array inline', + parent=self) + array_table = CONF.config_shortcut( + lambda: self.enter_array_table(), + context='array_builder', + name='enter array table', + parent=self) + inspectsc = CONF.config_shortcut( + self.inspect_current_object, + context='Console', + name='Inspect current object', + parent=self) + clear_line_sc = CONF.config_shortcut( + self.clear_line, + context='Console', + name="Clear line", + parent=self, + ) + clear_shell_sc = CONF.config_shortcut( + self.clear_terminal, + context='Console', + name="Clear shell", + parent=self, + ) + + return [inspectsc, array_inline, array_table, clear_line_sc, + clear_shell_sc] + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + #------ Context menu + def setup_context_menu(self): + """Reimplements ShellBaseWidget method""" + ShellBaseWidget.setup_context_menu(self) + self.copy_without_prompts_action = create_action( + self, + _("Copy without prompts"), + icon=ima.icon('copywop'), + triggered=self.copy_without_prompts) + + clear_line_action = create_action( + self, + _("Clear line"), + QKeySequence(CONF.get_shortcut('console', 'Clear line')), + icon=ima.icon('editdelete'), + tip=_("Clear line"), + triggered=self.clear_line) + + clear_action = create_action( + self, + _("Clear shell"), + QKeySequence(CONF.get_shortcut('console', 'Clear shell')), + icon=ima.icon('editclear'), + tip=_("Clear shell contents ('cls' command)"), + triggered=self.clear_terminal) + + add_actions(self.menu, (self.copy_without_prompts_action, + clear_line_action, clear_action)) + + def contextMenuEvent(self, event): + """Reimplements ShellBaseWidget method""" + state = self.has_selected_text() + self.copy_without_prompts_action.setEnabled(state) + ShellBaseWidget.contextMenuEvent(self, event) + + @Slot() + def copy_without_prompts(self): + """Copy text to clipboard without prompts""" + text = self.get_selected_text() + lines = text.split(os.linesep) + for index, line in enumerate(lines): + if line.startswith('>>> ') or line.startswith('... '): + lines[index] = line[4:] + text = os.linesep.join(lines) + QApplication.clipboard().setText(text) + + + #------ Key handlers + def postprocess_keyevent(self, event): + """Process keypress event""" + ShellBaseWidget.postprocess_keyevent(self, event) + + def _key_other(self, text): + """1 character key""" + if self.is_completion_widget_visible(): + self.completion_text += text + + def _key_backspace(self, cursor_position): + """Action for Backspace key""" + if self.has_selected_text(): + self.check_selection() + self.remove_selected_text() + elif self.current_prompt_pos == cursor_position: + # Avoid deleting prompt + return + elif self.is_cursor_on_last_line(): + self.stdkey_backspace() + if self.is_completion_widget_visible(): + # Removing only last character because if there was a selection + # the completion widget would have been canceled + self.completion_text = self.completion_text[:-1] + + def _key_tab(self): + """Action for TAB key""" + if self.is_cursor_on_last_line(): + empty_line = not self.get_current_line_to_cursor().strip() + if empty_line: + self.stdkey_tab() + else: + self.show_code_completion() + + def _key_ctrl_space(self): + """Action for Ctrl+Space""" + if not self.is_completion_widget_visible(): + self.show_code_completion() + + def _key_pageup(self): + """Action for PageUp key""" + pass + + def _key_pagedown(self): + """Action for PageDown key""" + pass + + def _key_escape(self): + """Action for ESCAPE key""" + if self.is_completion_widget_visible(): + self.hide_completion_widget() + + def _key_question(self, text): + """Action for '?'""" + if self.get_current_line_to_cursor(): + last_obj = self.get_last_obj() + if last_obj and not last_obj.isdigit(): + self.show_object_info(last_obj) + self.insert_text(text) + # In case calltip and completion are shown at the same time: + if self.is_completion_widget_visible(): + self.completion_text += '?' + + def _key_parenleft(self, text): + """Action for '('""" + self.hide_completion_widget() + if self.get_current_line_to_cursor(): + last_obj = self.get_last_obj() + if last_obj and not last_obj.isdigit(): + self.insert_text(text) + self.show_object_info(last_obj, call=True) + return + self.insert_text(text) + + def _key_period(self, text): + """Action for '.'""" + self.insert_text(text) + if self.codecompletion_auto: + # Enable auto-completion only if last token isn't a float + last_obj = self.get_last_obj() + if last_obj and not last_obj.isdigit(): + self.show_code_completion() + + + #------ Paste + def paste(self): + """Reimplemented slot to handle multiline paste action""" + text = to_text_string(QApplication.clipboard().text()) + if len(text.splitlines()) > 1: + # Multiline paste + if self.new_input_line: + self.on_new_line() + self.remove_selected_text() # Remove selection, eventually + end = self.get_current_line_from_cursor() + lines = self.get_current_line_to_cursor() + text + end + self.clear_line() + self.execute_lines(lines) + self.move_cursor(-len(end)) + else: + # Standard paste + ShellBaseWidget.paste(self) + + # ------ Code Completion / Calltips + # Methods implemented in child class: + # (e.g. InternalShell) + def get_dir(self, objtxt): + """Return dir(object)""" + raise NotImplementedError + + def get_globals_keys(self): + """Return shell globals() keys""" + raise NotImplementedError + + def get_cdlistdir(self): + """Return shell current directory list dir""" + raise NotImplementedError + + def iscallable(self, objtxt): + """Is object callable?""" + raise NotImplementedError + + def get_arglist(self, objtxt): + """Get func/method argument list""" + raise NotImplementedError + + def get__doc__(self, objtxt): + """Get object __doc__""" + raise NotImplementedError + + def get_doc(self, objtxt): + """Get object documentation dictionary""" + raise NotImplementedError + + def get_source(self, objtxt): + """Get object source""" + raise NotImplementedError + + def is_defined(self, objtxt, force_import=False): + """Return True if object is defined""" + raise NotImplementedError + + def show_completion_widget(self, textlist): + """Show completion widget""" + self.completion_widget.show_list( + textlist, automatic=False, position=None) + + def hide_completion_widget(self, focus_to_parent=True): + """Hide completion widget""" + self.completion_widget.hide(focus_to_parent=focus_to_parent) + + def show_completion_list(self, completions, completion_text=""): + """Display the possible completions""" + if not completions: + return + if not isinstance(completions[0], tuple): + completions = [(c, '') for c in completions] + if len(completions) == 1 and completions[0][0] == completion_text: + return + self.completion_text = completion_text + # Sorting completion list (entries starting with underscore are + # put at the end of the list): + underscore = set([(comp, t) for (comp, t) in completions + if comp.startswith('_')]) + + completions = sorted(set(completions) - underscore, + key=lambda x: str_lower(x[0])) + completions += sorted(underscore, key=lambda x: str_lower(x[0])) + self.show_completion_widget(completions) + + def show_code_completion(self): + """Display a completion list based on the current line""" + # Note: unicode conversion is needed only for ExternalShellBase + text = to_text_string(self.get_current_line_to_cursor()) + last_obj = self.get_last_obj() + if not text: + return + + obj_dir = self.get_dir(last_obj) + if last_obj and obj_dir and text.endswith('.'): + self.show_completion_list(obj_dir) + return + + # Builtins and globals + if not text.endswith('.') and last_obj \ + and re.match(r'[a-zA-Z_0-9]*$', last_obj): + b_k_g = dir(builtins)+self.get_globals_keys()+keyword.kwlist + for objname in b_k_g: + if objname.startswith(last_obj) and objname != last_obj: + self.show_completion_list(b_k_g, completion_text=last_obj) + return + else: + return + + # Looking for an incomplete completion + if last_obj is None: + last_obj = text + dot_pos = last_obj.rfind('.') + if dot_pos != -1: + if dot_pos == len(last_obj)-1: + completion_text = "" + else: + completion_text = last_obj[dot_pos+1:] + last_obj = last_obj[:dot_pos] + completions = self.get_dir(last_obj) + if completions is not None: + self.show_completion_list(completions, + completion_text=completion_text) + return + + # Looking for ' or ": filename completion + q_pos = max([text.rfind("'"), text.rfind('"')]) + if q_pos != -1: + completions = self.get_cdlistdir() + if completions: + self.show_completion_list(completions, + completion_text=text[q_pos+1:]) + return + + #------ Drag'n Drop + def drop_pathlist(self, pathlist): + """Drop path list""" + if pathlist: + files = ["r'%s'" % path for path in pathlist] + if len(files) == 1: + text = files[0] + else: + text = "[" + ", ".join(files) + "]" + if self.new_input_line: + self.on_new_line() + self.insert_text(text) + self.setFocus() + + +class TerminalWidget(ShellBaseWidget): + """ + Terminal widget + """ + COM = 'rem' if os.name == 'nt' else '#' + INITHISTORY = ['%s *** Spyder Terminal History Log ***' % COM, COM,] + SEPARATOR = '%s%s ---(%s)---' % (os.linesep*2, COM, time.ctime()) + + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = Signal(str) + + def __init__(self, parent, history_filename, profile=False): + ShellBaseWidget.__init__(self, parent, history_filename, profile) + + #------ Key handlers + def _key_other(self, text): + """1 character key""" + pass + + def _key_backspace(self, cursor_position): + """Action for Backspace key""" + if self.has_selected_text(): + self.check_selection() + self.remove_selected_text() + elif self.current_prompt_pos == cursor_position: + # Avoid deleting prompt + return + elif self.is_cursor_on_last_line(): + self.stdkey_backspace() + + def _key_tab(self): + """Action for TAB key""" + if self.is_cursor_on_last_line(): + self.stdkey_tab() + + def _key_ctrl_space(self): + """Action for Ctrl+Space""" + pass + + def _key_escape(self): + """Action for ESCAPE key""" + self.clear_line() + + def _key_question(self, text): + """Action for '?'""" + self.insert_text(text) + + def _key_parenleft(self, text): + """Action for '('""" + self.insert_text(text) + + def _key_period(self, text): + """Action for '.'""" + self.insert_text(text) + + + #------ Drag'n Drop + def drop_pathlist(self, pathlist): + """Drop path list""" + if pathlist: + files = ['"%s"' % path for path in pathlist] + if len(files) == 1: + text = files[0] + else: + text = " ".join(files) + if self.new_input_line: + self.on_new_line() + self.insert_text(text) + self.setFocus() diff --git a/spyder/plugins/editor/extensions/docstring.py b/spyder/plugins/editor/extensions/docstring.py index a5aca741676..e0205e63ba0 100644 --- a/spyder/plugins/editor/extensions/docstring.py +++ b/spyder/plugins/editor/extensions/docstring.py @@ -1,1034 +1,1034 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Generate Docstring.""" - -# Standard library imports -import re -from collections import OrderedDict - -# Third party imports -from qtpy.QtGui import QTextCursor -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QMenu - -# Local imports -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string - - -def is_start_of_function(text): - """Return True if text is the beginning of the function definition.""" - if isinstance(text, str): - function_prefix = ['def', 'async def'] - text = text.lstrip() - - for prefix in function_prefix: - if text.startswith(prefix): - return True - - return False - - -def get_indent(text): - """Get indent of text. - - https://stackoverflow.com/questions/2268532/grab-a-lines-whitespace- - indention-with-python - """ - indent = '' - - ret = re.match(r'(\s*)', text) - if ret: - indent = ret.group(1) - - return indent - - -def is_in_scope_forward(text): - """Check if the next empty line could be part of the definition.""" - text = text.replace(r"\"", "").replace(r"\'", "") - scopes = ["'''", '"""', "'", '"'] - indices = [10**6] * 4 # Limits function def length to 10**6 - for i in range(len(scopes)): - if scopes[i] in text: - indices[i] = text.index(scopes[i]) - if min(indices) == 10**6: - return (text.count(")") != text.count("(") or - text.count("]") != text.count("[") or - text.count("}") != text.count("{")) - s = scopes[indices.index(min(indices))] - p = indices[indices.index(min(indices))] - ls = len(s) - if s in text[p + ls:]: - text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] - return is_in_scope_forward(text) - elif ls == 3: - text = text[:p] - return (text.count(")") != text.count("(") or - text.count("]") != text.count("[") or - text.count("}") != text.count("{")) - else: - return False - - -def is_tuple_brackets(text): - """Check if the return type is a tuple.""" - scopes = ["(", "[", "{"] - complements = [")", "]", "}"] - indices = [10**6] * 4 # Limits return type length to 10**6 - for i in range(len(scopes)): - if scopes[i] in text: - indices[i] = text.index(scopes[i]) - if min(indices) == 10**6: - return "," in text - s = complements[indices.index(min(indices))] - p = indices[indices.index(min(indices))] - if s in text[p + 1:]: - text = text[:p] + text[p + 1:][text[p + 1:].index(s) + 1:] - return is_tuple_brackets(text) - else: - return False - - -def is_tuple_strings(text): - """Check if the return type is a string.""" - text = text.replace(r"\"", "").replace(r"\'", "") - scopes = ["'''", '"""', "'", '"'] - indices = [10**6] * 4 # Limits return type length to 10**6 - for i in range(len(scopes)): - if scopes[i] in text: - indices[i] = text.index(scopes[i]) - if min(indices) == 10**6: - return is_tuple_brackets(text) - s = scopes[indices.index(min(indices))] - p = indices[indices.index(min(indices))] - ls = len(s) - if s in text[p + ls:]: - text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] - return is_tuple_strings(text) - else: - return False - - -def is_in_scope_backward(text): - """Check if the next empty line could be part of the definition.""" - return is_in_scope_forward( - text.replace(r"\"", "").replace(r"\'", "")[::-1]) - - -class DocstringWriterExtension(object): - """Class for insert docstring template automatically.""" - - def __init__(self, code_editor): - """Initialize and Add code_editor to the variable.""" - self.code_editor = code_editor - self.quote3 = '"""' - self.quote3_other = "'''" - self.line_number_cursor = None - - @staticmethod - def is_beginning_triple_quotes(text): - """Return True if there are only triple quotes in text.""" - docstring_triggers = ['"""', 'r"""', "'''", "r'''"] - if text.lstrip() in docstring_triggers: - return True - - return False - - def is_end_of_function_definition(self, text, line_number): - """Return True if text is the end of the function definition.""" - text_without_whitespace = "".join(text.split()) - if ( - text_without_whitespace.endswith("):") or - text_without_whitespace.endswith("]:") or - (text_without_whitespace.endswith(":") and - "->" in text_without_whitespace) - ): - return True - elif text_without_whitespace.endswith(":") and line_number > 1: - complete_text = text_without_whitespace - document = self.code_editor.document() - cursor = QTextCursor( - document.findBlockByNumber(line_number - 2)) # previous line - for i in range(line_number - 2, -1, -1): - txt = "".join(str(cursor.block().text()).split()) - if txt.endswith("\\") or is_in_scope_backward(complete_text): - if txt.endswith("\\"): - txt = txt[:-1] - complete_text = txt + complete_text - else: - break - if i != 0: - cursor.movePosition(QTextCursor.PreviousBlock) - if is_start_of_function(complete_text): - return ( - complete_text.endswith("):") or - complete_text.endswith("]:") or - (complete_text.endswith(":") and - "->" in complete_text) - ) - else: - return False - else: - return False - - def get_function_definition_from_first_line(self): - """Get func def when the cursor is located on the first def line.""" - document = self.code_editor.document() - cursor = QTextCursor( - document.findBlockByNumber(self.line_number_cursor - 1)) - - func_text = '' - func_indent = '' - - is_first_line = True - line_number = cursor.blockNumber() + 1 - - number_of_lines = self.code_editor.blockCount() - remain_lines = number_of_lines - line_number + 1 - number_of_lines_of_function = 0 - - for __ in range(min(remain_lines, 20)): - cur_text = to_text_string(cursor.block().text()).rstrip() - - if is_first_line: - if not is_start_of_function(cur_text): - return None - - func_indent = get_indent(cur_text) - is_first_line = False - else: - cur_indent = get_indent(cur_text) - if cur_indent <= func_indent and cur_text.strip() != '': - return None - if is_start_of_function(cur_text): - return None - if (cur_text.strip() == '' and - not is_in_scope_forward(func_text)): - return None - - if len(cur_text) > 0 and cur_text[-1] == '\\': - cur_text = cur_text[:-1] - - func_text += cur_text - number_of_lines_of_function += 1 - - if self.is_end_of_function_definition( - cur_text, line_number + number_of_lines_of_function - 1): - return func_text, number_of_lines_of_function - - cursor.movePosition(QTextCursor.NextBlock) - - return None - - def get_function_definition_from_below_last_line(self): - """Get func def when the cursor is located below the last def line.""" - cursor = self.code_editor.textCursor() - func_text = '' - is_first_line = True - line_number = cursor.blockNumber() + 1 - number_of_lines_of_function = 0 - - for __ in range(min(line_number, 20)): - if cursor.block().blockNumber() == 0: - return None - - cursor.movePosition(QTextCursor.PreviousBlock) - prev_text = to_text_string(cursor.block().text()).rstrip() - - if is_first_line: - if not self.is_end_of_function_definition( - prev_text, line_number - 1): - return None - is_first_line = False - elif self.is_end_of_function_definition( - prev_text, line_number - number_of_lines_of_function - 1): - return None - - if len(prev_text) > 0 and prev_text[-1] == '\\': - prev_text = prev_text[:-1] - - func_text = prev_text + func_text - - number_of_lines_of_function += 1 - if is_start_of_function(prev_text): - return func_text, number_of_lines_of_function - - return None - - def get_function_body(self, func_indent): - """Get the function body text.""" - cursor = self.code_editor.textCursor() - line_number = cursor.blockNumber() + 1 - number_of_lines = self.code_editor.blockCount() - body_list = [] - - for __ in range(number_of_lines - line_number + 1): - text = to_text_string(cursor.block().text()) - text_indent = get_indent(text) - - if text.strip() == '': - pass - elif len(text_indent) <= len(func_indent): - break - - body_list.append(text) - - cursor.movePosition(QTextCursor.NextBlock) - - return '\n'.join(body_list) - - def write_docstring(self): - """Write docstring to editor.""" - line_to_cursor = self.code_editor.get_text('sol', 'cursor') - if self.is_beginning_triple_quotes(line_to_cursor): - cursor = self.code_editor.textCursor() - prev_pos = cursor.position() - - quote = line_to_cursor[-1] - docstring_type = CONF.get('editor', 'docstring_type') - docstring = self._generate_docstring(docstring_type, quote) - - if docstring: - self.code_editor.insert_text(docstring) - - cursor = self.code_editor.textCursor() - cursor.setPosition(prev_pos, QTextCursor.KeepAnchor) - cursor.movePosition(QTextCursor.NextBlock) - cursor.movePosition(QTextCursor.EndOfLine, - QTextCursor.KeepAnchor) - cursor.clearSelection() - self.code_editor.setTextCursor(cursor) - return True - - return False - - def write_docstring_at_first_line_of_function(self): - """Write docstring to editor at mouse position.""" - result = self.get_function_definition_from_first_line() - editor = self.code_editor - if result: - func_text, number_of_line_func = result - line_number_function = (self.line_number_cursor + - number_of_line_func - 1) - - cursor = editor.textCursor() - line_number_cursor = cursor.blockNumber() + 1 - offset = line_number_function - line_number_cursor - if offset > 0: - for __ in range(offset): - cursor.movePosition(QTextCursor.NextBlock) - else: - for __ in range(abs(offset)): - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.MoveAnchor) - editor.setTextCursor(cursor) - - indent = get_indent(func_text) - editor.insert_text('\n{}{}"""'.format(indent, editor.indent_chars)) - self.write_docstring() - - def write_docstring_for_shortcut(self): - """Write docstring to editor by shortcut of code editor.""" - # cursor placed below function definition - result = self.get_function_definition_from_below_last_line() - if result is not None: - __, number_of_lines_of_function = result - cursor = self.code_editor.textCursor() - for __ in range(number_of_lines_of_function): - cursor.movePosition(QTextCursor.PreviousBlock) - - self.code_editor.setTextCursor(cursor) - - cursor = self.code_editor.textCursor() - self.line_number_cursor = cursor.blockNumber() + 1 - - self.write_docstring_at_first_line_of_function() - - def _generate_docstring(self, doc_type, quote): - """Generate docstring.""" - docstring = None - - self.quote3 = quote * 3 - if quote == '"': - self.quote3_other = "'''" - else: - self.quote3_other = '"""' - - result = self.get_function_definition_from_below_last_line() - - if result: - func_def, __ = result - func_info = FunctionInfo() - func_info.parse_def(func_def) - - if func_info.has_info: - func_body = self.get_function_body(func_info.func_indent) - if func_body: - func_info.parse_body(func_body) - - if doc_type == 'Numpydoc': - docstring = self._generate_numpy_doc(func_info) - elif doc_type == 'Googledoc': - docstring = self._generate_google_doc(func_info) - elif doc_type == "Sphinxdoc": - docstring = self._generate_sphinx_doc(func_info) - - return docstring - - def _generate_numpy_doc(self, func_info): - """Generate a docstring of numpy type.""" - numpy_doc = '' - - arg_names = func_info.arg_name_list - arg_types = func_info.arg_type_list - arg_values = func_info.arg_value_list - - if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): - del arg_names[0] - del arg_types[0] - del arg_values[0] - - indent1 = func_info.func_indent + self.code_editor.indent_chars - indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 - - numpy_doc += '\n{}\n'.format(indent1) - - if len(arg_names) > 0: - numpy_doc += '\n{}Parameters'.format(indent1) - numpy_doc += '\n{}----------\n'.format(indent1) - - arg_text = '' - for arg_name, arg_type, arg_value in zip(arg_names, arg_types, - arg_values): - arg_text += '{}{} : '.format(indent1, arg_name) - if arg_type: - arg_text += '{}'.format(arg_type) - else: - arg_text += 'TYPE' - - if arg_value: - arg_text += ', optional' - - arg_text += '\n{}DESCRIPTION.'.format(indent2) - - if arg_value: - arg_value = arg_value.replace(self.quote3, self.quote3_other) - arg_text += ' The default is {}.'.format(arg_value) - - arg_text += '\n' - - numpy_doc += arg_text - - if func_info.raise_list: - numpy_doc += '\n{}Raises'.format(indent1) - numpy_doc += '\n{}------'.format(indent1) - for raise_type in func_info.raise_list: - numpy_doc += '\n{}{}'.format(indent1, raise_type) - numpy_doc += '\n{}DESCRIPTION.'.format(indent2) - numpy_doc += '\n' - - numpy_doc += '\n' - if func_info.has_yield: - header = '{0}Yields\n{0}------\n'.format(indent1) - else: - header = '{0}Returns\n{0}-------\n'.format(indent1) - - return_type_annotated = func_info.return_type_annotated - if return_type_annotated: - return_section = '{}{}{}'.format(header, indent1, - return_type_annotated) - return_section += '\n{}DESCRIPTION.'.format(indent2) - else: - return_element_type = indent1 + '{return_type}\n' + indent2 + \ - 'DESCRIPTION.' - placeholder = return_element_type.format(return_type='TYPE') - return_element_name = indent1 + '{return_name} : ' + \ - placeholder.lstrip() - - try: - return_section = self._generate_docstring_return_section( - func_info.return_value_in_body, header, - return_element_name, return_element_type, placeholder, - indent1) - except (ValueError, IndexError): - return_section = '{}{}None.'.format(header, indent1) - - numpy_doc += return_section - numpy_doc += '\n\n{}{}'.format(indent1, self.quote3) - - return numpy_doc - - def _generate_google_doc(self, func_info): - """Generate a docstring of google type.""" - google_doc = '' - - arg_names = func_info.arg_name_list - arg_types = func_info.arg_type_list - arg_values = func_info.arg_value_list - - if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): - del arg_names[0] - del arg_types[0] - del arg_values[0] - - indent1 = func_info.func_indent + self.code_editor.indent_chars - indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 - - google_doc += '\n{}\n'.format(indent1) - - if len(arg_names) > 0: - google_doc += '\n{0}Args:\n'.format(indent1) - - arg_text = '' - for arg_name, arg_type, arg_value in zip(arg_names, arg_types, - arg_values): - arg_text += '{}{} '.format(indent2, arg_name) - - arg_text += '(' - if arg_type: - arg_text += '{}'.format(arg_type) - else: - arg_text += 'TYPE' - - if arg_value: - arg_text += ', optional' - arg_text += '):' - - arg_text += ' DESCRIPTION.' - - if arg_value: - arg_value = arg_value.replace(self.quote3, self.quote3_other) - arg_text += ' Defaults to {}.\n'.format(arg_value) - else: - arg_text += '\n' - - google_doc += arg_text - - if func_info.raise_list: - google_doc += '\n{0}Raises:'.format(indent1) - for raise_type in func_info.raise_list: - google_doc += '\n{}{}'.format(indent2, raise_type) - google_doc += ': DESCRIPTION.' - google_doc += '\n' - - google_doc += '\n' - if func_info.has_yield: - header = '{}Yields:\n'.format(indent1) - else: - header = '{}Returns:\n'.format(indent1) - - return_type_annotated = func_info.return_type_annotated - if return_type_annotated: - return_section = '{}{}{}: DESCRIPTION.'.format( - header, indent2, return_type_annotated) - else: - return_element_type = indent2 + '{return_type}: DESCRIPTION.' - placeholder = return_element_type.format(return_type='TYPE') - return_element_name = indent2 + '{return_name} ' + \ - '(TYPE): DESCRIPTION.' - - try: - return_section = self._generate_docstring_return_section( - func_info.return_value_in_body, header, - return_element_name, return_element_type, placeholder, - indent2) - except (ValueError, IndexError): - return_section = '{}{}None.'.format(header, indent2) - - google_doc += return_section - google_doc += '\n\n{}{}'.format(indent1, self.quote3) - - return google_doc - - def _generate_sphinx_doc(self, func_info): - """Generate a docstring of sphinx type.""" - sphinx_doc = '' - - arg_names = func_info.arg_name_list - arg_types = func_info.arg_type_list - arg_values = func_info.arg_value_list - - if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): - del arg_names[0] - del arg_types[0] - del arg_values[0] - - indent1 = func_info.func_indent + self.code_editor.indent_chars - - sphinx_doc += '\n{}\n'.format(indent1) - - arg_text = '' - for arg_name, arg_type, arg_value in zip(arg_names, arg_types, - arg_values): - arg_text += '{}:param {}: DESCRIPTION'.format(indent1, arg_name) - - if arg_value: - arg_value = arg_value.replace(self.quote3, self.quote3_other) - arg_text += ', defaults to {}\n'.format(arg_value) - else: - arg_text += '\n' - - arg_text += '{}:type {}: '.format(indent1, arg_name) - - if arg_type: - arg_text += '{}'.format(arg_type) - else: - arg_text += 'TYPE' - - if arg_value: - arg_text += ', optional' - arg_text += '\n' - - sphinx_doc += arg_text - - if func_info.raise_list: - for raise_type in func_info.raise_list: - sphinx_doc += '{}:raises {}: DESCRIPTION\n'.format(indent1, - raise_type) - - if func_info.has_yield: - header = '{}:yield:'.format(indent1) - else: - header = '{}:return:'.format(indent1) - - return_type_annotated = func_info.return_type_annotated - if return_type_annotated: - return_section = '{} DESCRIPTION\n'.format(header) - return_section += '{}:rtype: {}'.format(indent1, - return_type_annotated) - else: - return_section = '{} DESCRIPTION\n'.format(header) - return_section += '{}:rtype: TYPE'.format(indent1) - - sphinx_doc += return_section - sphinx_doc += '\n\n{}{}'.format(indent1, self.quote3) - - return sphinx_doc - - @staticmethod - def find_top_level_bracket_locations(string_toparse): - """Get the locations of top-level brackets in a string.""" - bracket_stack = [] - replace_args_list = [] - bracket_type = None - literal_type = '' - brackets = {'(': ')', '[': ']', '{': '}'} - for idx, character in enumerate(string_toparse): - if (not bracket_stack and character in brackets.keys() - or character == bracket_type): - bracket_stack.append(idx) - bracket_type = character - elif bracket_type and character == brackets[bracket_type]: - begin_idx = bracket_stack.pop() - if not bracket_stack: - if not literal_type: - if bracket_type == '(': - literal_type = '(None)' - elif bracket_type == '[': - literal_type = '[list]' - elif bracket_type == '{': - if idx - begin_idx <= 1: - literal_type = '{dict}' - else: - literal_type = '{set}' - replace_args_list.append( - (string_toparse[begin_idx:idx + 1], - literal_type, 1)) - bracket_type = None - literal_type = '' - elif len(bracket_stack) == 1: - if bracket_type == '(' and character == ',': - literal_type = '(tuple)' - elif bracket_type == '{' and character == ':': - literal_type = '{dict}' - elif bracket_type == '(' and character == ':': - literal_type = '[slice]' - - if bracket_stack: - raise IndexError('Bracket mismatch') - for replace_args in replace_args_list: - string_toparse = string_toparse.replace(*replace_args) - return string_toparse - - @staticmethod - def parse_return_elements(return_vals_group, return_element_name, - return_element_type, placeholder): - """Return the appropriate text for a group of return elements.""" - all_eq = (return_vals_group.count(return_vals_group[0]) - == len(return_vals_group)) - if all([{'[list]', '(tuple)', '{dict}', '{set}'}.issuperset( - return_vals_group)]) and all_eq: - return return_element_type.format( - return_type=return_vals_group[0][1:-1]) - # Output placeholder if special Python chars present in name - py_chars = {' ', '+', '-', '*', '/', '%', '@', '<', '>', '&', '|', '^', - '~', '=', ',', ':', ';', '#', '(', '[', '{', '}', ']', - ')', } - if any([any([py_char in return_val for py_char in py_chars]) - for return_val in return_vals_group]): - return placeholder - # Output str type and no name if only string literals - if all(['"' in return_val or '\'' in return_val - for return_val in return_vals_group]): - return return_element_type.format(return_type='str') - # Output bool type and no name if only bool literals - if {'True', 'False'}.issuperset(return_vals_group): - return return_element_type.format(return_type='bool') - # Output numeric types and no name if only numeric literals - try: - [float(return_val) for return_val in return_vals_group] - num_not_int = 0 - for return_val in return_vals_group: - try: - int(return_val) - except ValueError: # If not an integer (EAFP) - num_not_int = num_not_int + 1 - if num_not_int == 0: - return return_element_type.format(return_type='int') - elif num_not_int == len(return_vals_group): - return return_element_type.format(return_type='float') - else: - return return_element_type.format(return_type='numeric') - except ValueError: # Not a numeric if float conversion didn't work - pass - # If names are not equal, don't contain "." or are a builtin - if ({'self', 'cls', 'None'}.isdisjoint(return_vals_group) and all_eq - and all(['.' not in return_val - for return_val in return_vals_group])): - return return_element_name.format(return_name=return_vals_group[0]) - return placeholder - - def _generate_docstring_return_section(self, return_vals, header, - return_element_name, - return_element_type, - placeholder, indent): - """Generate the Returns section of a function/method docstring.""" - # If all return values are None, return none - non_none_vals = [return_val for return_val in return_vals - if return_val and return_val != 'None'] - if not non_none_vals: - return header + indent + 'None.' - - # Get only values with matching brackets that can be cleaned up - non_none_vals = [return_val.strip(' ()\t\n').rstrip(',') - for return_val in non_none_vals] - non_none_vals = [re.sub('([\"\'])(?:(?=(\\\\?))\\2.)*?\\1', - '"string"', return_val) - for return_val in non_none_vals] - unambiguous_vals = [] - for return_val in non_none_vals: - try: - cleaned_val = self.find_top_level_bracket_locations(return_val) - except IndexError: - continue - unambiguous_vals.append(cleaned_val) - if not unambiguous_vals: - return header + placeholder - - # If remaining are a mix of tuples and not, return single placeholder - single_vals, tuple_vals = [], [] - for return_val in unambiguous_vals: - (tuple_vals.append(return_val) if ',' in return_val - else single_vals.append(return_val)) - if single_vals and tuple_vals: - return header + placeholder - - # If return values are tuples of different length, return a placeholder - if tuple_vals: - num_elements = [return_val.count(',') + 1 - for return_val in tuple_vals] - if num_elements.count(num_elements[0]) != len(num_elements): - return header + placeholder - num_elements = num_elements[0] - else: - num_elements = 1 - - # If all have the same len but some ambiguous return that placeholders - if len(unambiguous_vals) != len(non_none_vals): - return header + '\n'.join( - [placeholder for __ in range(num_elements)]) - - # Handle tuple (or single) values position by position - return_vals_grouped = zip(*[ - [return_element.strip() for return_element in - return_val.split(',')] - for return_val in unambiguous_vals]) - return_elements_out = [] - for return_vals_group in return_vals_grouped: - return_elements_out.append( - self.parse_return_elements(return_vals_group, - return_element_name, - return_element_type, - placeholder)) - - return header + '\n'.join(return_elements_out) - - -class FunctionInfo(object): - """Parse function definition text.""" - - def __init__(self): - """.""" - self.has_info = False - self.func_text = '' - self.args_text = '' - self.func_indent = '' - self.arg_name_list = [] - self.arg_type_list = [] - self.arg_value_list = [] - self.return_type_annotated = None - self.return_value_in_body = [] - self.raise_list = None - self.has_yield = False - - @staticmethod - def is_char_in_pairs(pos_char, pairs): - """Return True if the character is in pairs of brackets or quotes.""" - for pos_left, pos_right in pairs.items(): - if pos_left < pos_char < pos_right: - return True - - return False - - @staticmethod - def _find_quote_position(text): - """Return the start and end position of pairs of quotes.""" - pos = {} - is_found_left_quote = False - - for idx, character in enumerate(text): - if is_found_left_quote is False: - if character == "'" or character == '"': - is_found_left_quote = True - quote = character - left_pos = idx - else: - if character == quote and text[idx - 1] != '\\': - pos[left_pos] = idx - is_found_left_quote = False - - if is_found_left_quote: - raise IndexError("No matching close quote at: " + str(left_pos)) - - return pos - - def _find_bracket_position(self, text, bracket_left, bracket_right, - pos_quote): - """Return the start and end position of pairs of brackets. - - https://stackoverflow.com/questions/29991917/ - indices-of-matching-parentheses-in-python - """ - pos = {} - pstack = [] - - for idx, character in enumerate(text): - if character == bracket_left and \ - not self.is_char_in_pairs(idx, pos_quote): - pstack.append(idx) - elif character == bracket_right and \ - not self.is_char_in_pairs(idx, pos_quote): - if len(pstack) == 0: - raise IndexError( - "No matching closing parens at: " + str(idx)) - pos[pstack.pop()] = idx - - if len(pstack) > 0: - raise IndexError( - "No matching opening parens at: " + str(pstack.pop())) - - return pos - - def split_arg_to_name_type_value(self, args_list): - """Split argument text to name, type, value.""" - for arg in args_list: - arg_type = None - arg_value = None - - has_type = False - has_value = False - - pos_colon = arg.find(':') - pos_equal = arg.find('=') - - if pos_equal > -1: - has_value = True - - if pos_colon > -1: - if not has_value: - has_type = True - elif pos_equal > pos_colon: # exception for def foo(arg1=":") - has_type = True - - if has_value and has_type: - arg_name = arg[0:pos_colon].strip() - arg_type = arg[pos_colon + 1:pos_equal].strip() - arg_value = arg[pos_equal + 1:].strip() - elif not has_value and has_type: - arg_name = arg[0:pos_colon].strip() - arg_type = arg[pos_colon + 1:].strip() - elif has_value and not has_type: - arg_name = arg[0:pos_equal].strip() - arg_value = arg[pos_equal + 1:].strip() - else: - arg_name = arg.strip() - - self.arg_name_list.append(arg_name) - self.arg_type_list.append(arg_type) - self.arg_value_list.append(arg_value) - - def split_args_text_to_list(self, args_text): - """Split the text including multiple arguments to list. - - This function uses a comma to separate arguments and ignores a comma in - brackets and quotes. - """ - args_list = [] - idx_find_start = 0 - idx_arg_start = 0 - - try: - pos_quote = self._find_quote_position(args_text) - pos_round = self._find_bracket_position(args_text, '(', ')', - pos_quote) - pos_curly = self._find_bracket_position(args_text, '{', '}', - pos_quote) - pos_square = self._find_bracket_position(args_text, '[', ']', - pos_quote) - except IndexError: - return None - - while True: - pos_comma = args_text.find(',', idx_find_start) - - if pos_comma == -1: - break - - idx_find_start = pos_comma + 1 - - if self.is_char_in_pairs(pos_comma, pos_round) or \ - self.is_char_in_pairs(pos_comma, pos_curly) or \ - self.is_char_in_pairs(pos_comma, pos_square) or \ - self.is_char_in_pairs(pos_comma, pos_quote): - continue - - args_list.append(args_text[idx_arg_start:pos_comma]) - idx_arg_start = pos_comma + 1 - - if idx_arg_start < len(args_text): - args_list.append(args_text[idx_arg_start:]) - - return args_list - - def parse_def(self, text): - """Parse the function definition text.""" - self.__init__() - - if not is_start_of_function(text): - return - - self.func_indent = get_indent(text) - - text = text.strip() - - return_type_re = re.search( - r'->[ ]*([\"\'a-zA-Z0-9_,()\[\] ]*):$', text) - if return_type_re: - self.return_type_annotated = return_type_re.group(1).strip(" ()\\") - if is_tuple_strings(self.return_type_annotated): - self.return_type_annotated = ( - "(" + self.return_type_annotated + ")" - ) - text_end = text.rfind(return_type_re.group(0)) - else: - self.return_type_annotated = None - text_end = len(text) - - pos_args_start = text.find('(') + 1 - pos_args_end = text.rfind(')', pos_args_start, text_end) - - self.args_text = text[pos_args_start:pos_args_end] - - args_list = self.split_args_text_to_list(self.args_text) - if args_list is not None: - self.has_info = True - self.split_arg_to_name_type_value(args_list) - - def parse_body(self, text): - """Parse the function body text.""" - re_raise = re.findall(r'[ \t]raise ([a-zA-Z0-9_]*)', text) - if len(re_raise) > 0: - self.raise_list = [x.strip() for x in re_raise] - # remove duplicates from list while keeping it in the order - # in python 2.7 - # stackoverflow.com/questions/7961363/removing-duplicates-in-lists - self.raise_list = list(OrderedDict.fromkeys(self.raise_list)) - - re_yield = re.search(r'[ \t]yield ', text) - if re_yield: - self.has_yield = True - - # get return value - pattern_return = r'return |yield ' - line_list = text.split('\n') - is_found_return = False - line_return_tmp = '' - - for line in line_list: - line = line.strip() - - if is_found_return is False: - if re.match(pattern_return, line): - is_found_return = True - - if is_found_return: - line_return_tmp += line - # check the integrity of line - try: - pos_quote = self._find_quote_position(line_return_tmp) - - if line_return_tmp[-1] == '\\': - line_return_tmp = line_return_tmp[:-1] - continue - - self._find_bracket_position(line_return_tmp, '(', ')', - pos_quote) - self._find_bracket_position(line_return_tmp, '{', '}', - pos_quote) - self._find_bracket_position(line_return_tmp, '[', ']', - pos_quote) - except IndexError: - continue - - return_value = re.sub(pattern_return, '', line_return_tmp) - self.return_value_in_body.append(return_value) - - is_found_return = False - line_return_tmp = '' - - -class QMenuOnlyForEnter(QMenu): - """The class executes the selected action when "enter key" is input. - - If a input of keyboard is not the "enter key", the menu is closed and - the input is inserted to code editor. - """ - - def __init__(self, code_editor): - """Init QMenu.""" - super(QMenuOnlyForEnter, self).__init__(code_editor) - self.code_editor = code_editor - - def keyPressEvent(self, event): - """Close the instance if key is not enter key.""" - key = event.key() - if key not in (Qt.Key_Enter, Qt.Key_Return): - self.code_editor.keyPressEvent(event) - self.close() - else: - super(QMenuOnlyForEnter, self).keyPressEvent(event) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Generate Docstring.""" + +# Standard library imports +import re +from collections import OrderedDict + +# Third party imports +from qtpy.QtGui import QTextCursor +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QMenu + +# Local imports +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string + + +def is_start_of_function(text): + """Return True if text is the beginning of the function definition.""" + if isinstance(text, str): + function_prefix = ['def', 'async def'] + text = text.lstrip() + + for prefix in function_prefix: + if text.startswith(prefix): + return True + + return False + + +def get_indent(text): + """Get indent of text. + + https://stackoverflow.com/questions/2268532/grab-a-lines-whitespace- + indention-with-python + """ + indent = '' + + ret = re.match(r'(\s*)', text) + if ret: + indent = ret.group(1) + + return indent + + +def is_in_scope_forward(text): + """Check if the next empty line could be part of the definition.""" + text = text.replace(r"\"", "").replace(r"\'", "") + scopes = ["'''", '"""', "'", '"'] + indices = [10**6] * 4 # Limits function def length to 10**6 + for i in range(len(scopes)): + if scopes[i] in text: + indices[i] = text.index(scopes[i]) + if min(indices) == 10**6: + return (text.count(")") != text.count("(") or + text.count("]") != text.count("[") or + text.count("}") != text.count("{")) + s = scopes[indices.index(min(indices))] + p = indices[indices.index(min(indices))] + ls = len(s) + if s in text[p + ls:]: + text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] + return is_in_scope_forward(text) + elif ls == 3: + text = text[:p] + return (text.count(")") != text.count("(") or + text.count("]") != text.count("[") or + text.count("}") != text.count("{")) + else: + return False + + +def is_tuple_brackets(text): + """Check if the return type is a tuple.""" + scopes = ["(", "[", "{"] + complements = [")", "]", "}"] + indices = [10**6] * 4 # Limits return type length to 10**6 + for i in range(len(scopes)): + if scopes[i] in text: + indices[i] = text.index(scopes[i]) + if min(indices) == 10**6: + return "," in text + s = complements[indices.index(min(indices))] + p = indices[indices.index(min(indices))] + if s in text[p + 1:]: + text = text[:p] + text[p + 1:][text[p + 1:].index(s) + 1:] + return is_tuple_brackets(text) + else: + return False + + +def is_tuple_strings(text): + """Check if the return type is a string.""" + text = text.replace(r"\"", "").replace(r"\'", "") + scopes = ["'''", '"""', "'", '"'] + indices = [10**6] * 4 # Limits return type length to 10**6 + for i in range(len(scopes)): + if scopes[i] in text: + indices[i] = text.index(scopes[i]) + if min(indices) == 10**6: + return is_tuple_brackets(text) + s = scopes[indices.index(min(indices))] + p = indices[indices.index(min(indices))] + ls = len(s) + if s in text[p + ls:]: + text = text[:p] + text[p + ls:][text[p + ls:].index(s) + ls:] + return is_tuple_strings(text) + else: + return False + + +def is_in_scope_backward(text): + """Check if the next empty line could be part of the definition.""" + return is_in_scope_forward( + text.replace(r"\"", "").replace(r"\'", "")[::-1]) + + +class DocstringWriterExtension(object): + """Class for insert docstring template automatically.""" + + def __init__(self, code_editor): + """Initialize and Add code_editor to the variable.""" + self.code_editor = code_editor + self.quote3 = '"""' + self.quote3_other = "'''" + self.line_number_cursor = None + + @staticmethod + def is_beginning_triple_quotes(text): + """Return True if there are only triple quotes in text.""" + docstring_triggers = ['"""', 'r"""', "'''", "r'''"] + if text.lstrip() in docstring_triggers: + return True + + return False + + def is_end_of_function_definition(self, text, line_number): + """Return True if text is the end of the function definition.""" + text_without_whitespace = "".join(text.split()) + if ( + text_without_whitespace.endswith("):") or + text_without_whitespace.endswith("]:") or + (text_without_whitespace.endswith(":") and + "->" in text_without_whitespace) + ): + return True + elif text_without_whitespace.endswith(":") and line_number > 1: + complete_text = text_without_whitespace + document = self.code_editor.document() + cursor = QTextCursor( + document.findBlockByNumber(line_number - 2)) # previous line + for i in range(line_number - 2, -1, -1): + txt = "".join(str(cursor.block().text()).split()) + if txt.endswith("\\") or is_in_scope_backward(complete_text): + if txt.endswith("\\"): + txt = txt[:-1] + complete_text = txt + complete_text + else: + break + if i != 0: + cursor.movePosition(QTextCursor.PreviousBlock) + if is_start_of_function(complete_text): + return ( + complete_text.endswith("):") or + complete_text.endswith("]:") or + (complete_text.endswith(":") and + "->" in complete_text) + ) + else: + return False + else: + return False + + def get_function_definition_from_first_line(self): + """Get func def when the cursor is located on the first def line.""" + document = self.code_editor.document() + cursor = QTextCursor( + document.findBlockByNumber(self.line_number_cursor - 1)) + + func_text = '' + func_indent = '' + + is_first_line = True + line_number = cursor.blockNumber() + 1 + + number_of_lines = self.code_editor.blockCount() + remain_lines = number_of_lines - line_number + 1 + number_of_lines_of_function = 0 + + for __ in range(min(remain_lines, 20)): + cur_text = to_text_string(cursor.block().text()).rstrip() + + if is_first_line: + if not is_start_of_function(cur_text): + return None + + func_indent = get_indent(cur_text) + is_first_line = False + else: + cur_indent = get_indent(cur_text) + if cur_indent <= func_indent and cur_text.strip() != '': + return None + if is_start_of_function(cur_text): + return None + if (cur_text.strip() == '' and + not is_in_scope_forward(func_text)): + return None + + if len(cur_text) > 0 and cur_text[-1] == '\\': + cur_text = cur_text[:-1] + + func_text += cur_text + number_of_lines_of_function += 1 + + if self.is_end_of_function_definition( + cur_text, line_number + number_of_lines_of_function - 1): + return func_text, number_of_lines_of_function + + cursor.movePosition(QTextCursor.NextBlock) + + return None + + def get_function_definition_from_below_last_line(self): + """Get func def when the cursor is located below the last def line.""" + cursor = self.code_editor.textCursor() + func_text = '' + is_first_line = True + line_number = cursor.blockNumber() + 1 + number_of_lines_of_function = 0 + + for __ in range(min(line_number, 20)): + if cursor.block().blockNumber() == 0: + return None + + cursor.movePosition(QTextCursor.PreviousBlock) + prev_text = to_text_string(cursor.block().text()).rstrip() + + if is_first_line: + if not self.is_end_of_function_definition( + prev_text, line_number - 1): + return None + is_first_line = False + elif self.is_end_of_function_definition( + prev_text, line_number - number_of_lines_of_function - 1): + return None + + if len(prev_text) > 0 and prev_text[-1] == '\\': + prev_text = prev_text[:-1] + + func_text = prev_text + func_text + + number_of_lines_of_function += 1 + if is_start_of_function(prev_text): + return func_text, number_of_lines_of_function + + return None + + def get_function_body(self, func_indent): + """Get the function body text.""" + cursor = self.code_editor.textCursor() + line_number = cursor.blockNumber() + 1 + number_of_lines = self.code_editor.blockCount() + body_list = [] + + for __ in range(number_of_lines - line_number + 1): + text = to_text_string(cursor.block().text()) + text_indent = get_indent(text) + + if text.strip() == '': + pass + elif len(text_indent) <= len(func_indent): + break + + body_list.append(text) + + cursor.movePosition(QTextCursor.NextBlock) + + return '\n'.join(body_list) + + def write_docstring(self): + """Write docstring to editor.""" + line_to_cursor = self.code_editor.get_text('sol', 'cursor') + if self.is_beginning_triple_quotes(line_to_cursor): + cursor = self.code_editor.textCursor() + prev_pos = cursor.position() + + quote = line_to_cursor[-1] + docstring_type = CONF.get('editor', 'docstring_type') + docstring = self._generate_docstring(docstring_type, quote) + + if docstring: + self.code_editor.insert_text(docstring) + + cursor = self.code_editor.textCursor() + cursor.setPosition(prev_pos, QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.NextBlock) + cursor.movePosition(QTextCursor.EndOfLine, + QTextCursor.KeepAnchor) + cursor.clearSelection() + self.code_editor.setTextCursor(cursor) + return True + + return False + + def write_docstring_at_first_line_of_function(self): + """Write docstring to editor at mouse position.""" + result = self.get_function_definition_from_first_line() + editor = self.code_editor + if result: + func_text, number_of_line_func = result + line_number_function = (self.line_number_cursor + + number_of_line_func - 1) + + cursor = editor.textCursor() + line_number_cursor = cursor.blockNumber() + 1 + offset = line_number_function - line_number_cursor + if offset > 0: + for __ in range(offset): + cursor.movePosition(QTextCursor.NextBlock) + else: + for __ in range(abs(offset)): + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.MoveAnchor) + editor.setTextCursor(cursor) + + indent = get_indent(func_text) + editor.insert_text('\n{}{}"""'.format(indent, editor.indent_chars)) + self.write_docstring() + + def write_docstring_for_shortcut(self): + """Write docstring to editor by shortcut of code editor.""" + # cursor placed below function definition + result = self.get_function_definition_from_below_last_line() + if result is not None: + __, number_of_lines_of_function = result + cursor = self.code_editor.textCursor() + for __ in range(number_of_lines_of_function): + cursor.movePosition(QTextCursor.PreviousBlock) + + self.code_editor.setTextCursor(cursor) + + cursor = self.code_editor.textCursor() + self.line_number_cursor = cursor.blockNumber() + 1 + + self.write_docstring_at_first_line_of_function() + + def _generate_docstring(self, doc_type, quote): + """Generate docstring.""" + docstring = None + + self.quote3 = quote * 3 + if quote == '"': + self.quote3_other = "'''" + else: + self.quote3_other = '"""' + + result = self.get_function_definition_from_below_last_line() + + if result: + func_def, __ = result + func_info = FunctionInfo() + func_info.parse_def(func_def) + + if func_info.has_info: + func_body = self.get_function_body(func_info.func_indent) + if func_body: + func_info.parse_body(func_body) + + if doc_type == 'Numpydoc': + docstring = self._generate_numpy_doc(func_info) + elif doc_type == 'Googledoc': + docstring = self._generate_google_doc(func_info) + elif doc_type == "Sphinxdoc": + docstring = self._generate_sphinx_doc(func_info) + + return docstring + + def _generate_numpy_doc(self, func_info): + """Generate a docstring of numpy type.""" + numpy_doc = '' + + arg_names = func_info.arg_name_list + arg_types = func_info.arg_type_list + arg_values = func_info.arg_value_list + + if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): + del arg_names[0] + del arg_types[0] + del arg_values[0] + + indent1 = func_info.func_indent + self.code_editor.indent_chars + indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 + + numpy_doc += '\n{}\n'.format(indent1) + + if len(arg_names) > 0: + numpy_doc += '\n{}Parameters'.format(indent1) + numpy_doc += '\n{}----------\n'.format(indent1) + + arg_text = '' + for arg_name, arg_type, arg_value in zip(arg_names, arg_types, + arg_values): + arg_text += '{}{} : '.format(indent1, arg_name) + if arg_type: + arg_text += '{}'.format(arg_type) + else: + arg_text += 'TYPE' + + if arg_value: + arg_text += ', optional' + + arg_text += '\n{}DESCRIPTION.'.format(indent2) + + if arg_value: + arg_value = arg_value.replace(self.quote3, self.quote3_other) + arg_text += ' The default is {}.'.format(arg_value) + + arg_text += '\n' + + numpy_doc += arg_text + + if func_info.raise_list: + numpy_doc += '\n{}Raises'.format(indent1) + numpy_doc += '\n{}------'.format(indent1) + for raise_type in func_info.raise_list: + numpy_doc += '\n{}{}'.format(indent1, raise_type) + numpy_doc += '\n{}DESCRIPTION.'.format(indent2) + numpy_doc += '\n' + + numpy_doc += '\n' + if func_info.has_yield: + header = '{0}Yields\n{0}------\n'.format(indent1) + else: + header = '{0}Returns\n{0}-------\n'.format(indent1) + + return_type_annotated = func_info.return_type_annotated + if return_type_annotated: + return_section = '{}{}{}'.format(header, indent1, + return_type_annotated) + return_section += '\n{}DESCRIPTION.'.format(indent2) + else: + return_element_type = indent1 + '{return_type}\n' + indent2 + \ + 'DESCRIPTION.' + placeholder = return_element_type.format(return_type='TYPE') + return_element_name = indent1 + '{return_name} : ' + \ + placeholder.lstrip() + + try: + return_section = self._generate_docstring_return_section( + func_info.return_value_in_body, header, + return_element_name, return_element_type, placeholder, + indent1) + except (ValueError, IndexError): + return_section = '{}{}None.'.format(header, indent1) + + numpy_doc += return_section + numpy_doc += '\n\n{}{}'.format(indent1, self.quote3) + + return numpy_doc + + def _generate_google_doc(self, func_info): + """Generate a docstring of google type.""" + google_doc = '' + + arg_names = func_info.arg_name_list + arg_types = func_info.arg_type_list + arg_values = func_info.arg_value_list + + if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): + del arg_names[0] + del arg_types[0] + del arg_values[0] + + indent1 = func_info.func_indent + self.code_editor.indent_chars + indent2 = func_info.func_indent + self.code_editor.indent_chars * 2 + + google_doc += '\n{}\n'.format(indent1) + + if len(arg_names) > 0: + google_doc += '\n{0}Args:\n'.format(indent1) + + arg_text = '' + for arg_name, arg_type, arg_value in zip(arg_names, arg_types, + arg_values): + arg_text += '{}{} '.format(indent2, arg_name) + + arg_text += '(' + if arg_type: + arg_text += '{}'.format(arg_type) + else: + arg_text += 'TYPE' + + if arg_value: + arg_text += ', optional' + arg_text += '):' + + arg_text += ' DESCRIPTION.' + + if arg_value: + arg_value = arg_value.replace(self.quote3, self.quote3_other) + arg_text += ' Defaults to {}.\n'.format(arg_value) + else: + arg_text += '\n' + + google_doc += arg_text + + if func_info.raise_list: + google_doc += '\n{0}Raises:'.format(indent1) + for raise_type in func_info.raise_list: + google_doc += '\n{}{}'.format(indent2, raise_type) + google_doc += ': DESCRIPTION.' + google_doc += '\n' + + google_doc += '\n' + if func_info.has_yield: + header = '{}Yields:\n'.format(indent1) + else: + header = '{}Returns:\n'.format(indent1) + + return_type_annotated = func_info.return_type_annotated + if return_type_annotated: + return_section = '{}{}{}: DESCRIPTION.'.format( + header, indent2, return_type_annotated) + else: + return_element_type = indent2 + '{return_type}: DESCRIPTION.' + placeholder = return_element_type.format(return_type='TYPE') + return_element_name = indent2 + '{return_name} ' + \ + '(TYPE): DESCRIPTION.' + + try: + return_section = self._generate_docstring_return_section( + func_info.return_value_in_body, header, + return_element_name, return_element_type, placeholder, + indent2) + except (ValueError, IndexError): + return_section = '{}{}None.'.format(header, indent2) + + google_doc += return_section + google_doc += '\n\n{}{}'.format(indent1, self.quote3) + + return google_doc + + def _generate_sphinx_doc(self, func_info): + """Generate a docstring of sphinx type.""" + sphinx_doc = '' + + arg_names = func_info.arg_name_list + arg_types = func_info.arg_type_list + arg_values = func_info.arg_value_list + + if len(arg_names) > 0 and arg_names[0] in ('self', 'cls'): + del arg_names[0] + del arg_types[0] + del arg_values[0] + + indent1 = func_info.func_indent + self.code_editor.indent_chars + + sphinx_doc += '\n{}\n'.format(indent1) + + arg_text = '' + for arg_name, arg_type, arg_value in zip(arg_names, arg_types, + arg_values): + arg_text += '{}:param {}: DESCRIPTION'.format(indent1, arg_name) + + if arg_value: + arg_value = arg_value.replace(self.quote3, self.quote3_other) + arg_text += ', defaults to {}\n'.format(arg_value) + else: + arg_text += '\n' + + arg_text += '{}:type {}: '.format(indent1, arg_name) + + if arg_type: + arg_text += '{}'.format(arg_type) + else: + arg_text += 'TYPE' + + if arg_value: + arg_text += ', optional' + arg_text += '\n' + + sphinx_doc += arg_text + + if func_info.raise_list: + for raise_type in func_info.raise_list: + sphinx_doc += '{}:raises {}: DESCRIPTION\n'.format(indent1, + raise_type) + + if func_info.has_yield: + header = '{}:yield:'.format(indent1) + else: + header = '{}:return:'.format(indent1) + + return_type_annotated = func_info.return_type_annotated + if return_type_annotated: + return_section = '{} DESCRIPTION\n'.format(header) + return_section += '{}:rtype: {}'.format(indent1, + return_type_annotated) + else: + return_section = '{} DESCRIPTION\n'.format(header) + return_section += '{}:rtype: TYPE'.format(indent1) + + sphinx_doc += return_section + sphinx_doc += '\n\n{}{}'.format(indent1, self.quote3) + + return sphinx_doc + + @staticmethod + def find_top_level_bracket_locations(string_toparse): + """Get the locations of top-level brackets in a string.""" + bracket_stack = [] + replace_args_list = [] + bracket_type = None + literal_type = '' + brackets = {'(': ')', '[': ']', '{': '}'} + for idx, character in enumerate(string_toparse): + if (not bracket_stack and character in brackets.keys() + or character == bracket_type): + bracket_stack.append(idx) + bracket_type = character + elif bracket_type and character == brackets[bracket_type]: + begin_idx = bracket_stack.pop() + if not bracket_stack: + if not literal_type: + if bracket_type == '(': + literal_type = '(None)' + elif bracket_type == '[': + literal_type = '[list]' + elif bracket_type == '{': + if idx - begin_idx <= 1: + literal_type = '{dict}' + else: + literal_type = '{set}' + replace_args_list.append( + (string_toparse[begin_idx:idx + 1], + literal_type, 1)) + bracket_type = None + literal_type = '' + elif len(bracket_stack) == 1: + if bracket_type == '(' and character == ',': + literal_type = '(tuple)' + elif bracket_type == '{' and character == ':': + literal_type = '{dict}' + elif bracket_type == '(' and character == ':': + literal_type = '[slice]' + + if bracket_stack: + raise IndexError('Bracket mismatch') + for replace_args in replace_args_list: + string_toparse = string_toparse.replace(*replace_args) + return string_toparse + + @staticmethod + def parse_return_elements(return_vals_group, return_element_name, + return_element_type, placeholder): + """Return the appropriate text for a group of return elements.""" + all_eq = (return_vals_group.count(return_vals_group[0]) + == len(return_vals_group)) + if all([{'[list]', '(tuple)', '{dict}', '{set}'}.issuperset( + return_vals_group)]) and all_eq: + return return_element_type.format( + return_type=return_vals_group[0][1:-1]) + # Output placeholder if special Python chars present in name + py_chars = {' ', '+', '-', '*', '/', '%', '@', '<', '>', '&', '|', '^', + '~', '=', ',', ':', ';', '#', '(', '[', '{', '}', ']', + ')', } + if any([any([py_char in return_val for py_char in py_chars]) + for return_val in return_vals_group]): + return placeholder + # Output str type and no name if only string literals + if all(['"' in return_val or '\'' in return_val + for return_val in return_vals_group]): + return return_element_type.format(return_type='str') + # Output bool type and no name if only bool literals + if {'True', 'False'}.issuperset(return_vals_group): + return return_element_type.format(return_type='bool') + # Output numeric types and no name if only numeric literals + try: + [float(return_val) for return_val in return_vals_group] + num_not_int = 0 + for return_val in return_vals_group: + try: + int(return_val) + except ValueError: # If not an integer (EAFP) + num_not_int = num_not_int + 1 + if num_not_int == 0: + return return_element_type.format(return_type='int') + elif num_not_int == len(return_vals_group): + return return_element_type.format(return_type='float') + else: + return return_element_type.format(return_type='numeric') + except ValueError: # Not a numeric if float conversion didn't work + pass + # If names are not equal, don't contain "." or are a builtin + if ({'self', 'cls', 'None'}.isdisjoint(return_vals_group) and all_eq + and all(['.' not in return_val + for return_val in return_vals_group])): + return return_element_name.format(return_name=return_vals_group[0]) + return placeholder + + def _generate_docstring_return_section(self, return_vals, header, + return_element_name, + return_element_type, + placeholder, indent): + """Generate the Returns section of a function/method docstring.""" + # If all return values are None, return none + non_none_vals = [return_val for return_val in return_vals + if return_val and return_val != 'None'] + if not non_none_vals: + return header + indent + 'None.' + + # Get only values with matching brackets that can be cleaned up + non_none_vals = [return_val.strip(' ()\t\n').rstrip(',') + for return_val in non_none_vals] + non_none_vals = [re.sub('([\"\'])(?:(?=(\\\\?))\\2.)*?\\1', + '"string"', return_val) + for return_val in non_none_vals] + unambiguous_vals = [] + for return_val in non_none_vals: + try: + cleaned_val = self.find_top_level_bracket_locations(return_val) + except IndexError: + continue + unambiguous_vals.append(cleaned_val) + if not unambiguous_vals: + return header + placeholder + + # If remaining are a mix of tuples and not, return single placeholder + single_vals, tuple_vals = [], [] + for return_val in unambiguous_vals: + (tuple_vals.append(return_val) if ',' in return_val + else single_vals.append(return_val)) + if single_vals and tuple_vals: + return header + placeholder + + # If return values are tuples of different length, return a placeholder + if tuple_vals: + num_elements = [return_val.count(',') + 1 + for return_val in tuple_vals] + if num_elements.count(num_elements[0]) != len(num_elements): + return header + placeholder + num_elements = num_elements[0] + else: + num_elements = 1 + + # If all have the same len but some ambiguous return that placeholders + if len(unambiguous_vals) != len(non_none_vals): + return header + '\n'.join( + [placeholder for __ in range(num_elements)]) + + # Handle tuple (or single) values position by position + return_vals_grouped = zip(*[ + [return_element.strip() for return_element in + return_val.split(',')] + for return_val in unambiguous_vals]) + return_elements_out = [] + for return_vals_group in return_vals_grouped: + return_elements_out.append( + self.parse_return_elements(return_vals_group, + return_element_name, + return_element_type, + placeholder)) + + return header + '\n'.join(return_elements_out) + + +class FunctionInfo(object): + """Parse function definition text.""" + + def __init__(self): + """.""" + self.has_info = False + self.func_text = '' + self.args_text = '' + self.func_indent = '' + self.arg_name_list = [] + self.arg_type_list = [] + self.arg_value_list = [] + self.return_type_annotated = None + self.return_value_in_body = [] + self.raise_list = None + self.has_yield = False + + @staticmethod + def is_char_in_pairs(pos_char, pairs): + """Return True if the character is in pairs of brackets or quotes.""" + for pos_left, pos_right in pairs.items(): + if pos_left < pos_char < pos_right: + return True + + return False + + @staticmethod + def _find_quote_position(text): + """Return the start and end position of pairs of quotes.""" + pos = {} + is_found_left_quote = False + + for idx, character in enumerate(text): + if is_found_left_quote is False: + if character == "'" or character == '"': + is_found_left_quote = True + quote = character + left_pos = idx + else: + if character == quote and text[idx - 1] != '\\': + pos[left_pos] = idx + is_found_left_quote = False + + if is_found_left_quote: + raise IndexError("No matching close quote at: " + str(left_pos)) + + return pos + + def _find_bracket_position(self, text, bracket_left, bracket_right, + pos_quote): + """Return the start and end position of pairs of brackets. + + https://stackoverflow.com/questions/29991917/ + indices-of-matching-parentheses-in-python + """ + pos = {} + pstack = [] + + for idx, character in enumerate(text): + if character == bracket_left and \ + not self.is_char_in_pairs(idx, pos_quote): + pstack.append(idx) + elif character == bracket_right and \ + not self.is_char_in_pairs(idx, pos_quote): + if len(pstack) == 0: + raise IndexError( + "No matching closing parens at: " + str(idx)) + pos[pstack.pop()] = idx + + if len(pstack) > 0: + raise IndexError( + "No matching opening parens at: " + str(pstack.pop())) + + return pos + + def split_arg_to_name_type_value(self, args_list): + """Split argument text to name, type, value.""" + for arg in args_list: + arg_type = None + arg_value = None + + has_type = False + has_value = False + + pos_colon = arg.find(':') + pos_equal = arg.find('=') + + if pos_equal > -1: + has_value = True + + if pos_colon > -1: + if not has_value: + has_type = True + elif pos_equal > pos_colon: # exception for def foo(arg1=":") + has_type = True + + if has_value and has_type: + arg_name = arg[0:pos_colon].strip() + arg_type = arg[pos_colon + 1:pos_equal].strip() + arg_value = arg[pos_equal + 1:].strip() + elif not has_value and has_type: + arg_name = arg[0:pos_colon].strip() + arg_type = arg[pos_colon + 1:].strip() + elif has_value and not has_type: + arg_name = arg[0:pos_equal].strip() + arg_value = arg[pos_equal + 1:].strip() + else: + arg_name = arg.strip() + + self.arg_name_list.append(arg_name) + self.arg_type_list.append(arg_type) + self.arg_value_list.append(arg_value) + + def split_args_text_to_list(self, args_text): + """Split the text including multiple arguments to list. + + This function uses a comma to separate arguments and ignores a comma in + brackets and quotes. + """ + args_list = [] + idx_find_start = 0 + idx_arg_start = 0 + + try: + pos_quote = self._find_quote_position(args_text) + pos_round = self._find_bracket_position(args_text, '(', ')', + pos_quote) + pos_curly = self._find_bracket_position(args_text, '{', '}', + pos_quote) + pos_square = self._find_bracket_position(args_text, '[', ']', + pos_quote) + except IndexError: + return None + + while True: + pos_comma = args_text.find(',', idx_find_start) + + if pos_comma == -1: + break + + idx_find_start = pos_comma + 1 + + if self.is_char_in_pairs(pos_comma, pos_round) or \ + self.is_char_in_pairs(pos_comma, pos_curly) or \ + self.is_char_in_pairs(pos_comma, pos_square) or \ + self.is_char_in_pairs(pos_comma, pos_quote): + continue + + args_list.append(args_text[idx_arg_start:pos_comma]) + idx_arg_start = pos_comma + 1 + + if idx_arg_start < len(args_text): + args_list.append(args_text[idx_arg_start:]) + + return args_list + + def parse_def(self, text): + """Parse the function definition text.""" + self.__init__() + + if not is_start_of_function(text): + return + + self.func_indent = get_indent(text) + + text = text.strip() + + return_type_re = re.search( + r'->[ ]*([\"\'a-zA-Z0-9_,()\[\] ]*):$', text) + if return_type_re: + self.return_type_annotated = return_type_re.group(1).strip(" ()\\") + if is_tuple_strings(self.return_type_annotated): + self.return_type_annotated = ( + "(" + self.return_type_annotated + ")" + ) + text_end = text.rfind(return_type_re.group(0)) + else: + self.return_type_annotated = None + text_end = len(text) + + pos_args_start = text.find('(') + 1 + pos_args_end = text.rfind(')', pos_args_start, text_end) + + self.args_text = text[pos_args_start:pos_args_end] + + args_list = self.split_args_text_to_list(self.args_text) + if args_list is not None: + self.has_info = True + self.split_arg_to_name_type_value(args_list) + + def parse_body(self, text): + """Parse the function body text.""" + re_raise = re.findall(r'[ \t]raise ([a-zA-Z0-9_]*)', text) + if len(re_raise) > 0: + self.raise_list = [x.strip() for x in re_raise] + # remove duplicates from list while keeping it in the order + # in python 2.7 + # stackoverflow.com/questions/7961363/removing-duplicates-in-lists + self.raise_list = list(OrderedDict.fromkeys(self.raise_list)) + + re_yield = re.search(r'[ \t]yield ', text) + if re_yield: + self.has_yield = True + + # get return value + pattern_return = r'return |yield ' + line_list = text.split('\n') + is_found_return = False + line_return_tmp = '' + + for line in line_list: + line = line.strip() + + if is_found_return is False: + if re.match(pattern_return, line): + is_found_return = True + + if is_found_return: + line_return_tmp += line + # check the integrity of line + try: + pos_quote = self._find_quote_position(line_return_tmp) + + if line_return_tmp[-1] == '\\': + line_return_tmp = line_return_tmp[:-1] + continue + + self._find_bracket_position(line_return_tmp, '(', ')', + pos_quote) + self._find_bracket_position(line_return_tmp, '{', '}', + pos_quote) + self._find_bracket_position(line_return_tmp, '[', ']', + pos_quote) + except IndexError: + continue + + return_value = re.sub(pattern_return, '', line_return_tmp) + self.return_value_in_body.append(return_value) + + is_found_return = False + line_return_tmp = '' + + +class QMenuOnlyForEnter(QMenu): + """The class executes the selected action when "enter key" is input. + + If a input of keyboard is not the "enter key", the menu is closed and + the input is inserted to code editor. + """ + + def __init__(self, code_editor): + """Init QMenu.""" + super(QMenuOnlyForEnter, self).__init__(code_editor) + self.code_editor = code_editor + + def keyPressEvent(self, event): + """Close the instance if key is not enter key.""" + key = event.key() + if key not in (Qt.Key_Enter, Qt.Key_Return): + self.code_editor.keyPressEvent(event) + self.close() + else: + super(QMenuOnlyForEnter, self).keyPressEvent(event) diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index ffb8d3cf72f..6977f10d0e5 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -1,3584 +1,3584 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Editor Plugin""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import logging -import os -import os.path as osp -import re -import sys -import time - -# Third party imports -from qtpy.compat import from_qvariant, getopenfilenames, to_qvariant -from qtpy.QtCore import QByteArray, Qt, Signal, Slot, QDir -from qtpy.QtGui import QTextCursor -from qtpy.QtPrintSupport import (QAbstractPrintDialog, QPrintDialog, QPrinter, - QPrintPreviewDialog) -from qtpy.QtWidgets import (QAction, QActionGroup, QApplication, QDialog, - QFileDialog, QInputDialog, QMenu, QSplitter, - QToolBar, QVBoxLayout, QWidget) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.config.mixins import SpyderConfigurationObserver -from spyder.api.panel import Panel -from spyder.api.plugins import Plugins, SpyderPluginWidget -from spyder.config.base import _, get_conf_path, running_under_pytest -from spyder.config.manager import CONF -from spyder.config.utils import (get_edit_filetypes, get_edit_filters, - get_filter) -from spyder.py3compat import PY2, qbytearray_to_str, to_text_string -from spyder.utils import encoding, programs, sourcecode -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import create_action, add_actions, MENU_SEPARATOR -from spyder.utils.misc import getcwd_or_home -from spyder.widgets.findreplace import FindReplace -from spyder.plugins.editor.confpage import EditorConfigPage -from spyder.plugins.editor.utils.autosave import AutosaveForPlugin -from spyder.plugins.editor.utils.switcher import EditorSwitcherManager -from spyder.plugins.editor.widgets.codeeditor_widgets import Printer -from spyder.plugins.editor.widgets.editor import (EditorMainWindow, - EditorSplitter, - EditorStack,) -from spyder.plugins.editor.widgets.codeeditor import CodeEditor -from spyder.plugins.editor.utils.bookmarks import (load_bookmarks, - save_bookmarks) -from spyder.plugins.editor.utils.debugger import (clear_all_breakpoints, - clear_breakpoint) -from spyder.plugins.editor.widgets.status import (CursorPositionStatus, - EncodingStatus, EOLStatus, - ReadWriteStatus, VCSStatus) -from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN_OPTION, - get_run_configuration, RunConfigDialog, - RunConfiguration, RunConfigOneDialog) -from spyder.plugins.mainmenu.api import ApplicationMenus -from spyder.widgets.simplecodeeditor import SimpleCodeEditor - - -logger = logging.getLogger(__name__) - - -class Editor(SpyderPluginWidget, SpyderConfigurationObserver): - """ - Multi-file Editor widget - """ - CONF_SECTION = 'editor' - CONFIGWIDGET_CLASS = EditorConfigPage - CONF_FILE = False - TEMPFILE_PATH = get_conf_path('temp.py') - TEMPLATE_PATH = get_conf_path('template.py') - DISABLE_ACTIONS_WHEN_HIDDEN = False # SpyderPluginWidget class attribute - - # This is required for the new API - NAME = 'editor' - REQUIRES = [Plugins.Console] - OPTIONAL = [Plugins.Completions, Plugins.OutlineExplorer] - - # Signals - run_in_current_ipyclient = Signal(str, str, str, - bool, bool, bool, bool, bool) - run_cell_in_ipyclient = Signal(str, object, str, bool, bool) - debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) - exec_in_extconsole = Signal(str, bool) - redirect_stdio = Signal(bool) - - sig_dir_opened = Signal(str) - """ - This signal is emitted when the editor changes the current directory. - - Parameters - ---------- - new_working_directory: str - The new working directory path. - - Notes - ----- - This option is available on the options menu of the editor plugin - """ - - breakpoints_saved = Signal() - - sig_file_opened_closed_or_updated = Signal(str, str) - """ - This signal is emitted when a file is opened, closed or updated, - including switching among files. - - Parameters - ---------- - filename: str - Name of the file that was opened, closed or updated. - language: str - Name of the programming language of the file that was opened, - closed or updated. - """ - - sig_file_debug_message_requested = Signal() - - # This signal is fired for any focus change among all editor stacks - sig_editor_focus_changed = Signal() - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Dictionary required by the Help pane to render a docstring. - - Examples - -------- - >>> help_data = { - 'obj_text': str, - 'name': str, - 'argspec': str, - 'note': str, - 'docstring': str, - 'force_refresh': bool, - 'path': str, - } - - See Also - -------- - :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help - """ - - sig_open_files_finished = Signal() - """ - This signal is emitted when the editor finished to open files. - """ - - def __init__(self, parent, ignore_last_opened_files=False): - SpyderPluginWidget.__init__(self, parent) - - self.__set_eol_chars = True - - # Creating template if it doesn't already exist - if not osp.isfile(self.TEMPLATE_PATH): - if os.name == "nt": - shebang = [] - else: - shebang = ['#!/usr/bin/env python' + ('2' if PY2 else '3')] - header = shebang + [ - '# -*- coding: utf-8 -*-', - '"""', 'Created on %(date)s', '', - '@author: %(username)s', '"""', '', ''] - try: - encoding.write(os.linesep.join(header), self.TEMPLATE_PATH, - 'utf-8') - except EnvironmentError: - pass - - self.projects = None - self.outlineexplorer = None - - self.file_dependent_actions = [] - self.pythonfile_dependent_actions = [] - self.dock_toolbar_actions = None - self.edit_menu_actions = None #XXX: find another way to notify Spyder - self.stack_menu_actions = None - self.checkable_actions = {} - - self.__first_open_files_setup = True - self.editorstacks = [] - self.last_focused_editorstack = {} - self.editorwindows = [] - self.editorwindows_to_be_created = [] - self.toolbar_list = None - self.menu_list = None - - # We need to call this here to create self.dock_toolbar_actions, - # which is used below. - self._setup() - self.options_button.hide() - - # Configuration dialog size - self.dialog_size = None - - self.vcs_status = VCSStatus(self) - self.cursorpos_status = CursorPositionStatus(self) - self.encoding_status = EncodingStatus(self) - self.eol_status = EOLStatus(self) - self.readwrite_status = ReadWriteStatus(self) - - # TODO: temporal fix while editor uses new API - statusbar = self.main.get_plugin(Plugins.StatusBar, error=False) - if statusbar: - statusbar.add_status_widget(self.readwrite_status) - statusbar.add_status_widget(self.eol_status) - statusbar.add_status_widget(self.encoding_status) - statusbar.add_status_widget(self.cursorpos_status) - statusbar.add_status_widget(self.vcs_status) - - layout = QVBoxLayout() - self.dock_toolbar = QToolBar(self) - add_actions(self.dock_toolbar, self.dock_toolbar_actions) - layout.addWidget(self.dock_toolbar) - - self.last_edit_cursor_pos = None - self.cursor_undo_history = [] - self.cursor_redo_history = [] - self.__ignore_cursor_history = True - - # Completions setup - self.completion_capabilities = {} - - # Setup new windows: - self.main.all_actions_defined.connect(self.setup_other_windows) - - # Change module completions when PYTHONPATH changes - self.main.sig_pythonpath_changed.connect(self.set_path) - - # Find widget - self.find_widget = FindReplace(self, enable_replace=True) - self.find_widget.hide() - self.register_widget_shortcuts(self.find_widget) - - # Start autosave component - # (needs to be done before EditorSplitter) - self.autosave = AutosaveForPlugin(self) - self.autosave.try_recover_from_autosave() - - # Multiply by 1000 to convert seconds to milliseconds - self.autosave.interval = self.get_option('autosave_interval') * 1000 - self.autosave.enabled = self.get_option('autosave_enabled') - - # SimpleCodeEditor instance used to print file contents - self._print_editor = self._create_print_editor() - self._print_editor.hide() - - # Tabbed editor widget + Find/Replace widget - editor_widgets = QWidget(self) - editor_layout = QVBoxLayout() - editor_layout.setContentsMargins(0, 0, 0, 0) - editor_widgets.setLayout(editor_layout) - self.editorsplitter = EditorSplitter(self, self, - self.stack_menu_actions, first=True) - editor_layout.addWidget(self.editorsplitter) - editor_layout.addWidget(self.find_widget) - editor_layout.addWidget(self._print_editor) - - # Splitter: editor widgets (see above) + outline explorer - self.splitter = QSplitter(self) - self.splitter.setContentsMargins(0, 0, 0, 0) - self.splitter.addWidget(editor_widgets) - self.splitter.setStretchFactor(0, 5) - self.splitter.setStretchFactor(1, 1) - layout.addWidget(self.splitter) - self.setLayout(layout) - self.setFocusPolicy(Qt.ClickFocus) - - # Editor's splitter state - state = self.get_option('splitter_state', None) - if state is not None: - self.splitter.restoreState( QByteArray().fromHex( - str(state).encode('utf-8')) ) - - self.recent_files = self.get_option('recent_files', []) - self.untitled_num = 0 - - # Parameters of last file execution: - self.__last_ic_exec = None # internal console - self.__last_ec_exec = None # external console - - # File types and filters used by the Open dialog - self.edit_filetypes = None - self.edit_filters = None - - self.__ignore_cursor_history = False - current_editor = self.get_current_editor() - if current_editor is not None: - filename = self.get_current_filename() - cursor = current_editor.textCursor() - self.add_cursor_to_history(filename, cursor) - self.update_cursorpos_actions() - self.set_path() - - def set_projects(self, projects): - self.projects = projects - - @Slot() - def show_hide_projects(self): - if self.projects is not None: - dw = self.projects.dockwidget - if dw.isVisible(): - dw.hide() - else: - dw.show() - dw.raise_() - self.switch_to_plugin() - - def set_outlineexplorer(self, outlineexplorer): - self.outlineexplorer = outlineexplorer - for editorstack in self.editorstacks: - # Pass the OutlineExplorer widget to the stacks because they - # don't need the plugin - editorstack.set_outlineexplorer(self.outlineexplorer.get_widget()) - self.outlineexplorer.get_widget().edit_goto.connect( - lambda filenames, goto, word: - self.load(filenames=filenames, goto=goto, word=word, - editorwindow=self)) - self.outlineexplorer.get_widget().edit.connect( - lambda filenames: - self.load(filenames=filenames, editorwindow=self)) - - #------ Private API -------------------------------------------------------- - def restore_scrollbar_position(self): - """Restoring scrollbar position after main window is visible""" - # Widget is now visible, we may center cursor on top level editor: - try: - self.get_current_editor().centerCursor() - except AttributeError: - pass - - @Slot(dict) - def report_open_file(self, options): - """Report that a file was opened to the completion manager.""" - filename = options['filename'] - language = options['language'] - codeeditor = options['codeeditor'] - status = None - if self.main.get_plugin(Plugins.Completions, error=False): - status = ( - self.main.completions.start_completion_services_for_language( - language.lower())) - self.main.completions.register_file( - language.lower(), filename, codeeditor) - if status: - if language.lower() in self.completion_capabilities: - # When this condition is True, it means there's a server - # that can provide completion services for this file. - codeeditor.register_completion_capabilities( - self.completion_capabilities[language.lower()]) - codeeditor.start_completion_services() - elif self.main.completions.is_fallback_only(language.lower()): - # This is required to use fallback completions for files - # without a language server. - codeeditor.start_completion_services() - else: - if codeeditor.language == language.lower(): - logger.debug('Setting {0} completions off'.format(filename)) - codeeditor.completions_available = False - - @Slot(dict, str) - def register_completion_capabilities(self, capabilities, language): - """ - Register completion server capabilities in all editorstacks. - - Parameters - ---------- - capabilities: dict - Capabilities supported by a language server. - language: str - Programming language for the language server (it has to be - in small caps). - """ - logger.debug( - 'Completion server capabilities for {!s} are: {!r}'.format( - language, capabilities) - ) - - # This is required to start workspace before completion - # services when Spyder starts with an open project. - # TODO: Find a better solution for it in the future!! - projects = self.main.get_plugin(Plugins.Projects, error=False) - if projects: - projects.start_workspace_services() - - self.completion_capabilities[language] = dict(capabilities) - for editorstack in self.editorstacks: - editorstack.register_completion_capabilities( - capabilities, language) - - self.start_completion_services(language) - - def start_completion_services(self, language): - """Notify all editorstacks about LSP server availability.""" - for editorstack in self.editorstacks: - editorstack.start_completion_services(language) - - def stop_completion_services(self, language): - """Notify all editorstacks about LSP server unavailability.""" - for editorstack in self.editorstacks: - editorstack.stop_completion_services(language) - - def send_completion_request(self, language, request, params): - logger.debug("Perform request {0} for: {1}".format( - request, params['file'])) - try: - self.main.completions.send_request(language, request, params) - except AttributeError: - # Completions was closed - pass - - @Slot(str, tuple, dict) - def _rpc_call(self, method, args, kwargs): - meth = getattr(self, method) - meth(*args, **kwargs) - - #------ SpyderPluginWidget API --------------------------------------------- - @staticmethod - def get_plugin_title(): - """Return widget title""" - # TODO: This is a temporary measure to get the title of this plugin - # without creating an instance - title = _('Editor') - return title - - def get_plugin_icon(self): - """Return widget icon.""" - return ima.icon('edit') - - def get_focus_widget(self): - """ - Return the widget to give focus to. - - This happens when plugin's dockwidget is raised on top-level. - """ - return self.get_current_editor() - - def _visibility_changed(self, enable): - """DockWidget visibility has changed""" - SpyderPluginWidget._visibility_changed(self, enable) - if self.dockwidget is None: - return - if self.dockwidget.isWindow(): - self.dock_toolbar.show() - else: - self.dock_toolbar.hide() - if enable: - self.refresh_plugin() - self.sig_update_plugin_title.emit() - - def refresh_plugin(self): - """Refresh editor plugin""" - editorstack = self.get_current_editorstack() - editorstack.refresh() - self.refresh_save_all_action() - - def closing_plugin(self, cancelable=False): - """Perform actions before parent main window is closed""" - state = self.splitter.saveState() - self.set_option('splitter_state', qbytearray_to_str(state)) - editorstack = self.editorstacks[0] - - active_project_path = None - if self.projects is not None: - active_project_path = self.projects.get_active_project_path() - if not active_project_path: - self.set_open_filenames() - else: - self.projects.set_project_filenames( - [finfo.filename for finfo in editorstack.data]) - - self.set_option('layout_settings', - self.editorsplitter.get_layout_settings()) - self.set_option('windows_layout_settings', - [win.get_layout_settings() for win in self.editorwindows]) -# self.set_option('filenames', filenames) - self.set_option('recent_files', self.recent_files) - - # Stop autosave timer before closing windows - self.autosave.stop_autosave_timer() - - try: - if not editorstack.save_if_changed(cancelable) and cancelable: - return False - else: - for win in self.editorwindows[:]: - win.close() - return True - except IndexError: - return True - - def get_plugin_actions(self): - """Return a list of actions related to plugin""" - # ---- File menu and toolbar ---- - self.new_action = create_action( - self, - _("&New file..."), - icon=ima.icon('filenew'), tip=_("New file"), - triggered=self.new, - context=Qt.WidgetShortcut - ) - self.register_shortcut(self.new_action, context="Editor", - name="New file", add_shortcut_to_tip=True) - - self.open_last_closed_action = create_action( - self, - _("O&pen last closed"), - tip=_("Open last closed"), - triggered=self.open_last_closed - ) - self.register_shortcut(self.open_last_closed_action, context="Editor", - name="Open last closed") - - self.open_action = create_action(self, _("&Open..."), - icon=ima.icon('fileopen'), tip=_("Open file"), - triggered=self.load, - context=Qt.WidgetShortcut) - self.register_shortcut(self.open_action, context="Editor", - name="Open file", add_shortcut_to_tip=True) - - self.revert_action = create_action(self, _("&Revert"), - icon=ima.icon('revert'), tip=_("Revert file from disk"), - triggered=self.revert) - - self.save_action = create_action(self, _("&Save"), - icon=ima.icon('filesave'), tip=_("Save file"), - triggered=self.save, - context=Qt.WidgetShortcut) - self.register_shortcut(self.save_action, context="Editor", - name="Save file", add_shortcut_to_tip=True) - - self.save_all_action = create_action(self, _("Sav&e all"), - icon=ima.icon('save_all'), tip=_("Save all files"), - triggered=self.save_all, - context=Qt.WidgetShortcut) - self.register_shortcut(self.save_all_action, context="Editor", - name="Save all", add_shortcut_to_tip=True) - - save_as_action = create_action(self, _("Save &as..."), None, - ima.icon('filesaveas'), tip=_("Save current file as..."), - triggered=self.save_as, - context=Qt.WidgetShortcut) - self.register_shortcut(save_as_action, "Editor", "Save As") - - save_copy_as_action = create_action(self, _("Save copy as..."), None, - ima.icon('filesaveas'), _("Save copy of current file as..."), - triggered=self.save_copy_as) - - print_preview_action = create_action(self, _("Print preview..."), - tip=_("Print preview..."), triggered=self.print_preview) - self.print_action = create_action(self, _("&Print..."), - icon=ima.icon('print'), tip=_("Print current file..."), - triggered=self.print_file) - # Shortcut for close_action is defined in widgets/editor.py - self.close_action = create_action(self, _("&Close"), - icon=ima.icon('fileclose'), tip=_("Close current file"), - triggered=self.close_file) - - self.close_all_action = create_action(self, _("C&lose all"), - icon=ima.icon('filecloseall'), tip=_("Close all opened files"), - triggered=self.close_all_files, - context=Qt.WidgetShortcut) - self.register_shortcut(self.close_all_action, context="Editor", - name="Close all") - - # ---- Find menu and toolbar ---- - _text = _("&Find text") - find_action = create_action(self, _text, icon=ima.icon('find'), - tip=_text, triggered=self.find, - context=Qt.WidgetShortcut) - self.register_shortcut(find_action, context="find_replace", - name="Find text", add_shortcut_to_tip=True) - find_next_action = create_action(self, _("Find &next"), - icon=ima.icon('findnext'), - triggered=self.find_next, - context=Qt.WidgetShortcut) - self.register_shortcut(find_next_action, context="find_replace", - name="Find next") - find_previous_action = create_action(self, _("Find &previous"), - icon=ima.icon('findprevious'), - triggered=self.find_previous, - context=Qt.WidgetShortcut) - self.register_shortcut(find_previous_action, context="find_replace", - name="Find previous") - _text = _("&Replace text") - replace_action = create_action(self, _text, icon=ima.icon('replace'), - tip=_text, triggered=self.replace, - context=Qt.WidgetShortcut) - self.register_shortcut(replace_action, context="find_replace", - name="Replace text") - - # ---- Debug menu and toolbar ---- - set_clear_breakpoint_action = create_action(self, - _("Set/Clear breakpoint"), - icon=ima.icon('breakpoint_big'), - triggered=self.set_or_clear_breakpoint, - context=Qt.WidgetShortcut) - self.register_shortcut(set_clear_breakpoint_action, context="Editor", - name="Breakpoint") - - set_cond_breakpoint_action = create_action(self, - _("Set/Edit conditional breakpoint"), - icon=ima.icon('breakpoint_cond_big'), - triggered=self.set_or_edit_conditional_breakpoint, - context=Qt.WidgetShortcut) - self.register_shortcut(set_cond_breakpoint_action, context="Editor", - name="Conditional breakpoint") - - clear_all_breakpoints_action = create_action(self, - _('Clear breakpoints in all files'), - triggered=self.clear_all_breakpoints) - - # --- Debug toolbar --- - self.debug_action = create_action( - self, _("&Debug"), - icon=ima.icon('debug'), - tip=_("Debug file"), - triggered=self.debug_file) - self.register_shortcut(self.debug_action, context="_", name="Debug", - add_shortcut_to_tip=True) - - self.debug_next_action = create_action( - self, _("Step"), - icon=ima.icon('arrow-step-over'), tip=_("Run current line"), - triggered=lambda: self.debug_command("next")) - self.register_shortcut(self.debug_next_action, "_", "Debug Step Over", - add_shortcut_to_tip=True) - - self.debug_continue_action = create_action( - self, _("Continue"), - icon=ima.icon('arrow-continue'), - tip=_("Continue execution until next breakpoint"), - triggered=lambda: self.debug_command("continue")) - self.register_shortcut( - self.debug_continue_action, "_", "Debug Continue", - add_shortcut_to_tip=True) - - self.debug_step_action = create_action( - self, _("Step Into"), - icon=ima.icon('arrow-step-in'), - tip=_("Step into function or method of current line"), - triggered=lambda: self.debug_command("step")) - self.register_shortcut(self.debug_step_action, "_", "Debug Step Into", - add_shortcut_to_tip=True) - - self.debug_return_action = create_action( - self, _("Step Return"), - icon=ima.icon('arrow-step-out'), - tip=_("Run until current function or method returns"), - triggered=lambda: self.debug_command("return")) - self.register_shortcut( - self.debug_return_action, "_", "Debug Step Return", - add_shortcut_to_tip=True) - - self.debug_exit_action = create_action( - self, _("Stop"), - icon=ima.icon('stop_debug'), tip=_("Stop debugging"), - triggered=self.stop_debugging) - self.register_shortcut(self.debug_exit_action, "_", "Debug Exit", - add_shortcut_to_tip=True) - - # --- Run toolbar --- - run_action = create_action(self, _("&Run"), icon=ima.icon('run'), - tip=_("Run file"), - triggered=self.run_file) - self.register_shortcut(run_action, context="_", name="Run", - add_shortcut_to_tip=True) - - configure_action = create_action( - self, - _("&Configuration per file..."), - icon=ima.icon('run_settings'), - tip=_("Run settings"), - menurole=QAction.NoRole, - triggered=self.edit_run_configurations) - - self.register_shortcut(configure_action, context="_", - name="Configure", add_shortcut_to_tip=True) - - re_run_action = create_action(self, _("Re-run &last script"), - icon=ima.icon('run_again'), - tip=_("Run again last file"), - triggered=self.re_run_file) - self.register_shortcut(re_run_action, context="_", - name="Re-run last script", - add_shortcut_to_tip=True) - - run_selected_action = create_action(self, _("Run &selection or " - "current line"), - icon=ima.icon('run_selection'), - tip=_("Run selection or " - "current line"), - triggered=self.run_selection, - context=Qt.WidgetShortcut) - self.register_shortcut(run_selected_action, context="Editor", - name="Run selection", add_shortcut_to_tip=True) - - run_to_line_action = create_action(self, _("Run &to current line"), - tip=_("Run to current line"), - triggered=self.run_to_line, - context=Qt.WidgetShortcut) - self.register_shortcut(run_to_line_action, context="Editor", - name="Run to line", add_shortcut_to_tip=True) - - run_from_line_action = create_action(self, _("Run &from current line"), - tip=_("Run from current line"), - triggered=self.run_from_line, - context=Qt.WidgetShortcut) - self.register_shortcut(run_from_line_action, context="Editor", - name="Run from line", add_shortcut_to_tip=True) - - run_cell_action = create_action(self, - _("Run cell"), - icon=ima.icon('run_cell'), - tip=_("Run current cell \n" - "[Use #%% to create cells]"), - triggered=self.run_cell, - context=Qt.WidgetShortcut) - - self.register_shortcut(run_cell_action, context="Editor", - name="Run cell", add_shortcut_to_tip=True) - - run_cell_advance_action = create_action( - self, - _("Run cell and advance"), - icon=ima.icon('run_cell_advance'), - tip=_("Run current cell and go to the next one "), - triggered=self.run_cell_and_advance, - context=Qt.WidgetShortcut) - - self.register_shortcut(run_cell_advance_action, context="Editor", - name="Run cell and advance", - add_shortcut_to_tip=True) - - self.debug_cell_action = create_action( - self, - _("Debug cell"), - icon=ima.icon('debug_cell'), - tip=_("Debug current cell " - "(Alt+Shift+Enter)"), - triggered=self.debug_cell, - context=Qt.WidgetShortcut) - - self.register_shortcut(self.debug_cell_action, context="Editor", - name="Debug cell", - add_shortcut_to_tip=True) - - re_run_last_cell_action = create_action(self, - _("Re-run last cell"), - tip=_("Re run last cell "), - triggered=self.re_run_last_cell, - context=Qt.WidgetShortcut) - self.register_shortcut(re_run_last_cell_action, - context="Editor", - name='re-run last cell', - add_shortcut_to_tip=True) - - # --- Source code Toolbar --- - self.todo_list_action = create_action(self, - _("Show todo list"), icon=ima.icon('todo_list'), - tip=_("Show comments list (TODO/FIXME/XXX/HINT/TIP/@todo/" - "HACK/BUG/OPTIMIZE/!!!/???)"), - triggered=self.go_to_next_todo) - self.todo_menu = QMenu(self) - self.todo_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") - self.todo_list_action.setMenu(self.todo_menu) - self.todo_menu.aboutToShow.connect(self.update_todo_menu) - - self.warning_list_action = create_action(self, - _("Show warning/error list"), icon=ima.icon('wng_list'), - tip=_("Show code analysis warnings/errors"), - triggered=self.go_to_next_warning) - self.warning_menu = QMenu(self) - self.warning_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") - self.warning_list_action.setMenu(self.warning_menu) - self.warning_menu.aboutToShow.connect(self.update_warning_menu) - self.previous_warning_action = create_action(self, - _("Previous warning/error"), icon=ima.icon('prev_wng'), - tip=_("Go to previous code analysis warning/error"), - triggered=self.go_to_previous_warning, - context=Qt.WidgetShortcut) - self.register_shortcut(self.previous_warning_action, - context="Editor", - name="Previous warning", - add_shortcut_to_tip=True) - self.next_warning_action = create_action(self, - _("Next warning/error"), icon=ima.icon('next_wng'), - tip=_("Go to next code analysis warning/error"), - triggered=self.go_to_next_warning, - context=Qt.WidgetShortcut) - self.register_shortcut(self.next_warning_action, - context="Editor", - name="Next warning", - add_shortcut_to_tip=True) - - self.previous_edit_cursor_action = create_action(self, - _("Last edit location"), icon=ima.icon('last_edit_location'), - tip=_("Go to last edit location"), - triggered=self.go_to_last_edit_location, - context=Qt.WidgetShortcut) - self.register_shortcut(self.previous_edit_cursor_action, - context="Editor", - name="Last edit location", - add_shortcut_to_tip=True) - self.previous_cursor_action = create_action(self, - _("Previous cursor position"), icon=ima.icon('prev_cursor'), - tip=_("Go to previous cursor position"), - triggered=self.go_to_previous_cursor_position, - context=Qt.WidgetShortcut) - self.register_shortcut(self.previous_cursor_action, - context="Editor", - name="Previous cursor position", - add_shortcut_to_tip=True) - self.next_cursor_action = create_action(self, - _("Next cursor position"), icon=ima.icon('next_cursor'), - tip=_("Go to next cursor position"), - triggered=self.go_to_next_cursor_position, - context=Qt.WidgetShortcut) - self.register_shortcut(self.next_cursor_action, - context="Editor", - name="Next cursor position", - add_shortcut_to_tip=True) - - # --- Edit Toolbar --- - self.toggle_comment_action = create_action(self, - _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), - tip=_("Comment current line or selection"), - triggered=self.toggle_comment, context=Qt.WidgetShortcut) - self.register_shortcut(self.toggle_comment_action, context="Editor", - name="Toggle comment") - blockcomment_action = create_action(self, _("Add &block comment"), - tip=_("Add block comment around " - "current line or selection"), - triggered=self.blockcomment, context=Qt.WidgetShortcut) - self.register_shortcut(blockcomment_action, context="Editor", - name="Blockcomment") - unblockcomment_action = create_action(self, - _("R&emove block comment"), - tip = _("Remove comment block around " - "current line or selection"), - triggered=self.unblockcomment, context=Qt.WidgetShortcut) - self.register_shortcut(unblockcomment_action, context="Editor", - name="Unblockcomment") - - # ---------------------------------------------------------------------- - # The following action shortcuts are hard-coded in CodeEditor - # keyPressEvent handler (the shortcut is here only to inform user): - # (context=Qt.WidgetShortcut -> disable shortcut for other widgets) - self.indent_action = create_action(self, - _("Indent"), "Tab", icon=ima.icon('indent'), - tip=_("Indent current line or selection"), - triggered=self.indent, context=Qt.WidgetShortcut) - self.unindent_action = create_action(self, - _("Unindent"), "Shift+Tab", icon=ima.icon('unindent'), - tip=_("Unindent current line or selection"), - triggered=self.unindent, context=Qt.WidgetShortcut) - - self.text_uppercase_action = create_action(self, - _("Toggle Uppercase"), icon=ima.icon('toggle_uppercase'), - tip=_("Change to uppercase current line or selection"), - triggered=self.text_uppercase, context=Qt.WidgetShortcut) - self.register_shortcut(self.text_uppercase_action, context="Editor", - name="transform to uppercase") - - self.text_lowercase_action = create_action(self, - _("Toggle Lowercase"), icon=ima.icon('toggle_lowercase'), - tip=_("Change to lowercase current line or selection"), - triggered=self.text_lowercase, context=Qt.WidgetShortcut) - self.register_shortcut(self.text_lowercase_action, context="Editor", - name="transform to lowercase") - # ---------------------------------------------------------------------- - - self.win_eol_action = create_action( - self, - _("CRLF (Windows)"), - toggled=lambda checked: self.toggle_eol_chars('nt', checked) - ) - self.linux_eol_action = create_action( - self, - _("LF (Unix)"), - toggled=lambda checked: self.toggle_eol_chars('posix', checked) - ) - self.mac_eol_action = create_action( - self, - _("CR (macOS)"), - toggled=lambda checked: self.toggle_eol_chars('mac', checked) - ) - eol_action_group = QActionGroup(self) - eol_actions = (self.win_eol_action, self.linux_eol_action, - self.mac_eol_action) - add_actions(eol_action_group, eol_actions) - eol_menu = QMenu(_("Convert end-of-line characters"), self) - eol_menu.setObjectName('checkbox-padding') - add_actions(eol_menu, eol_actions) - - trailingspaces_action = create_action( - self, - _("Remove trailing spaces"), - triggered=self.remove_trailing_spaces) - - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '') - self.formatting_action = create_action( - self, - _('Format file or selection with {0}').format( - formatter.capitalize()), - shortcut=CONF.get_shortcut('editor', 'autoformatting'), - context=Qt.WidgetShortcut, - triggered=self.format_document_or_selection) - self.formatting_action.setEnabled(False) - - # Checkable actions - showblanks_action = self._create_checkable_action( - _("Show blank spaces"), 'blank_spaces', 'set_blanks_enabled') - - scrollpastend_action = self._create_checkable_action( - _("Scroll past the end"), 'scroll_past_end', - 'set_scrollpastend_enabled') - - showindentguides_action = self._create_checkable_action( - _("Show indent guides"), 'indent_guides', 'set_indent_guides') - - showcodefolding_action = self._create_checkable_action( - _("Show code folding"), 'code_folding', 'set_code_folding_enabled') - - show_classfunc_dropdown_action = self._create_checkable_action( - _("Show selector for classes and functions"), - 'show_class_func_dropdown', 'set_classfunc_dropdown_visible') - - show_codestyle_warnings_action = self._create_checkable_action( - _("Show code style warnings"), 'pycodestyle',) - - show_docstring_warnings_action = self._create_checkable_action( - _("Show docstring style warnings"), 'pydocstyle') - - underline_errors = self._create_checkable_action( - _("Underline errors and warnings"), - 'underline_errors', 'set_underline_errors_enabled') - - self.checkable_actions = { - 'blank_spaces': showblanks_action, - 'scroll_past_end': scrollpastend_action, - 'indent_guides': showindentguides_action, - 'code_folding': showcodefolding_action, - 'show_class_func_dropdown': show_classfunc_dropdown_action, - 'pycodestyle': show_codestyle_warnings_action, - 'pydocstyle': show_docstring_warnings_action, - 'underline_errors': underline_errors} - - fixindentation_action = create_action(self, _("Fix indentation"), - tip=_("Replace tab characters by space characters"), - triggered=self.fix_indentation) - - gotoline_action = create_action(self, _("Go to line..."), - icon=ima.icon('gotoline'), - triggered=self.go_to_line, - context=Qt.WidgetShortcut) - self.register_shortcut(gotoline_action, context="Editor", - name="Go to line") - - workdir_action = create_action(self, - _("Set console working directory"), - icon=ima.icon('DirOpenIcon'), - tip=_("Set current console (and file explorer) working " - "directory to current script directory"), - triggered=self.__set_workdir) - - self.max_recent_action = create_action(self, - _("Maximum number of recent files..."), - triggered=self.change_max_recent_files) - self.clear_recent_action = create_action(self, - _("Clear this list"), tip=_("Clear recent files list"), - triggered=self.clear_recent_files) - - # Fixes spyder-ide/spyder#6055. - # See: https://bugreports.qt.io/browse/QTBUG-8596 - self.tab_navigation_actions = [] - if sys.platform == 'darwin': - self.go_to_next_file_action = create_action( - self, - _("Go to next file"), - shortcut=CONF.get_shortcut('editor', 'go to previous file'), - triggered=self.go_to_next_file, - ) - self.go_to_previous_file_action = create_action( - self, - _("Go to previous file"), - shortcut=CONF.get_shortcut('editor', 'go to next file'), - triggered=self.go_to_previous_file, - ) - self.register_shortcut( - self.go_to_next_file_action, - context="Editor", - name="Go to next file", - ) - self.register_shortcut( - self.go_to_previous_file_action, - context="Editor", - name="Go to previous file", - ) - self.tab_navigation_actions = [ - MENU_SEPARATOR, - self.go_to_previous_file_action, - self.go_to_next_file_action, - ] - - # ---- File menu/toolbar construction ---- - self.recent_file_menu = QMenu(_("Open &recent"), self) - self.recent_file_menu.aboutToShow.connect(self.update_recent_file_menu) - - from spyder.plugins.mainmenu.api import ( - ApplicationMenus, FileMenuSections) - # New Section - self.main.mainmenu.add_item_to_application_menu( - self.new_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.New, - before_section=FileMenuSections.Restart, - omit_id=True) - # Open section - open_actions = [ - self.open_action, - self.open_last_closed_action, - self.recent_file_menu, - ] - for open_action in open_actions: - self.main.mainmenu.add_item_to_application_menu( - open_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Open, - before_section=FileMenuSections.Restart, - omit_id=True) - # Save section - save_actions = [ - self.save_action, - self.save_all_action, - save_as_action, - save_copy_as_action, - self.revert_action, - ] - for save_action in save_actions: - self.main.mainmenu.add_item_to_application_menu( - save_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Save, - before_section=FileMenuSections.Restart, - omit_id=True) - # Print - print_actions = [ - print_preview_action, - self.print_action, - ] - for print_action in print_actions: - self.main.mainmenu.add_item_to_application_menu( - print_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Print, - before_section=FileMenuSections.Restart, - omit_id=True) - # Close - close_actions = [ - self.close_action, - self.close_all_action - ] - for close_action in close_actions: - self.main.mainmenu.add_item_to_application_menu( - close_action, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Close, - before_section=FileMenuSections.Restart, - omit_id=True) - # Navigation - if sys.platform == 'darwin': - self.main.mainmenu.add_item_to_application_menu( - self.tab_navigation_actions, - menu_id=ApplicationMenus.File, - section=FileMenuSections.Navigation, - before_section=FileMenuSections.Restart, - omit_id=True) - - file_toolbar_actions = ([self.new_action, self.open_action, - self.save_action, self.save_all_action] + - self.main.file_toolbar_actions) - - self.main.file_toolbar_actions += file_toolbar_actions - - # ---- Find menu/toolbar construction ---- - search_menu_actions = [find_action, - find_next_action, - find_previous_action, - replace_action, - gotoline_action] - - self.main.search_toolbar_actions = [find_action, - find_next_action, - replace_action] - - # ---- Edit menu/toolbar construction ---- - self.edit_menu_actions = [self.toggle_comment_action, - blockcomment_action, unblockcomment_action, - self.indent_action, self.unindent_action, - self.text_uppercase_action, - self.text_lowercase_action] - - # ---- Search menu/toolbar construction ---- - if not hasattr(self.main, 'search_menu_actions'): - # This list will not exist in the fast tests. - self.main.search_menu_actions = [] - - self.main.search_menu_actions = ( - search_menu_actions + self.main.search_menu_actions) - - # ---- Run menu/toolbar construction ---- - run_menu_actions = [run_action, run_cell_action, - run_cell_advance_action, - re_run_last_cell_action, MENU_SEPARATOR, - run_selected_action, run_to_line_action, - run_from_line_action, re_run_action, - configure_action, MENU_SEPARATOR] - self.main.run_menu_actions = ( - run_menu_actions + self.main.run_menu_actions) - run_toolbar_actions = [run_action, run_cell_action, - run_cell_advance_action, run_selected_action] - self.main.run_toolbar_actions += run_toolbar_actions - - # ---- Debug menu/toolbar construction ---- - debug_menu_actions = [ - self.debug_action, - self.debug_cell_action, - self.debug_next_action, - self.debug_step_action, - self.debug_return_action, - self.debug_continue_action, - self.debug_exit_action, - MENU_SEPARATOR, - set_clear_breakpoint_action, - set_cond_breakpoint_action, - clear_all_breakpoints_action, - ] - self.main.debug_menu_actions = ( - debug_menu_actions + self.main.debug_menu_actions) - debug_toolbar_actions = [ - self.debug_action, - self.debug_next_action, - self.debug_step_action, - self.debug_return_action, - self.debug_continue_action, - self.debug_exit_action - ] - self.main.debug_toolbar_actions += debug_toolbar_actions - - # ---- Source menu/toolbar construction ---- - source_menu_actions = [ - showblanks_action, - scrollpastend_action, - showindentguides_action, - showcodefolding_action, - show_classfunc_dropdown_action, - show_codestyle_warnings_action, - show_docstring_warnings_action, - underline_errors, - MENU_SEPARATOR, - self.todo_list_action, - self.warning_list_action, - self.previous_warning_action, - self.next_warning_action, - MENU_SEPARATOR, - self.previous_edit_cursor_action, - self.previous_cursor_action, - self.next_cursor_action, - MENU_SEPARATOR, - eol_menu, - trailingspaces_action, - fixindentation_action, - self.formatting_action - ] - self.main.source_menu_actions = ( - source_menu_actions + self.main.source_menu_actions) - - # ---- Dock widget and file dependent actions ---- - self.dock_toolbar_actions = ( - file_toolbar_actions + - [MENU_SEPARATOR] + - run_toolbar_actions + - [MENU_SEPARATOR] + - debug_toolbar_actions - ) - self.pythonfile_dependent_actions = [ - run_action, - configure_action, - set_clear_breakpoint_action, - set_cond_breakpoint_action, - self.debug_action, - self.debug_cell_action, - run_selected_action, - run_cell_action, - run_cell_advance_action, - re_run_last_cell_action, - blockcomment_action, - unblockcomment_action, - ] - self.cythonfile_compatible_actions = [run_action, configure_action] - self.file_dependent_actions = ( - self.pythonfile_dependent_actions + - [ - self.save_action, - save_as_action, - save_copy_as_action, - print_preview_action, - self.print_action, - self.save_all_action, - gotoline_action, - workdir_action, - self.close_action, - self.close_all_action, - self.toggle_comment_action, - self.revert_action, - self.indent_action, - self.unindent_action - ] - ) - self.stack_menu_actions = [gotoline_action, workdir_action] - - return self.file_dependent_actions - - def update_pdb_state(self, state, last_step): - """ - Enable/disable debugging actions and handle pdb state change. - - Some examples depending on the debugging state: - self.debug_action.setEnabled(not state) - self.debug_cell_action.setEnabled(not state) - self.debug_next_action.setEnabled(state) - self.debug_step_action.setEnabled(state) - self.debug_return_action.setEnabled(state) - self.debug_continue_action.setEnabled(state) - self.debug_exit_action.setEnabled(state) - """ - current_editor = self.get_current_editor() - if current_editor: - current_editor.update_debugger_panel_state(state, last_step) - - def register_plugin(self): - """Register plugin in Spyder's main window""" - completions = self.main.get_plugin(Plugins.Completions, error=False) - outlineexplorer = self.main.get_plugin( - Plugins.OutlineExplorer, error=False) - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - - self.main.restore_scrollbar_position.connect( - self.restore_scrollbar_position) - self.main.console.sig_edit_goto_requested.connect(self.load) - self.redirect_stdio.connect(self.main.redirect_internalshell_stdio) - - if completions: - self.main.completions.sig_language_completions_available.connect( - self.register_completion_capabilities) - self.main.completions.sig_open_file.connect(self.load) - self.main.completions.sig_editor_rpc.connect(self._rpc_call) - self.main.completions.sig_stop_completions.connect( - self.stop_completion_services) - - self.sig_file_opened_closed_or_updated.connect( - self.main.completions.file_opened_closed_or_updated) - - if outlineexplorer: - self.set_outlineexplorer(self.main.outlineexplorer) - - if ipyconsole: - ipyconsole.register_spyder_kernel_call_handler( - 'cell_count', self.handle_cell_count) - ipyconsole.register_spyder_kernel_call_handler( - 'current_filename', self.handle_current_filename) - ipyconsole.register_spyder_kernel_call_handler( - 'get_file_code', self.handle_get_file_code) - ipyconsole.register_spyder_kernel_call_handler( - 'run_cell', self.handle_run_cell) - - self.add_dockwidget() - self.update_pdb_state(False, {}) - - # Add modes to switcher - self.switcher_manager = EditorSwitcherManager( - self, - self.main.switcher, - lambda: self.get_current_editor(), - lambda: self.get_current_editorstack(), - section=self.get_plugin_title()) - - def update_source_menu(self, options, **kwargs): - option_names = [opt[-1] if isinstance(opt, tuple) else opt - for opt in options] - named_options = dict(zip(option_names, options)) - for name, action in self.checkable_actions.items(): - if name in named_options: - if name == 'underline_errors': - section = 'editor' - opt = 'underline_errors' - else: - section = 'completions' - opt = named_options[name] - - state = self.get_option(opt, section=section) - - # Avoid triggering the action when this action changes state - # See: spyder-ide/spyder#9915 - action.blockSignals(True) - action.setChecked(state) - action.blockSignals(False) - - def update_font(self): - """Update font from Preferences""" - font = self.get_font() - color_scheme = self.get_color_scheme() - for editorstack in self.editorstacks: - editorstack.set_default_font(font, color_scheme) - completion_size = CONF.get('main', 'completion/size') - for finfo in editorstack.data: - comp_widget = finfo.editor.completion_widget - kite_call_to_action = finfo.editor.kite_call_to_action - comp_widget.setup_appearance(completion_size, font) - kite_call_to_action.setFont(font) - - def set_ancestor(self, ancestor): - """ - Set ancestor of child widgets like the CompletionWidget. - - Needed to properly set position of the widget based on the correct - parent/ancestor. - - See spyder-ide/spyder#11076 - """ - for editorstack in self.editorstacks: - for finfo in editorstack.data: - comp_widget = finfo.editor.completion_widget - kite_call_to_action = finfo.editor.kite_call_to_action - - # This is necessary to catch an error when the plugin is - # undocked and docked back, and (probably) a completion is - # in progress. - # Fixes spyder-ide/spyder#17486 - try: - comp_widget.setParent(ancestor) - kite_call_to_action.setParent(ancestor) - except RuntimeError: - pass - - def _create_checkable_action(self, text, conf_name, method=''): - """Helper function to create a checkable action. - - Args: - text (str): Text to be displayed in the action. - conf_name (str): configuration setting associated with the - action - method (str): name of EditorStack class that will be used - to update the changes in each editorstack. - """ - def toogle(checked): - self.switch_to_plugin() - self._toggle_checkable_action(checked, method, conf_name) - - action = create_action(self, text, toggled=toogle) - action.blockSignals(True) - - if conf_name not in ['pycodestyle', 'pydocstyle']: - action.setChecked(self.get_option(conf_name)) - else: - opt = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', conf_name), - False - ) - action.setChecked(opt) - - action.blockSignals(False) - - return action - - @Slot(bool, str, str) - def _toggle_checkable_action(self, checked, method_name, conf_name): - """ - Handle the toogle of a checkable action. - - Update editorstacks, PyLS and CONF. - - Args: - checked (bool): State of the action. - method_name (str): name of EditorStack class that will be used - to update the changes in each editorstack. - conf_name (str): configuration setting associated with the - action. - """ - if method_name: - if self.editorstacks: - for editorstack in self.editorstacks: - try: - method = getattr(editorstack, method_name) - method(checked) - except AttributeError as e: - logger.error(e, exc_info=True) - self.set_option(conf_name, checked) - else: - if conf_name in ('pycodestyle', 'pydocstyle'): - CONF.set( - 'completions', - ('provider_configuration', 'lsp', 'values', conf_name), - checked) - if self.main.get_plugin(Plugins.Completions, error=False): - completions = self.main.completions - completions.after_configuration_update([]) - - #------ Focus tabwidget - def __get_focused_editorstack(self): - fwidget = QApplication.focusWidget() - if isinstance(fwidget, EditorStack): - return fwidget - else: - for editorstack in self.editorstacks: - if editorstack.isAncestorOf(fwidget): - return editorstack - - def set_last_focused_editorstack(self, editorwindow, editorstack): - self.last_focused_editorstack[editorwindow] = editorstack - # very last editorstack - self.last_focused_editorstack[None] = editorstack - - def get_last_focused_editorstack(self, editorwindow=None): - return self.last_focused_editorstack[editorwindow] - - def remove_last_focused_editorstack(self, editorstack): - for editorwindow, widget in list( - self.last_focused_editorstack.items()): - if widget is editorstack: - self.last_focused_editorstack[editorwindow] = None - - def save_focused_editorstack(self): - editorstack = self.__get_focused_editorstack() - if editorstack is not None: - for win in [self]+self.editorwindows: - if win.isAncestorOf(editorstack): - self.set_last_focused_editorstack(win, editorstack) - - # ------ Handling editorstacks - def register_editorstack(self, editorstack): - self.editorstacks.append(editorstack) - self.register_widget_shortcuts(editorstack) - - if self.isAncestorOf(editorstack): - # editorstack is a child of the Editor plugin - self.set_last_focused_editorstack(self, editorstack) - editorstack.set_closable(len(self.editorstacks) > 1) - if self.outlineexplorer is not None: - editorstack.set_outlineexplorer( - self.outlineexplorer.get_widget()) - editorstack.set_find_widget(self.find_widget) - editorstack.reset_statusbar.connect(self.readwrite_status.hide) - editorstack.reset_statusbar.connect(self.encoding_status.hide) - editorstack.reset_statusbar.connect(self.cursorpos_status.hide) - editorstack.readonly_changed.connect( - self.readwrite_status.update_readonly) - editorstack.encoding_changed.connect( - self.encoding_status.update_encoding) - editorstack.sig_editor_cursor_position_changed.connect( - self.cursorpos_status.update_cursor_position) - editorstack.sig_editor_cursor_position_changed.connect( - self.current_editor_cursor_changed) - editorstack.sig_refresh_eol_chars.connect( - self.eol_status.update_eol) - editorstack.current_file_changed.connect( - self.vcs_status.update_vcs) - editorstack.file_saved.connect( - self.vcs_status.update_vcs_state) - - editorstack.set_io_actions(self.new_action, self.open_action, - self.save_action, self.revert_action) - editorstack.set_tempfile_path(self.TEMPFILE_PATH) - - settings = ( - ('set_todolist_enabled', 'todo_list'), - ('set_blanks_enabled', 'blank_spaces'), - ('set_underline_errors_enabled', 'underline_errors'), - ('set_scrollpastend_enabled', 'scroll_past_end'), - ('set_linenumbers_enabled', 'line_numbers'), - ('set_edgeline_enabled', 'edge_line'), - ('set_indent_guides', 'indent_guides'), - ('set_code_folding_enabled', 'code_folding'), - ('set_focus_to_editor', 'focus_to_editor'), - ('set_run_cell_copy', 'run_cell_copy'), - ('set_close_parentheses_enabled', 'close_parentheses'), - ('set_close_quotes_enabled', 'close_quotes'), - ('set_add_colons_enabled', 'add_colons'), - ('set_auto_unindent_enabled', 'auto_unindent'), - ('set_indent_chars', 'indent_chars'), - ('set_tab_stop_width_spaces', 'tab_stop_width_spaces'), - ('set_wrap_enabled', 'wrap'), - ('set_tabmode_enabled', 'tab_always_indent'), - ('set_stripmode_enabled', 'strip_trailing_spaces_on_modify'), - ('set_intelligent_backspace_enabled', 'intelligent_backspace'), - ('set_automatic_completions_enabled', 'automatic_completions'), - ('set_automatic_completions_after_chars', - 'automatic_completions_after_chars'), - ('set_automatic_completions_after_ms', - 'automatic_completions_after_ms'), - ('set_completions_hint_enabled', 'completions_hint'), - ('set_completions_hint_after_ms', - 'completions_hint_after_ms'), - ('set_highlight_current_line_enabled', 'highlight_current_line'), - ('set_highlight_current_cell_enabled', 'highlight_current_cell'), - ('set_occurrence_highlighting_enabled', 'occurrence_highlighting'), - ('set_occurrence_highlighting_timeout', 'occurrence_highlighting/timeout'), - ('set_checkeolchars_enabled', 'check_eol_chars'), - ('set_tabbar_visible', 'show_tab_bar'), - ('set_classfunc_dropdown_visible', 'show_class_func_dropdown'), - ('set_always_remove_trailing_spaces', 'always_remove_trailing_spaces'), - ('set_remove_trailing_newlines', 'always_remove_trailing_newlines'), - ('set_add_newline', 'add_newline'), - ('set_convert_eol_on_save', 'convert_eol_on_save'), - ('set_convert_eol_on_save_to', 'convert_eol_on_save_to'), - ) - - for method, setting in settings: - getattr(editorstack, method)(self.get_option(setting)) - - editorstack.set_help_enabled(CONF.get('help', 'connect/editor')) - - hover_hints = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', - 'enable_hover_hints'), - True - ) - - format_on_save = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'format_on_save'), - False - ) - - edge_line_columns = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', - 'pycodestyle/max_line_length'), - 79 - ) - - editorstack.set_hover_hints_enabled(hover_hints) - editorstack.set_format_on_save(format_on_save) - editorstack.set_edgeline_columns(edge_line_columns) - color_scheme = self.get_color_scheme() - editorstack.set_default_font(self.get_font(), color_scheme) - - editorstack.starting_long_process.connect(self.starting_long_process) - editorstack.ending_long_process.connect(self.ending_long_process) - - # Redirect signals - editorstack.sig_option_changed.connect(self.sig_option_changed) - editorstack.redirect_stdio.connect( - lambda state: self.redirect_stdio.emit(state)) - editorstack.exec_in_extconsole.connect( - lambda text, option: - self.exec_in_extconsole.emit(text, option)) - editorstack.run_cell_in_ipyclient.connect(self.run_cell_in_ipyclient) - editorstack.debug_cell_in_ipyclient.connect( - self.debug_cell_in_ipyclient) - editorstack.update_plugin_title.connect( - lambda: self.sig_update_plugin_title.emit()) - editorstack.editor_focus_changed.connect(self.save_focused_editorstack) - editorstack.editor_focus_changed.connect(self.main.plugin_focus_changed) - editorstack.editor_focus_changed.connect(self.sig_editor_focus_changed) - editorstack.zoom_in.connect(lambda: self.zoom(1)) - editorstack.zoom_out.connect(lambda: self.zoom(-1)) - editorstack.zoom_reset.connect(lambda: self.zoom(0)) - editorstack.sig_open_file.connect(self.report_open_file) - editorstack.sig_new_file.connect(lambda s: self.new(text=s)) - editorstack.sig_new_file[()].connect(self.new) - editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) - editorstack.sig_close_file.connect(self.remove_file_cursor_history) - editorstack.file_saved.connect(self.file_saved_in_editorstack) - editorstack.file_renamed_in_data.connect( - self.file_renamed_in_data_in_editorstack) - editorstack.opened_files_list_changed.connect( - self.opened_files_list_changed) - editorstack.active_languages_stats.connect( - self.update_active_languages) - editorstack.sig_go_to_definition.connect( - lambda fname, line, col: self.load( - fname, line, start_column=col)) - editorstack.sig_perform_completion_request.connect( - self.send_completion_request) - editorstack.todo_results_changed.connect(self.todo_results_changed) - editorstack.update_code_analysis_actions.connect( - self.update_code_analysis_actions) - editorstack.update_code_analysis_actions.connect( - self.update_todo_actions) - editorstack.refresh_file_dependent_actions.connect( - self.refresh_file_dependent_actions) - editorstack.refresh_save_all_action.connect(self.refresh_save_all_action) - editorstack.sig_refresh_eol_chars.connect(self.refresh_eol_chars) - editorstack.sig_refresh_formatting.connect(self.refresh_formatting) - editorstack.sig_breakpoints_saved.connect(self.breakpoints_saved) - editorstack.text_changed_at.connect(self.text_changed_at) - editorstack.current_file_changed.connect(self.current_file_changed) - editorstack.plugin_load.connect(self.load) - editorstack.plugin_load[()].connect(self.load) - editorstack.edit_goto.connect(self.load) - editorstack.sig_save_as.connect(self.save_as) - editorstack.sig_prev_edit_pos.connect(self.go_to_last_edit_location) - editorstack.sig_prev_cursor.connect(self.go_to_previous_cursor_position) - editorstack.sig_next_cursor.connect(self.go_to_next_cursor_position) - editorstack.sig_prev_warning.connect(self.go_to_previous_warning) - editorstack.sig_next_warning.connect(self.go_to_next_warning) - editorstack.sig_save_bookmark.connect(self.save_bookmark) - editorstack.sig_load_bookmark.connect(self.load_bookmark) - editorstack.sig_save_bookmarks.connect(self.save_bookmarks) - editorstack.sig_help_requested.connect(self.sig_help_requested) - - # Register editorstack's autosave component with plugin's autosave - # component - self.autosave.register_autosave_for_stack(editorstack.autosave) - - def unregister_editorstack(self, editorstack): - """Removing editorstack only if it's not the last remaining""" - self.remove_last_focused_editorstack(editorstack) - if len(self.editorstacks) > 1: - index = self.editorstacks.index(editorstack) - self.editorstacks.pop(index) - return True - else: - # editorstack was not removed! - return False - - def clone_editorstack(self, editorstack): - editorstack.clone_from(self.editorstacks[0]) - for finfo in editorstack.data: - self.register_widget_shortcuts(finfo.editor) - - @Slot(str, str) - def close_file_in_all_editorstacks(self, editorstack_id_str, filename): - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.blockSignals(True) - index = editorstack.get_index_from_filename(filename) - editorstack.close_file(index, force=True) - editorstack.blockSignals(False) - - @Slot(str, str, str) - def file_saved_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was saved in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.file_saved_in_other_editorstack(original_filename, - filename) - - @Slot(str, str, str) - def file_renamed_in_data_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was renamed in data in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.rename_in_data(original_filename, filename) - - #------ Handling editor windows - def setup_other_windows(self): - """Setup toolbars and menus for 'New window' instances""" - # TODO: All the actions here should be taken from - # the MainMenus plugin - file_menu_actions = self.main.mainmenu.get_application_menu( - ApplicationMenus.File).get_actions() - tools_menu_actions = self.main.mainmenu.get_application_menu( - ApplicationMenus.Tools).get_actions() - help_menu_actions = self.main.mainmenu.get_application_menu( - ApplicationMenus.Help).get_actions() - - self.toolbar_list = ((_("File toolbar"), "file_toolbar", - self.main.file_toolbar_actions), - (_("Run toolbar"), "run_toolbar", - self.main.run_toolbar_actions), - (_("Debug toolbar"), "debug_toolbar", - self.main.debug_toolbar_actions)) - - self.menu_list = ((_("&File"), file_menu_actions), - (_("&Edit"), self.main.edit_menu_actions), - (_("&Search"), self.main.search_menu_actions), - (_("Sour&ce"), self.main.source_menu_actions), - (_("&Run"), self.main.run_menu_actions), - (_("&Tools"), tools_menu_actions), - (_("&View"), []), - (_("&Help"), help_menu_actions)) - # Create pending new windows: - for layout_settings in self.editorwindows_to_be_created: - win = self.create_new_window() - win.set_layout_settings(layout_settings) - - def switch_to_plugin(self): - """ - Reimplemented method to deactivate shortcut when - opening a new window. - """ - if not self.editorwindows: - super(Editor, self).switch_to_plugin() - - def create_new_window(self): - window = EditorMainWindow( - self, self.stack_menu_actions, self.toolbar_list, self.menu_list) - window.add_toolbars_to_menu("&View", window.get_toolbars()) - window.load_toolbars() - window.resize(self.size()) - window.show() - window.editorwidget.editorsplitter.editorstack.new_window = True - self.register_editorwindow(window) - window.destroyed.connect(lambda: self.unregister_editorwindow(window)) - return window - - def register_editorwindow(self, window): - self.editorwindows.append(window) - - def unregister_editorwindow(self, window): - self.editorwindows.pop(self.editorwindows.index(window)) - - - #------ Accessors - def get_filenames(self): - return [finfo.filename for finfo in self.editorstacks[0].data] - - def get_filename_index(self, filename): - return self.editorstacks[0].has_filename(filename) - - def get_current_editorstack(self, editorwindow=None): - if self.editorstacks is not None: - if len(self.editorstacks) == 1: - editorstack = self.editorstacks[0] - else: - editorstack = self.__get_focused_editorstack() - if editorstack is None or editorwindow is not None: - editorstack = self.get_last_focused_editorstack( - editorwindow) - if editorstack is None: - editorstack = self.editorstacks[0] - return editorstack - - def get_current_editor(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_editor() - - def get_current_finfo(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_finfo() - - def get_current_filename(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_filename() - - def get_current_language(self): - editorstack = self.get_current_editorstack() - if editorstack is not None: - return editorstack.get_current_language() - - def is_file_opened(self, filename=None): - return self.editorstacks[0].is_file_opened(filename) - - def set_current_filename(self, filename, editorwindow=None, focus=True): - """Set focus to *filename* if this file has been opened. - - Return the editor instance associated to *filename*. - """ - editorstack = self.get_current_editorstack(editorwindow) - return editorstack.set_current_filename(filename, focus) - - def set_path(self): - for finfo in self.editorstacks[0].data: - finfo.path = self.main.get_spyder_pythonpath() - - #------ Refresh methods - def refresh_file_dependent_actions(self): - """Enable/disable file dependent actions - (only if dockwidget is visible)""" - if self.dockwidget and self.dockwidget.isVisible(): - enable = self.get_current_editor() is not None - for action in self.file_dependent_actions: - action.setEnabled(enable) - - def refresh_save_all_action(self): - """Enable 'Save All' if there are files to be saved""" - editorstack = self.get_current_editorstack() - if editorstack: - state = any(finfo.editor.document().isModified() or finfo.newly_created - for finfo in editorstack.data) - self.save_all_action.setEnabled(state) - - def update_warning_menu(self): - """Update warning list menu""" - editor = self.get_current_editor() - check_results = editor.get_current_warnings() - self.warning_menu.clear() - filename = self.get_current_filename() - for message, line_number in check_results: - error = 'syntax' in message - text = message[:1].upper() + message[1:] - icon = ima.icon('error') if error else ima.icon('warning') - slot = lambda _checked, _l=line_number: self.load(filename, goto=_l) - action = create_action(self, text=text, icon=icon) - action.triggered[bool].connect(slot) - self.warning_menu.addAction(action) - - def update_todo_menu(self): - """Update todo list menu""" - editorstack = self.get_current_editorstack() - results = editorstack.get_todo_results() - self.todo_menu.clear() - filename = self.get_current_filename() - for text, line0 in results: - icon = ima.icon('todo') - slot = lambda _checked, _l=line0: self.load(filename, goto=_l) - action = create_action(self, text=text, icon=icon) - action.triggered[bool].connect(slot) - self.todo_menu.addAction(action) - self.update_todo_actions() - - def todo_results_changed(self): - """ - Synchronize todo results between editorstacks - Refresh todo list navigation buttons - """ - editorstack = self.get_current_editorstack() - results = editorstack.get_todo_results() - index = editorstack.get_stack_index() - if index != -1: - filename = editorstack.data[index].filename - for other_editorstack in self.editorstacks: - if other_editorstack is not editorstack: - other_editorstack.set_todo_results(filename, results) - self.update_todo_actions() - - def refresh_eol_chars(self, os_name): - os_name = to_text_string(os_name) - self.__set_eol_chars = False - if os_name == 'nt': - self.win_eol_action.setChecked(True) - elif os_name == 'posix': - self.linux_eol_action.setChecked(True) - else: - self.mac_eol_action.setChecked(True) - self.__set_eol_chars = True - - def refresh_formatting(self, status): - self.formatting_action.setEnabled(status) - - def refresh_formatter_name(self): - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '') - self.formatting_action.setText( - _('Format file or selection with {0}').format( - formatter.capitalize())) - - #------ Slots - def opened_files_list_changed(self): - """ - Opened files list has changed: - --> open/close file action - --> modification ('*' added to title) - --> current edited file has changed - """ - # Refresh Python file dependent actions: - editor = self.get_current_editor() - if editor: - python_enable = editor.is_python_or_ipython() - cython_enable = python_enable or ( - programs.is_module_installed('Cython') and editor.is_cython()) - for action in self.pythonfile_dependent_actions: - if action in self.cythonfile_compatible_actions: - enable = cython_enable - else: - enable = python_enable - action.setEnabled(enable) - self.sig_file_opened_closed_or_updated.emit( - self.get_current_filename(), self.get_current_language()) - - def update_code_analysis_actions(self): - """Update actions in the warnings menu.""" - editor = self.get_current_editor() - - # To fix an error at startup - if editor is None: - return - - # Update actions state if there are errors present - for action in (self.warning_list_action, self.previous_warning_action, - self.next_warning_action): - action.setEnabled(editor.errors_present()) - - def update_todo_actions(self): - editorstack = self.get_current_editorstack() - results = editorstack.get_todo_results() - state = (self.get_option('todo_list') and - results is not None and len(results)) - if state is not None: - self.todo_list_action.setEnabled(state) - - @Slot(set) - def update_active_languages(self, languages): - if self.main.get_plugin(Plugins.Completions, error=False): - self.main.completions.update_client_status(languages) - - # ------ Bookmarks - def save_bookmarks(self, filename, bookmarks): - """Receive bookmark changes and save them.""" - filename = to_text_string(filename) - bookmarks = to_text_string(bookmarks) - filename = osp.normpath(osp.abspath(filename)) - bookmarks = eval(bookmarks) - save_bookmarks(filename, bookmarks) - - #------ File I/O - def __load_temp_file(self): - """Load temporary file from a text file in user home directory""" - if not osp.isfile(self.TEMPFILE_PATH): - # Creating temporary file - default = ['# -*- coding: utf-8 -*-', - '"""', _("Spyder Editor"), '', - _("This is a temporary script file."), - '"""', '', ''] - text = os.linesep.join([encoding.to_unicode(qstr) - for qstr in default]) - try: - encoding.write(to_text_string(text), self.TEMPFILE_PATH, - 'utf-8') - except EnvironmentError: - self.new() - return - - self.load(self.TEMPFILE_PATH) - - @Slot() - def __set_workdir(self): - """Set current script directory as working directory""" - fname = self.get_current_filename() - if fname is not None: - directory = osp.dirname(osp.abspath(fname)) - self.sig_dir_opened.emit(directory) - - def __add_recent_file(self, fname): - """Add to recent file list""" - if fname is None: - return - if fname in self.recent_files: - self.recent_files.remove(fname) - self.recent_files.insert(0, fname) - if len(self.recent_files) > self.get_option('max_recent_files'): - self.recent_files.pop(-1) - - def _clone_file_everywhere(self, finfo): - """Clone file (*src_editor* widget) in all editorstacks - Cloning from the first editorstack in which every single new editor - is created (when loading or creating a new file)""" - for editorstack in self.editorstacks[1:]: - editor = editorstack.clone_editor_from(finfo, set_current=False) - self.register_widget_shortcuts(editor) - - - @Slot() - @Slot(str) - def new(self, fname=None, editorstack=None, text=None): - """ - Create a new file - Untitled - - fname=None --> fname will be 'untitledXX.py' but do not create file - fname= --> create file - """ - # If no text is provided, create default content - empty = False - try: - if text is None: - default_content = True - text, enc = encoding.read(self.TEMPLATE_PATH) - enc_match = re.search(r'-*- coding: ?([a-z0-9A-Z\-]*) -*-', - text) - if enc_match: - enc = enc_match.group(1) - # Initialize template variables - # Windows - username = encoding.to_unicode_from_fs( - os.environ.get('USERNAME', '')) - # Linux, Mac OS X - if not username: - username = encoding.to_unicode_from_fs( - os.environ.get('USER', '-')) - VARS = { - 'date': time.ctime(), - 'username': username, - } - try: - text = text % VARS - except Exception: - pass - else: - default_content = False - enc = encoding.read(self.TEMPLATE_PATH)[1] - except (IOError, OSError): - text = '' - enc = 'utf-8' - default_content = True - - create_fname = lambda n: to_text_string(_("untitled")) + ("%d.py" % n) - # Creating editor widget - if editorstack is None: - current_es = self.get_current_editorstack() - else: - current_es = editorstack - created_from_here = fname is None - if created_from_here: - if self.untitled_num == 0: - for finfo in current_es.data: - current_filename = finfo.editor.filename - if _("untitled") in current_filename: - # Start the counter of the untitled_num with respect - # to this number if there's other untitled file in - # spyder. Please see spyder-ide/spyder#7831 - fname_data = osp.splitext(current_filename) - try: - act_num = int( - fname_data[0].split(_("untitled"))[-1]) - self.untitled_num = act_num + 1 - except ValueError: - # Catch the error in case the user has something - # different from a number after the untitled - # part. - # Please see spyder-ide/spyder#12892 - self.untitled_num = 0 - while True: - fname = create_fname(self.untitled_num) - self.untitled_num += 1 - if not osp.isfile(fname): - break - basedir = getcwd_or_home() - - projects = self.main.get_plugin(Plugins.Projects, error=False) - if projects and projects.get_active_project() is not None: - basedir = projects.get_active_project_path() - else: - c_fname = self.get_current_filename() - if c_fname is not None and c_fname != self.TEMPFILE_PATH: - basedir = osp.dirname(c_fname) - fname = osp.abspath(osp.join(basedir, fname)) - else: - # QString when triggered by a Qt signal - fname = osp.abspath(to_text_string(fname)) - index = current_es.has_filename(fname) - if index is not None and not current_es.close_file(index): - return - - # Creating the editor widget in the first editorstack (the one that - # can't be destroyed), then cloning this editor widget in all other - # editorstacks: - # Setting empty to True by default to avoid the additional space - # created at the end of the templates. - # See: spyder-ide/spyder#12596 - finfo = self.editorstacks[0].new(fname, enc, text, default_content, - empty=True) - finfo.path = self.main.get_spyder_pythonpath() - self._clone_file_everywhere(finfo) - current_editor = current_es.set_current_filename(finfo.filename) - self.register_widget_shortcuts(current_editor) - if not created_from_here: - self.save(force=True) - - def edit_template(self): - """Edit new file template""" - self.load(self.TEMPLATE_PATH) - - def update_recent_file_menu(self): - """Update recent file menu""" - recent_files = [] - for fname in self.recent_files: - if osp.isfile(fname): - recent_files.append(fname) - self.recent_file_menu.clear() - if recent_files: - for fname in recent_files: - action = create_action( - self, fname, - icon=ima.get_icon_by_extension_or_type( - fname, scale_factor=1.0)) - action.triggered[bool].connect(self.load) - action.setData(to_qvariant(fname)) - self.recent_file_menu.addAction(action) - self.clear_recent_action.setEnabled(len(recent_files) > 0) - add_actions(self.recent_file_menu, (None, self.max_recent_action, - self.clear_recent_action)) - - @Slot() - def clear_recent_files(self): - """Clear recent files list""" - self.recent_files = [] - - @Slot() - def change_max_recent_files(self): - "Change max recent files entries""" - editorstack = self.get_current_editorstack() - mrf, valid = QInputDialog.getInt(editorstack, _('Editor'), - _('Maximum number of recent files'), - self.get_option('max_recent_files'), 1, 35) - if valid: - self.set_option('max_recent_files', mrf) - - @Slot() - @Slot(str) - @Slot(str, int, str) - @Slot(str, int, str, object) - def load(self, filenames=None, goto=None, word='', - editorwindow=None, processevents=True, start_column=None, - end_column=None, set_focus=True, add_where='end'): - """ - Load a text file - editorwindow: load in this editorwindow (useful when clicking on - outline explorer with multiple editor windows) - processevents: determines if processEvents() should be called at the - end of this method (set to False to prevent keyboard events from - creeping through to the editor during debugging) - If goto is not none it represent a line to go to. start_column is - the start position in this line and end_column the length - (So that the end position is start_column + end_column) - Alternatively, the first match of word is used as a position. - """ - cursor_history_state = self.__ignore_cursor_history - self.__ignore_cursor_history = True - # Switch to editor before trying to load a file - try: - self.switch_to_plugin() - except AttributeError: - pass - - editor0 = self.get_current_editor() - if editor0 is not None: - filename0 = self.get_current_filename() - else: - filename0 = None - if not filenames: - # Recent files action - action = self.sender() - if isinstance(action, QAction): - filenames = from_qvariant(action.data(), to_text_string) - if not filenames: - basedir = getcwd_or_home() - if self.edit_filetypes is None: - self.edit_filetypes = get_edit_filetypes() - if self.edit_filters is None: - self.edit_filters = get_edit_filters() - - c_fname = self.get_current_filename() - if c_fname is not None and c_fname != self.TEMPFILE_PATH: - basedir = osp.dirname(c_fname) - - self.redirect_stdio.emit(False) - parent_widget = self.get_current_editorstack() - if filename0 is not None: - selectedfilter = get_filter(self.edit_filetypes, - osp.splitext(filename0)[1]) - else: - selectedfilter = '' - - if not running_under_pytest(): - # See: spyder-ide/spyder#3291 - if sys.platform == 'darwin': - dialog = QFileDialog( - parent=parent_widget, - caption=_("Open file"), - directory=basedir, - ) - dialog.setNameFilters(self.edit_filters.split(';;')) - dialog.setOption(QFileDialog.HideNameFilterDetails, True) - dialog.setFilter(QDir.AllDirs | QDir.Files | QDir.Drives - | QDir.Hidden) - dialog.setFileMode(QFileDialog.ExistingFiles) - - if dialog.exec_(): - filenames = dialog.selectedFiles() - else: - filenames, _sf = getopenfilenames( - parent_widget, - _("Open file"), - basedir, - self.edit_filters, - selectedfilter=selectedfilter, - options=QFileDialog.HideNameFilterDetails, - ) - else: - # Use a Qt (i.e. scriptable) dialog for pytest - dialog = QFileDialog(parent_widget, _("Open file"), - options=QFileDialog.DontUseNativeDialog) - if dialog.exec_(): - filenames = dialog.selectedFiles() - - self.redirect_stdio.emit(True) - - if filenames: - filenames = [osp.normpath(fname) for fname in filenames] - else: - self.__ignore_cursor_history = cursor_history_state - return - - focus_widget = QApplication.focusWidget() - if self.editorwindows and not self.dockwidget.isVisible(): - # We override the editorwindow variable to force a focus on - # the editor window instead of the hidden editor dockwidget. - # See spyder-ide/spyder#5742. - if editorwindow not in self.editorwindows: - editorwindow = self.editorwindows[0] - editorwindow.setFocus() - editorwindow.raise_() - elif (self.dockwidget and not self._ismaximized - and not self.dockwidget.isAncestorOf(focus_widget) - and not isinstance(focus_widget, CodeEditor)): - self.switch_to_plugin() - - def _convert(fname): - fname = osp.abspath(encoding.to_unicode_from_fs(fname)) - if os.name == 'nt' and len(fname) >= 2 and fname[1] == ':': - fname = fname[0].upper()+fname[1:] - return fname - - if hasattr(filenames, 'replaceInStrings'): - # This is a QStringList instance (PyQt API #1), converting to list: - filenames = list(filenames) - if not isinstance(filenames, list): - filenames = [_convert(filenames)] - else: - filenames = [_convert(fname) for fname in list(filenames)] - if isinstance(goto, int): - goto = [goto] - elif goto is not None and len(goto) != len(filenames): - goto = None - - for index, filename in enumerate(filenames): - # -- Do not open an already opened file - focus = set_focus and index == 0 - current_editor = self.set_current_filename(filename, - editorwindow, - focus=focus) - if current_editor is None: - # -- Not a valid filename: - if not osp.isfile(filename): - continue - # -- - current_es = self.get_current_editorstack(editorwindow) - # Creating the editor widget in the first editorstack - # (the one that can't be destroyed), then cloning this - # editor widget in all other editorstacks: - finfo = self.editorstacks[0].load( - filename, set_current=False, add_where=add_where, - processevents=processevents) - finfo.path = self.main.get_spyder_pythonpath() - self._clone_file_everywhere(finfo) - current_editor = current_es.set_current_filename(filename, - focus=focus) - current_editor.debugger.load_breakpoints() - current_editor.set_bookmarks(load_bookmarks(filename)) - self.register_widget_shortcuts(current_editor) - current_es.analyze_script() - self.__add_recent_file(filename) - if goto is not None: # 'word' is assumed to be None as well - current_editor.go_to_line(goto[index], word=word, - start_column=start_column, - end_column=end_column) - current_editor.clearFocus() - current_editor.setFocus() - current_editor.window().raise_() - if processevents: - QApplication.processEvents() - else: - # processevents is false only when calling from debugging - current_editor.sig_debug_stop.emit(goto[index]) - - ipyconsole = self.main.get_plugin( - Plugins.IPythonConsole, error=False) - if ipyconsole: - current_sw = ipyconsole.get_current_shellwidget() - current_sw.sig_prompt_ready.connect( - current_editor.sig_debug_stop[()]) - current_pdb_state = ipyconsole.get_pdb_state() - pdb_last_step = ipyconsole.get_pdb_last_step() - self.update_pdb_state(current_pdb_state, pdb_last_step) - - self.__ignore_cursor_history = cursor_history_state - self.add_cursor_to_history() - - def _create_print_editor(self): - """Create a SimpleCodeEditor instance to print file contents.""" - editor = SimpleCodeEditor(self) - editor.setup_editor( - color_scheme="scintilla", highlight_current_line=False - ) - return editor - - @Slot() - def print_file(self): - """Print current file.""" - editor = self.get_current_editor() - filename = self.get_current_filename() - - # Set print editor - self._print_editor.set_text(editor.toPlainText()) - self._print_editor.set_language(editor.language) - self._print_editor.set_font(self.get_font()) - - # Create printer - printer = Printer(mode=QPrinter.HighResolution, - header_font=self.get_font()) - print_dialog = QPrintDialog(printer, self._print_editor) - - # Adjust print options when user has selected text - if editor.has_selected_text(): - print_dialog.setOption(QAbstractPrintDialog.PrintSelection, True) - - # Copy selection from current editor to print editor - cursor_1 = editor.textCursor() - start, end = cursor_1.selectionStart(), cursor_1.selectionEnd() - - cursor_2 = self._print_editor.textCursor() - cursor_2.setPosition(start) - cursor_2.setPosition(end, QTextCursor.KeepAnchor) - self._print_editor.setTextCursor(cursor_2) - - # Print - self.redirect_stdio.emit(False) - answer = print_dialog.exec_() - self.redirect_stdio.emit(True) - - if answer == QDialog.Accepted: - self.starting_long_process(_("Printing...")) - printer.setDocName(filename) - self._print_editor.print_(printer) - self.ending_long_process() - - # Clear selection - self._print_editor.textCursor().removeSelectedText() - - @Slot() - def print_preview(self): - """Print preview for current file.""" - editor = self.get_current_editor() - - # Set print editor - self._print_editor.set_text(editor.toPlainText()) - self._print_editor.set_language(editor.language) - self._print_editor.set_font(self.get_font()) - - # Create printer - printer = Printer(mode=QPrinter.HighResolution, - header_font=self.get_font()) - - # Create preview - preview = QPrintPreviewDialog(printer, self) - preview.setWindowFlags(Qt.Window) - preview.paintRequested.connect( - lambda printer: self._print_editor.print_(printer) - ) - - # Show preview - self.redirect_stdio.emit(False) - preview.exec_() - self.redirect_stdio.emit(True) - - def can_close_file(self, filename=None): - """ - Check if a file can be closed taking into account debugging state. - """ - if not CONF.get('ipython_console', 'pdb_prevent_closing'): - return True - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - - debugging = False - last_pdb_step = {} - if ipyconsole: - debugging = ipyconsole.get_pdb_state() - last_pdb_step = ipyconsole.get_pdb_last_step() - - can_close = True - if debugging and 'fname' in last_pdb_step and filename: - if osp.normcase(last_pdb_step['fname']) == osp.normcase(filename): - can_close = False - self.sig_file_debug_message_requested.emit() - elif debugging: - can_close = False - self.sig_file_debug_message_requested.emit() - return can_close - - @Slot() - def close_file(self): - """Close current file""" - filename = self.get_current_filename() - if self.can_close_file(filename=filename): - editorstack = self.get_current_editorstack() - editorstack.close_file() - - @Slot() - def close_all_files(self): - """Close all opened scripts""" - self.editorstacks[0].close_all_files() - - @Slot() - def save(self, index=None, force=False): - """Save file""" - editorstack = self.get_current_editorstack() - return editorstack.save(index=index, force=force) - - @Slot() - def save_as(self): - """Save *as* the currently edited file""" - editorstack = self.get_current_editorstack() - if editorstack.save_as(): - fname = editorstack.get_current_filename() - self.__add_recent_file(fname) - - @Slot() - def save_copy_as(self): - """Save *copy as* the currently edited file""" - editorstack = self.get_current_editorstack() - editorstack.save_copy_as() - - @Slot() - def save_all(self, save_new_files=True): - """Save all opened files""" - self.get_current_editorstack().save_all(save_new_files=save_new_files) - - @Slot() - def revert(self): - """Revert the currently edited file from disk""" - editorstack = self.get_current_editorstack() - editorstack.revert() - - @Slot() - def find(self): - """Find slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.show() - editorstack.find_widget.search_text.setFocus() - - @Slot() - def find_next(self): - """Fnd next slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.find_next() - - @Slot() - def find_previous(self): - """Find previous slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.find_previous() - - @Slot() - def replace(self): - """Replace slot""" - editorstack = self.get_current_editorstack() - editorstack.find_widget.show_replace() - - def open_last_closed(self): - """ Reopens the last closed tab.""" - editorstack = self.get_current_editorstack() - last_closed_files = editorstack.get_last_closed_files() - if (len(last_closed_files) > 0): - file_to_open = last_closed_files[0] - last_closed_files.remove(file_to_open) - editorstack.set_last_closed_files(last_closed_files) - self.load(file_to_open) - - #------ Explorer widget - def close_file_from_name(self, filename): - """Close file from its name""" - filename = osp.abspath(to_text_string(filename)) - index = self.editorstacks[0].has_filename(filename) - if index is not None: - self.editorstacks[0].close_file(index) - - def removed(self, filename): - """File was removed in file explorer widget or in project explorer""" - self.close_file_from_name(filename) - - def removed_tree(self, dirname): - """Directory was removed in project explorer widget""" - dirname = osp.abspath(to_text_string(dirname)) - for fname in self.get_filenames(): - if osp.abspath(fname).startswith(dirname): - self.close_file_from_name(fname) - - def renamed(self, source, dest): - """ - Propagate file rename to editor stacks and autosave component. - - This function is called when a file is renamed in the file explorer - widget or the project explorer. The file may not be opened in the - editor. - """ - filename = osp.abspath(to_text_string(source)) - index = self.editorstacks[0].has_filename(filename) - if index is not None: - for editorstack in self.editorstacks: - editorstack.rename_in_data(filename, - new_filename=to_text_string(dest)) - self.editorstacks[0].autosave.file_renamed( - filename, to_text_string(dest)) - - def renamed_tree(self, source, dest): - """Directory was renamed in file explorer or in project explorer.""" - dirname = osp.abspath(to_text_string(source)) - tofile = to_text_string(dest) - for fname in self.get_filenames(): - if osp.abspath(fname).startswith(dirname): - new_filename = fname.replace(dirname, tofile) - self.renamed(source=fname, dest=new_filename) - - #------ Source code - @Slot() - def indent(self): - """Indent current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.indent() - - @Slot() - def unindent(self): - """Unindent current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.unindent() - - @Slot() - def text_uppercase(self): - """Change current line or selection to uppercase.""" - editor = self.get_current_editor() - if editor is not None: - editor.transform_to_uppercase() - - @Slot() - def text_lowercase(self): - """Change current line or selection to lowercase.""" - editor = self.get_current_editor() - if editor is not None: - editor.transform_to_lowercase() - - @Slot() - def toggle_comment(self): - """Comment current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.toggle_comment() - - @Slot() - def blockcomment(self): - """Block comment current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.blockcomment() - - @Slot() - def unblockcomment(self): - """Un-block comment current line or selection""" - editor = self.get_current_editor() - if editor is not None: - editor.unblockcomment() - @Slot() - def go_to_next_todo(self): - self.switch_to_plugin() - editor = self.get_current_editor() - editor.go_to_next_todo() - filename = self.get_current_filename() - cursor = editor.textCursor() - self.add_cursor_to_history(filename, cursor) - - @Slot() - def go_to_next_warning(self): - self.switch_to_plugin() - editor = self.get_current_editor() - editor.go_to_next_warning() - filename = self.get_current_filename() - cursor = editor.textCursor() - self.add_cursor_to_history(filename, cursor) - - @Slot() - def go_to_previous_warning(self): - self.switch_to_plugin() - editor = self.get_current_editor() - editor.go_to_previous_warning() - filename = self.get_current_filename() - cursor = editor.textCursor() - self.add_cursor_to_history(filename, cursor) - - def toggle_eol_chars(self, os_name, checked): - if checked: - editor = self.get_current_editor() - if self.__set_eol_chars: - self.switch_to_plugin() - editor.set_eol_chars( - eol_chars=sourcecode.get_eol_chars_from_os_name(os_name) - ) - - @Slot() - def remove_trailing_spaces(self): - self.switch_to_plugin() - editorstack = self.get_current_editorstack() - editorstack.remove_trailing_spaces() - - @Slot() - def format_document_or_selection(self): - self.switch_to_plugin() - editorstack = self.get_current_editorstack() - editorstack.format_document_or_selection() - - @Slot() - def fix_indentation(self): - self.switch_to_plugin() - editorstack = self.get_current_editorstack() - editorstack.fix_indentation() - - #------ Cursor position history management - def update_cursorpos_actions(self): - self.previous_edit_cursor_action.setEnabled( - self.last_edit_cursor_pos is not None) - self.previous_cursor_action.setEnabled( - len(self.cursor_undo_history) > 0) - self.next_cursor_action.setEnabled( - len(self.cursor_redo_history) > 0) - - def add_cursor_to_history(self, filename=None, cursor=None): - if self.__ignore_cursor_history: - return - if filename is None: - filename = self.get_current_filename() - if cursor is None: - editor = self._get_editor(filename) - if editor is None: - return - cursor = editor.textCursor() - - replace_last_entry = False - if len(self.cursor_undo_history) > 0: - fname, hist_cursor = self.cursor_undo_history[-1] - if fname == filename: - if cursor.blockNumber() == hist_cursor.blockNumber(): - # Only one cursor per line - replace_last_entry = True - - if replace_last_entry: - self.cursor_undo_history.pop() - else: - # Drop redo stack as we moved - self.cursor_redo_history = [] - - self.cursor_undo_history.append((filename, cursor)) - self.update_cursorpos_actions() - - def text_changed_at(self, filename, position): - self.last_edit_cursor_pos = (to_text_string(filename), position) - - def current_file_changed(self, filename, position, line, column): - cursor = self.get_current_editor().textCursor() - self.add_cursor_to_history(to_text_string(filename), cursor) - - # Hide any open tooltips - current_stack = self.get_current_editorstack() - if current_stack is not None: - current_stack.hide_tooltip() - - # Update debugging state - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - if ipyconsole is not None: - pdb_state = ipyconsole.get_pdb_state() - pdb_last_step = ipyconsole.get_pdb_last_step() - self.update_pdb_state(pdb_state, pdb_last_step) - - def current_editor_cursor_changed(self, line, column): - """Handles the change of the cursor inside the current editor.""" - code_editor = self.get_current_editor() - filename = code_editor.filename - cursor = code_editor.textCursor() - self.add_cursor_to_history( - to_text_string(filename), cursor) - - def remove_file_cursor_history(self, id, filename): - """Remove the cursor history of a file if the file is closed.""" - new_history = [] - for i, (cur_filename, cursor) in enumerate( - self.cursor_undo_history): - if cur_filename != filename: - new_history.append((cur_filename, cursor)) - self.cursor_undo_history = new_history - - new_redo_history = [] - for i, (cur_filename, cursor) in enumerate( - self.cursor_redo_history): - if cur_filename != filename: - new_redo_history.append((cur_filename, cursor)) - self.cursor_redo_history = new_redo_history - - @Slot() - def go_to_last_edit_location(self): - if self.last_edit_cursor_pos is not None: - filename, position = self.last_edit_cursor_pos - if not osp.isfile(filename): - self.last_edit_cursor_pos = None - return - else: - self.load(filename) - editor = self.get_current_editor() - if position < editor.document().characterCount(): - editor.set_cursor_position(position) - - def _pop_next_cursor_diff(self, history, current_filename, current_cursor): - """Get the next cursor from history that is different from current.""" - while history: - filename, cursor = history.pop() - if (filename != current_filename or - cursor.position() != current_cursor.position()): - return filename, cursor - return None, None - - def _history_steps(self, number_steps, - backwards_history, forwards_history, - current_filename, current_cursor): - """ - Move number_steps in the forwards_history, filling backwards_history. - """ - for i in range(number_steps): - if len(forwards_history) > 0: - # Put the current cursor in history - backwards_history.append( - (current_filename, current_cursor)) - # Extract the next different cursor - current_filename, current_cursor = ( - self._pop_next_cursor_diff( - forwards_history, - current_filename, current_cursor)) - if current_cursor is None: - # Went too far, back up once - current_filename, current_cursor = ( - backwards_history.pop()) - return current_filename, current_cursor - - - def __move_cursor_position(self, index_move): - """ - Move the cursor position forward or backward in the cursor - position history by the specified index increment. - """ - self.__ignore_cursor_history = True - # Remove last position as it will be replaced by the current position - if self.cursor_undo_history: - self.cursor_undo_history.pop() - - # Update last position on the line - current_filename = self.get_current_filename() - current_cursor = self.get_current_editor().textCursor() - - if index_move < 0: - # Undo - current_filename, current_cursor = self._history_steps( - -index_move, - self.cursor_redo_history, - self.cursor_undo_history, - current_filename, current_cursor) - - else: - # Redo - current_filename, current_cursor = self._history_steps( - index_move, - self.cursor_undo_history, - self.cursor_redo_history, - current_filename, current_cursor) - - # Place current cursor in history - self.cursor_undo_history.append( - (current_filename, current_cursor)) - filenames = self.get_current_editorstack().get_filenames() - if (not osp.isfile(current_filename) - and current_filename not in filenames): - self.cursor_undo_history.pop() - else: - self.load(current_filename) - editor = self.get_current_editor() - editor.setTextCursor(current_cursor) - editor.ensureCursorVisible() - self.__ignore_cursor_history = False - self.update_cursorpos_actions() - - @Slot() - def go_to_previous_cursor_position(self): - self.__ignore_cursor_history = True - self.switch_to_plugin() - self.__move_cursor_position(-1) - - @Slot() - def go_to_next_cursor_position(self): - self.__ignore_cursor_history = True - self.switch_to_plugin() - self.__move_cursor_position(1) - - @Slot() - def go_to_line(self, line=None): - """Open 'go to line' dialog""" - if isinstance(line, bool): - line = None - editorstack = self.get_current_editorstack() - if editorstack is not None: - editorstack.go_to_line(line) - - @Slot() - def set_or_clear_breakpoint(self): - """Set/Clear breakpoint""" - editorstack = self.get_current_editorstack() - if editorstack is not None: - self.switch_to_plugin() - editorstack.set_or_clear_breakpoint() - - @Slot() - def set_or_edit_conditional_breakpoint(self): - """Set/Edit conditional breakpoint""" - editorstack = self.get_current_editorstack() - if editorstack is not None: - self.switch_to_plugin() - editorstack.set_or_edit_conditional_breakpoint() - - @Slot() - def clear_all_breakpoints(self): - """Clear breakpoints in all files""" - self.switch_to_plugin() - clear_all_breakpoints() - self.breakpoints_saved.emit() - editorstack = self.get_current_editorstack() - if editorstack is not None: - for data in editorstack.data: - data.editor.debugger.clear_breakpoints() - self.refresh_plugin() - - def clear_breakpoint(self, filename, lineno): - """Remove a single breakpoint""" - clear_breakpoint(filename, lineno) - self.breakpoints_saved.emit() - editorstack = self.get_current_editorstack() - if editorstack is not None: - index = self.is_file_opened(filename) - if index is not None: - editorstack.data[index].editor.debugger.toogle_breakpoint( - lineno) - - def stop_debugging(self): - """Stop debugging""" - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - if ipyconsole: - ipyconsole.stop_debugging() - - def debug_command(self, command): - """Debug actions""" - self.switch_to_plugin() - ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) - if ipyconsole: - ipyconsole.pdb_execute_command(command) - ipyconsole.switch_to_plugin() - - # ----- Handlers for the IPython Console kernels - def _get_editorstack(self): - """ - Get the current editorstack. - - Raises an exception in case no editorstack is found - """ - editorstack = self.get_current_editorstack() - if editorstack is None: - raise RuntimeError('No editorstack found.') - - return editorstack - - def _get_editor(self, filename): - """Get editor for filename and set it as the current editor.""" - editorstack = self._get_editorstack() - if editorstack is None: - return None - - if not filename: - return None - - index = editorstack.has_filename(filename) - if index is None: - return None - - return editorstack.data[index].editor - - def handle_run_cell(self, cell_name, filename): - """ - Get cell code from cell name and file name. - """ - editorstack = self._get_editorstack() - editor = self._get_editor(filename) - - if editor is None: - raise RuntimeError( - "File {} not open in the editor".format(filename)) - - editorstack.last_cell_call = (filename, cell_name) - - # The file is open, load code from editor - return editor.get_cell_code(cell_name) - - def handle_cell_count(self, filename): - """Get number of cells in file to loop.""" - editor = self._get_editor(filename) - - if editor is None: - raise RuntimeError( - "File {} not open in the editor".format(filename)) - - # The file is open, get cell count from editor - return editor.get_cell_count() - - def handle_current_filename(self, filename): - """Get the current filename.""" - return self._get_editorstack().get_current_finfo().filename - - def handle_get_file_code(self, filename, save_all=True): - """ - Return the bytes that compose the file. - - Bytes are returned instead of str to support non utf-8 files. - """ - editorstack = self._get_editorstack() - if save_all and CONF.get( - 'editor', 'save_all_before_run', default=True): - editorstack.save_all(save_new_files=False) - editor = self._get_editor(filename) - - if editor is None: - # Load it from file instead - text, _enc = encoding.read(filename) - return text - - return editor.toPlainText() - - #------ Run Python script - @Slot() - def edit_run_configurations(self): - dialog = RunConfigDialog(self) - dialog.size_change.connect(lambda s: self.set_dialog_size(s)) - if self.dialog_size is not None: - dialog.resize(self.dialog_size) - fname = osp.abspath(self.get_current_filename()) - dialog.setup(fname) - if dialog.exec_(): - fname = dialog.file_to_run - if fname is not None: - self.load(fname) - self.run_file() - - @Slot() - def run_file(self, debug=False): - """Run script inside current interpreter or in a new one""" - editorstack = self.get_current_editorstack() - - editor = self.get_current_editor() - fname = osp.abspath(self.get_current_filename()) - - # Get fname's dirname before we escape the single and double - # quotes. Fixes spyder-ide/spyder#6771. - dirname = osp.dirname(fname) - - # Escape single and double quotes in fname and dirname. - # Fixes spyder-ide/spyder#2158. - fname = fname.replace("'", r"\'").replace('"', r'\"') - dirname = dirname.replace("'", r"\'").replace('"', r'\"') - - runconf = get_run_configuration(fname) - if runconf is None: - dialog = RunConfigOneDialog(self) - dialog.size_change.connect(lambda s: self.set_dialog_size(s)) - if self.dialog_size is not None: - dialog.resize(self.dialog_size) - dialog.setup(fname) - if CONF.get('run', 'open_at_least_once', - not running_under_pytest()): - # Open Run Config dialog at least once: the first time - # a script is ever run in Spyder, so that the user may - # see it at least once and be conscious that it exists - show_dlg = True - CONF.set('run', 'open_at_least_once', False) - else: - # Open Run Config dialog only - # if ALWAYS_OPEN_FIRST_RUN_OPTION option is enabled - show_dlg = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION) - if show_dlg and not dialog.exec_(): - return - runconf = dialog.get_configuration() - - if runconf.default: - # use global run preferences settings - runconf = RunConfiguration() - - args = runconf.get_arguments() - python_args = runconf.get_python_arguments() - interact = runconf.interact - post_mortem = runconf.post_mortem - current = runconf.current - systerm = runconf.systerm - clear_namespace = runconf.clear_namespace - console_namespace = runconf.console_namespace - - if runconf.file_dir: - wdir = dirname - elif runconf.cw_dir: - wdir = '' - elif osp.isdir(runconf.dir): - wdir = runconf.dir - else: - wdir = '' - - python = True # Note: in the future, it may be useful to run - # something in a terminal instead of a Python interp. - self.__last_ec_exec = (fname, wdir, args, interact, debug, - python, python_args, current, systerm, - post_mortem, clear_namespace, - console_namespace) - self.re_run_file(save_new_files=False) - if not interact and not debug: - # If external console dockwidget is hidden, it will be - # raised in top-level and so focus will be given to the - # current external shell automatically - # (see SpyderPluginWidget.visibility_changed method) - editor.setFocus() - - def set_dialog_size(self, size): - self.dialog_size = size - - @Slot() - def debug_file(self): - """Debug current script""" - self.switch_to_plugin() - current_editor = self.get_current_editor() - if current_editor is not None: - current_editor.sig_debug_start.emit() - self.run_file(debug=True) - - @Slot() - def re_run_file(self, save_new_files=True): - """Re-run last script""" - if self.get_option('save_all_before_run'): - all_saved = self.save_all(save_new_files=save_new_files) - if all_saved is not None and not all_saved: - return - if self.__last_ec_exec is None: - return - (fname, wdir, args, interact, debug, - python, python_args, current, systerm, - post_mortem, clear_namespace, - console_namespace) = self.__last_ec_exec - if not systerm: - self.run_in_current_ipyclient.emit(fname, wdir, args, - debug, post_mortem, - current, clear_namespace, - console_namespace) - else: - self.main.open_external_console(fname, wdir, args, interact, - debug, python, python_args, - systerm, post_mortem) - - @Slot() - def run_selection(self): - """Run selection or current line in external console""" - editorstack = self.get_current_editorstack() - editorstack.run_selection() - - @Slot() - def run_to_line(self): - """Run all lines from beginning up to current line""" - editorstack = self.get_current_editorstack() - editorstack.run_to_line() - - @Slot() - def run_from_line(self): - """Run all lines from current line to end""" - editorstack = self.get_current_editorstack() - editorstack.run_from_line() - - @Slot() - def run_cell(self): - """Run current cell""" - editorstack = self.get_current_editorstack() - editorstack.run_cell() - - @Slot() - def run_cell_and_advance(self): - """Run current cell and advance to the next one""" - editorstack = self.get_current_editorstack() - editorstack.run_cell_and_advance() - - @Slot() - def debug_cell(self): - '''Debug Current cell.''' - editorstack = self.get_current_editorstack() - editorstack.debug_cell() - - @Slot() - def re_run_last_cell(self): - """Run last executed cell.""" - editorstack = self.get_current_editorstack() - editorstack.re_run_last_cell() - - # ------ Code bookmarks - @Slot(int) - def save_bookmark(self, slot_num): - """Save current line and position as bookmark.""" - bookmarks = CONF.get('editor', 'bookmarks') - editorstack = self.get_current_editorstack() - if slot_num in bookmarks: - filename, line_num, column = bookmarks[slot_num] - if osp.isfile(filename): - index = editorstack.has_filename(filename) - if index is not None: - block = (editorstack.tabs.widget(index).document() - .findBlockByNumber(line_num)) - block.userData().bookmarks.remove((slot_num, column)) - if editorstack is not None: - self.switch_to_plugin() - editorstack.set_bookmark(slot_num) - - @Slot(int) - def load_bookmark(self, slot_num): - """Set cursor to bookmarked file and position.""" - bookmarks = CONF.get('editor', 'bookmarks') - if slot_num in bookmarks: - filename, line_num, column = bookmarks[slot_num] - else: - return - if not osp.isfile(filename): - self.last_edit_cursor_pos = None - return - self.load(filename) - editor = self.get_current_editor() - if line_num < editor.document().lineCount(): - linelength = len(editor.document() - .findBlockByNumber(line_num).text()) - if column <= linelength: - editor.go_to_line(line_num + 1, column) - else: - # Last column - editor.go_to_line(line_num + 1, linelength) - - #------ Zoom in/out/reset - def zoom(self, factor): - """Zoom in/out/reset""" - editor = self.get_current_editorstack().get_current_editor() - if factor == 0: - font = self.get_font() - editor.set_font(font) - else: - font = editor.font() - size = font.pointSize() + factor - if size > 0: - font.setPointSize(size) - editor.set_font(font) - editor.update_tab_stop_width_spaces() - - #------ Options - def apply_plugin_settings(self, options): - """Apply configuration file's plugin settings""" - if self.editorstacks is not None: - # --- syntax highlight and text rendering settings - currentline_n = 'highlight_current_line' - currentline_o = self.get_option(currentline_n) - currentcell_n = 'highlight_current_cell' - currentcell_o = self.get_option(currentcell_n) - occurrence_n = 'occurrence_highlighting' - occurrence_o = self.get_option(occurrence_n) - occurrence_timeout_n = 'occurrence_highlighting/timeout' - occurrence_timeout_o = self.get_option(occurrence_timeout_n) - focus_to_editor_n = 'focus_to_editor' - focus_to_editor_o = self.get_option(focus_to_editor_n) - - for editorstack in self.editorstacks: - if currentline_n in options: - editorstack.set_highlight_current_line_enabled( - currentline_o) - if currentcell_n in options: - editorstack.set_highlight_current_cell_enabled( - currentcell_o) - if occurrence_n in options: - editorstack.set_occurrence_highlighting_enabled(occurrence_o) - if occurrence_timeout_n in options: - editorstack.set_occurrence_highlighting_timeout( - occurrence_timeout_o) - if focus_to_editor_n in options: - editorstack.set_focus_to_editor(focus_to_editor_o) - - # --- everything else - tabbar_n = 'show_tab_bar' - tabbar_o = self.get_option(tabbar_n) - classfuncdropdown_n = 'show_class_func_dropdown' - classfuncdropdown_o = self.get_option(classfuncdropdown_n) - linenb_n = 'line_numbers' - linenb_o = self.get_option(linenb_n) - blanks_n = 'blank_spaces' - blanks_o = self.get_option(blanks_n) - scrollpastend_n = 'scroll_past_end' - scrollpastend_o = self.get_option(scrollpastend_n) - wrap_n = 'wrap' - wrap_o = self.get_option(wrap_n) - indentguides_n = 'indent_guides' - indentguides_o = self.get_option(indentguides_n) - codefolding_n = 'code_folding' - codefolding_o = self.get_option(codefolding_n) - tabindent_n = 'tab_always_indent' - tabindent_o = self.get_option(tabindent_n) - stripindent_n = 'strip_trailing_spaces_on_modify' - stripindent_o = self.get_option(stripindent_n) - ibackspace_n = 'intelligent_backspace' - ibackspace_o = self.get_option(ibackspace_n) - removetrail_n = 'always_remove_trailing_spaces' - removetrail_o = self.get_option(removetrail_n) - add_newline_n = 'add_newline' - add_newline_o = self.get_option(add_newline_n) - removetrail_newlines_n = 'always_remove_trailing_newlines' - removetrail_newlines_o = self.get_option(removetrail_newlines_n) - converteol_n = 'convert_eol_on_save' - converteol_o = self.get_option(converteol_n) - converteolto_n = 'convert_eol_on_save_to' - converteolto_o = self.get_option(converteolto_n) - runcellcopy_n = 'run_cell_copy' - runcellcopy_o = self.get_option(runcellcopy_n) - closepar_n = 'close_parentheses' - closepar_o = self.get_option(closepar_n) - close_quotes_n = 'close_quotes' - close_quotes_o = self.get_option(close_quotes_n) - add_colons_n = 'add_colons' - add_colons_o = self.get_option(add_colons_n) - autounindent_n = 'auto_unindent' - autounindent_o = self.get_option(autounindent_n) - indent_chars_n = 'indent_chars' - indent_chars_o = self.get_option(indent_chars_n) - tab_stop_width_spaces_n = 'tab_stop_width_spaces' - tab_stop_width_spaces_o = self.get_option(tab_stop_width_spaces_n) - help_n = 'connect_to_oi' - help_o = CONF.get('help', 'connect/editor') - todo_n = 'todo_list' - todo_o = self.get_option(todo_n) - - finfo = self.get_current_finfo() - - for editorstack in self.editorstacks: - # Checkable options - if blanks_n in options: - editorstack.set_blanks_enabled(blanks_o) - if scrollpastend_n in options: - editorstack.set_scrollpastend_enabled(scrollpastend_o) - if indentguides_n in options: - editorstack.set_indent_guides(indentguides_o) - if codefolding_n in options: - editorstack.set_code_folding_enabled(codefolding_o) - if classfuncdropdown_n in options: - editorstack.set_classfunc_dropdown_visible( - classfuncdropdown_o) - if tabbar_n in options: - editorstack.set_tabbar_visible(tabbar_o) - if linenb_n in options: - editorstack.set_linenumbers_enabled(linenb_o, - current_finfo=finfo) - if wrap_n in options: - editorstack.set_wrap_enabled(wrap_o) - if tabindent_n in options: - editorstack.set_tabmode_enabled(tabindent_o) - if stripindent_n in options: - editorstack.set_stripmode_enabled(stripindent_o) - if ibackspace_n in options: - editorstack.set_intelligent_backspace_enabled(ibackspace_o) - if removetrail_n in options: - editorstack.set_always_remove_trailing_spaces(removetrail_o) - if add_newline_n in options: - editorstack.set_add_newline(add_newline_o) - if removetrail_newlines_n in options: - editorstack.set_remove_trailing_newlines( - removetrail_newlines_o) - if converteol_n in options: - editorstack.set_convert_eol_on_save(converteol_o) - if converteolto_n in options: - editorstack.set_convert_eol_on_save_to(converteolto_o) - if runcellcopy_n in options: - editorstack.set_run_cell_copy(runcellcopy_o) - if closepar_n in options: - editorstack.set_close_parentheses_enabled(closepar_o) - if close_quotes_n in options: - editorstack.set_close_quotes_enabled(close_quotes_o) - if add_colons_n in options: - editorstack.set_add_colons_enabled(add_colons_o) - if autounindent_n in options: - editorstack.set_auto_unindent_enabled(autounindent_o) - if indent_chars_n in options: - editorstack.set_indent_chars(indent_chars_o) - if tab_stop_width_spaces_n in options: - editorstack.set_tab_stop_width_spaces(tab_stop_width_spaces_o) - if help_n in options: - editorstack.set_help_enabled(help_o) - if todo_n in options: - editorstack.set_todolist_enabled(todo_o, - current_finfo=finfo) - - for name, action in self.checkable_actions.items(): - if name in options: - # Avoid triggering the action when this action changes state - action.blockSignals(True) - state = self.get_option(name) - action.setChecked(state) - action.blockSignals(False) - # See: spyder-ide/spyder#9915 - - # Multiply by 1000 to convert seconds to milliseconds - self.autosave.interval = ( - self.get_option('autosave_interval') * 1000) - self.autosave.enabled = self.get_option('autosave_enabled') - - # We must update the current editor after the others: - # (otherwise, code analysis buttons state would correspond to the - # last editor instead of showing the one of the current editor) - if finfo is not None: - if todo_n in options and todo_o: - finfo.run_todo_finder() - - @on_conf_change(option='edge_line') - def set_edgeline_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set edge line to {value}") - for editorstack in self.editorstacks: - editorstack.set_edgeline_enabled(value) - - @on_conf_change( - option=('provider_configuration', 'lsp', 'values', - 'pycodestyle/max_line_length'), - section='completions' - ) - def set_edgeline_columns(self, value): - if self.editorstacks is not None: - logger.debug(f"Set edge line columns to {value}") - for editorstack in self.editorstacks: - editorstack.set_edgeline_columns(value) - - @on_conf_change(option='enable_code_snippets', section='completions') - def set_code_snippets_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set code snippets to {value}") - for editorstack in self.editorstacks: - editorstack.set_code_snippets_enabled(value) - - @on_conf_change(option='automatic_completions') - def set_automatic_completions_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set automatic completions to {value}") - for editorstack in self.editorstacks: - editorstack.set_automatic_completions_enabled(value) - - @on_conf_change(option='automatic_completions_after_chars') - def set_automatic_completions_after_chars(self, value): - if self.editorstacks is not None: - logger.debug(f"Set chars for automatic completions to {value}") - for editorstack in self.editorstacks: - editorstack.set_automatic_completions_after_chars(value) - - @on_conf_change(option='automatic_completions_after_ms') - def set_automatic_completions_after_ms(self, value): - if self.editorstacks is not None: - logger.debug(f"Set automatic completions after {value} ms") - for editorstack in self.editorstacks: - editorstack.set_automatic_completions_after_ms(value) - - @on_conf_change(option='completions_hint') - def set_completions_hint_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set completions hint to {value}") - for editorstack in self.editorstacks: - editorstack.set_completions_hint_enabled(value) - - @on_conf_change(option='completions_hint_after_ms') - def set_completions_hint_after_ms(self, value): - if self.editorstacks is not None: - logger.debug(f"Set completions hint after {value} ms") - for editorstack in self.editorstacks: - editorstack.set_completions_hint_after_ms(value) - - @on_conf_change( - option=('provider_configuration', 'lsp', 'values', - 'enable_hover_hints'), - section='completions' - ) - def set_hover_hints_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set hover hints to {value}") - for editorstack in self.editorstacks: - editorstack.set_hover_hints_enabled(value) - - @on_conf_change( - option=('provider_configuration', 'lsp', 'values', 'format_on_save'), - section='completions' - ) - def set_format_on_save(self, value): - if self.editorstacks is not None: - logger.debug(f"Set format on save to {value}") - for editorstack in self.editorstacks: - editorstack.set_format_on_save(value) - - @on_conf_change(option='underline_errors') - def set_underline_errors_enabled(self, value): - if self.editorstacks is not None: - logger.debug(f"Set underline errors to {value}") - for editorstack in self.editorstacks: - editorstack.set_underline_errors_enabled(value) - - @on_conf_change(option='selected', section='appearance') - def set_color_scheme(self, value): - if self.editorstacks is not None: - logger.debug(f"Set color scheme to {value}") - for editorstack in self.editorstacks: - editorstack.set_color_scheme(value) - - # --- Open files - def get_open_filenames(self): - """Get the list of open files in the current stack""" - editorstack = self.editorstacks[0] - filenames = [] - filenames += [finfo.filename for finfo in editorstack.data] - return filenames - - def set_open_filenames(self): - """ - Set the recent opened files on editor based on active project. - - If no project is active, then editor filenames are saved, otherwise - the opened filenames are stored in the project config info. - """ - if self.projects is not None: - if not self.projects.get_active_project(): - filenames = self.get_open_filenames() - self.set_option('filenames', filenames) - - def setup_open_files(self, close_previous_files=True): - """ - Open the list of saved files per project. - - Also open any files that the user selected in the recovery dialog. - """ - self.set_create_new_file_if_empty(False) - active_project_path = None - if self.projects is not None: - active_project_path = self.projects.get_active_project_path() - - if active_project_path: - filenames = self.projects.get_project_filenames() - else: - filenames = self.get_option('filenames', default=[]) - - if close_previous_files: - self.close_all_files() - - all_filenames = self.autosave.recover_files_to_open + filenames - if all_filenames and any([osp.isfile(f) for f in all_filenames]): - layout = self.get_option('layout_settings', None) - # Check if no saved layout settings exist, e.g. clean prefs file. - # If not, load with default focus/layout, to fix - # spyder-ide/spyder#8458. - if layout: - is_vertical, cfname, clines = layout.get('splitsettings')[0] - # Check that a value for current line exist for each filename - # in the available settings. See spyder-ide/spyder#12201 - if cfname in filenames and len(filenames) == len(clines): - index = filenames.index(cfname) - # First we load the last focused file. - self.load(filenames[index], goto=clines[index], set_focus=True) - # Then we load the files located to the left of the last - # focused file in the tabbar, while keeping the focus on - # the last focused file. - if index > 0: - self.load(filenames[index::-1], goto=clines[index::-1], - set_focus=False, add_where='start') - # Then we load the files located to the right of the last - # focused file in the tabbar, while keeping the focus on - # the last focused file. - if index < (len(filenames) - 1): - self.load(filenames[index+1:], goto=clines[index:], - set_focus=False, add_where='end') - # Finally we load any recovered files at the end of the tabbar, - # while keeping focus on the last focused file. - if self.autosave.recover_files_to_open: - self.load(self.autosave.recover_files_to_open, - set_focus=False, add_where='end') - else: - if filenames: - self.load(filenames, goto=clines) - if self.autosave.recover_files_to_open: - self.load(self.autosave.recover_files_to_open) - else: - if filenames: - self.load(filenames) - if self.autosave.recover_files_to_open: - self.load(self.autosave.recover_files_to_open) - - if self.__first_open_files_setup: - self.__first_open_files_setup = False - if layout is not None: - self.editorsplitter.set_layout_settings( - layout, - dont_goto=filenames[0]) - win_layout = self.get_option('windows_layout_settings', []) - if win_layout: - for layout_settings in win_layout: - self.editorwindows_to_be_created.append( - layout_settings) - self.set_last_focused_editorstack(self, self.editorstacks[0]) - - # This is necessary to update the statusbar widgets after files - # have been loaded. - editorstack = self.get_current_editorstack() - if editorstack: - self.get_current_editorstack().refresh() - else: - self.__load_temp_file() - self.set_create_new_file_if_empty(True) - self.sig_open_files_finished.emit() - - def save_open_files(self): - """Save the list of open files""" - self.set_option('filenames', self.get_open_filenames()) - - def set_create_new_file_if_empty(self, value): - """Change the value of create_new_file_if_empty""" - for editorstack in self.editorstacks: - editorstack.create_new_file_if_empty = value - - # --- File Menu actions (Mac only) - @Slot() - def go_to_next_file(self): - """Switch to next file tab on the current editor stack.""" - editorstack = self.get_current_editorstack() - editorstack.tabs.tab_navigate(+1) - - @Slot() - def go_to_previous_file(self): - """Switch to previous file tab on the current editor stack.""" - editorstack = self.get_current_editorstack() - editorstack.tabs.tab_navigate(-1) - - def set_current_project_path(self, root_path=None): - """ - Set the current active project root path. - - Parameters - ---------- - root_path: str or None, optional - Path to current project root path. Default is None. - """ - for editorstack in self.editorstacks: - editorstack.set_current_project_path(root_path) - - def register_panel(self, panel_class, *args, position=Panel.Position.LEFT, - **kwargs): - """Register a panel in all the editorstacks in the given position.""" - for editorstack in self.editorstacks: - editorstack.register_panel( - panel_class, *args, position=position, **kwargs) - - # TODO: To be updated after migration - def on_mainwindow_visible(self): - return +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Editor Plugin""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import logging +import os +import os.path as osp +import re +import sys +import time + +# Third party imports +from qtpy.compat import from_qvariant, getopenfilenames, to_qvariant +from qtpy.QtCore import QByteArray, Qt, Signal, Slot, QDir +from qtpy.QtGui import QTextCursor +from qtpy.QtPrintSupport import (QAbstractPrintDialog, QPrintDialog, QPrinter, + QPrintPreviewDialog) +from qtpy.QtWidgets import (QAction, QActionGroup, QApplication, QDialog, + QFileDialog, QInputDialog, QMenu, QSplitter, + QToolBar, QVBoxLayout, QWidget) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.config.mixins import SpyderConfigurationObserver +from spyder.api.panel import Panel +from spyder.api.plugins import Plugins, SpyderPluginWidget +from spyder.config.base import _, get_conf_path, running_under_pytest +from spyder.config.manager import CONF +from spyder.config.utils import (get_edit_filetypes, get_edit_filters, + get_filter) +from spyder.py3compat import PY2, qbytearray_to_str, to_text_string +from spyder.utils import encoding, programs, sourcecode +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import create_action, add_actions, MENU_SEPARATOR +from spyder.utils.misc import getcwd_or_home +from spyder.widgets.findreplace import FindReplace +from spyder.plugins.editor.confpage import EditorConfigPage +from spyder.plugins.editor.utils.autosave import AutosaveForPlugin +from spyder.plugins.editor.utils.switcher import EditorSwitcherManager +from spyder.plugins.editor.widgets.codeeditor_widgets import Printer +from spyder.plugins.editor.widgets.editor import (EditorMainWindow, + EditorSplitter, + EditorStack,) +from spyder.plugins.editor.widgets.codeeditor import CodeEditor +from spyder.plugins.editor.utils.bookmarks import (load_bookmarks, + save_bookmarks) +from spyder.plugins.editor.utils.debugger import (clear_all_breakpoints, + clear_breakpoint) +from spyder.plugins.editor.widgets.status import (CursorPositionStatus, + EncodingStatus, EOLStatus, + ReadWriteStatus, VCSStatus) +from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN_OPTION, + get_run_configuration, RunConfigDialog, + RunConfiguration, RunConfigOneDialog) +from spyder.plugins.mainmenu.api import ApplicationMenus +from spyder.widgets.simplecodeeditor import SimpleCodeEditor + + +logger = logging.getLogger(__name__) + + +class Editor(SpyderPluginWidget, SpyderConfigurationObserver): + """ + Multi-file Editor widget + """ + CONF_SECTION = 'editor' + CONFIGWIDGET_CLASS = EditorConfigPage + CONF_FILE = False + TEMPFILE_PATH = get_conf_path('temp.py') + TEMPLATE_PATH = get_conf_path('template.py') + DISABLE_ACTIONS_WHEN_HIDDEN = False # SpyderPluginWidget class attribute + + # This is required for the new API + NAME = 'editor' + REQUIRES = [Plugins.Console] + OPTIONAL = [Plugins.Completions, Plugins.OutlineExplorer] + + # Signals + run_in_current_ipyclient = Signal(str, str, str, + bool, bool, bool, bool, bool) + run_cell_in_ipyclient = Signal(str, object, str, bool, bool) + debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) + exec_in_extconsole = Signal(str, bool) + redirect_stdio = Signal(bool) + + sig_dir_opened = Signal(str) + """ + This signal is emitted when the editor changes the current directory. + + Parameters + ---------- + new_working_directory: str + The new working directory path. + + Notes + ----- + This option is available on the options menu of the editor plugin + """ + + breakpoints_saved = Signal() + + sig_file_opened_closed_or_updated = Signal(str, str) + """ + This signal is emitted when a file is opened, closed or updated, + including switching among files. + + Parameters + ---------- + filename: str + Name of the file that was opened, closed or updated. + language: str + Name of the programming language of the file that was opened, + closed or updated. + """ + + sig_file_debug_message_requested = Signal() + + # This signal is fired for any focus change among all editor stacks + sig_editor_focus_changed = Signal() + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Dictionary required by the Help pane to render a docstring. + + Examples + -------- + >>> help_data = { + 'obj_text': str, + 'name': str, + 'argspec': str, + 'note': str, + 'docstring': str, + 'force_refresh': bool, + 'path': str, + } + + See Also + -------- + :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help + """ + + sig_open_files_finished = Signal() + """ + This signal is emitted when the editor finished to open files. + """ + + def __init__(self, parent, ignore_last_opened_files=False): + SpyderPluginWidget.__init__(self, parent) + + self.__set_eol_chars = True + + # Creating template if it doesn't already exist + if not osp.isfile(self.TEMPLATE_PATH): + if os.name == "nt": + shebang = [] + else: + shebang = ['#!/usr/bin/env python' + ('2' if PY2 else '3')] + header = shebang + [ + '# -*- coding: utf-8 -*-', + '"""', 'Created on %(date)s', '', + '@author: %(username)s', '"""', '', ''] + try: + encoding.write(os.linesep.join(header), self.TEMPLATE_PATH, + 'utf-8') + except EnvironmentError: + pass + + self.projects = None + self.outlineexplorer = None + + self.file_dependent_actions = [] + self.pythonfile_dependent_actions = [] + self.dock_toolbar_actions = None + self.edit_menu_actions = None #XXX: find another way to notify Spyder + self.stack_menu_actions = None + self.checkable_actions = {} + + self.__first_open_files_setup = True + self.editorstacks = [] + self.last_focused_editorstack = {} + self.editorwindows = [] + self.editorwindows_to_be_created = [] + self.toolbar_list = None + self.menu_list = None + + # We need to call this here to create self.dock_toolbar_actions, + # which is used below. + self._setup() + self.options_button.hide() + + # Configuration dialog size + self.dialog_size = None + + self.vcs_status = VCSStatus(self) + self.cursorpos_status = CursorPositionStatus(self) + self.encoding_status = EncodingStatus(self) + self.eol_status = EOLStatus(self) + self.readwrite_status = ReadWriteStatus(self) + + # TODO: temporal fix while editor uses new API + statusbar = self.main.get_plugin(Plugins.StatusBar, error=False) + if statusbar: + statusbar.add_status_widget(self.readwrite_status) + statusbar.add_status_widget(self.eol_status) + statusbar.add_status_widget(self.encoding_status) + statusbar.add_status_widget(self.cursorpos_status) + statusbar.add_status_widget(self.vcs_status) + + layout = QVBoxLayout() + self.dock_toolbar = QToolBar(self) + add_actions(self.dock_toolbar, self.dock_toolbar_actions) + layout.addWidget(self.dock_toolbar) + + self.last_edit_cursor_pos = None + self.cursor_undo_history = [] + self.cursor_redo_history = [] + self.__ignore_cursor_history = True + + # Completions setup + self.completion_capabilities = {} + + # Setup new windows: + self.main.all_actions_defined.connect(self.setup_other_windows) + + # Change module completions when PYTHONPATH changes + self.main.sig_pythonpath_changed.connect(self.set_path) + + # Find widget + self.find_widget = FindReplace(self, enable_replace=True) + self.find_widget.hide() + self.register_widget_shortcuts(self.find_widget) + + # Start autosave component + # (needs to be done before EditorSplitter) + self.autosave = AutosaveForPlugin(self) + self.autosave.try_recover_from_autosave() + + # Multiply by 1000 to convert seconds to milliseconds + self.autosave.interval = self.get_option('autosave_interval') * 1000 + self.autosave.enabled = self.get_option('autosave_enabled') + + # SimpleCodeEditor instance used to print file contents + self._print_editor = self._create_print_editor() + self._print_editor.hide() + + # Tabbed editor widget + Find/Replace widget + editor_widgets = QWidget(self) + editor_layout = QVBoxLayout() + editor_layout.setContentsMargins(0, 0, 0, 0) + editor_widgets.setLayout(editor_layout) + self.editorsplitter = EditorSplitter(self, self, + self.stack_menu_actions, first=True) + editor_layout.addWidget(self.editorsplitter) + editor_layout.addWidget(self.find_widget) + editor_layout.addWidget(self._print_editor) + + # Splitter: editor widgets (see above) + outline explorer + self.splitter = QSplitter(self) + self.splitter.setContentsMargins(0, 0, 0, 0) + self.splitter.addWidget(editor_widgets) + self.splitter.setStretchFactor(0, 5) + self.splitter.setStretchFactor(1, 1) + layout.addWidget(self.splitter) + self.setLayout(layout) + self.setFocusPolicy(Qt.ClickFocus) + + # Editor's splitter state + state = self.get_option('splitter_state', None) + if state is not None: + self.splitter.restoreState( QByteArray().fromHex( + str(state).encode('utf-8')) ) + + self.recent_files = self.get_option('recent_files', []) + self.untitled_num = 0 + + # Parameters of last file execution: + self.__last_ic_exec = None # internal console + self.__last_ec_exec = None # external console + + # File types and filters used by the Open dialog + self.edit_filetypes = None + self.edit_filters = None + + self.__ignore_cursor_history = False + current_editor = self.get_current_editor() + if current_editor is not None: + filename = self.get_current_filename() + cursor = current_editor.textCursor() + self.add_cursor_to_history(filename, cursor) + self.update_cursorpos_actions() + self.set_path() + + def set_projects(self, projects): + self.projects = projects + + @Slot() + def show_hide_projects(self): + if self.projects is not None: + dw = self.projects.dockwidget + if dw.isVisible(): + dw.hide() + else: + dw.show() + dw.raise_() + self.switch_to_plugin() + + def set_outlineexplorer(self, outlineexplorer): + self.outlineexplorer = outlineexplorer + for editorstack in self.editorstacks: + # Pass the OutlineExplorer widget to the stacks because they + # don't need the plugin + editorstack.set_outlineexplorer(self.outlineexplorer.get_widget()) + self.outlineexplorer.get_widget().edit_goto.connect( + lambda filenames, goto, word: + self.load(filenames=filenames, goto=goto, word=word, + editorwindow=self)) + self.outlineexplorer.get_widget().edit.connect( + lambda filenames: + self.load(filenames=filenames, editorwindow=self)) + + #------ Private API -------------------------------------------------------- + def restore_scrollbar_position(self): + """Restoring scrollbar position after main window is visible""" + # Widget is now visible, we may center cursor on top level editor: + try: + self.get_current_editor().centerCursor() + except AttributeError: + pass + + @Slot(dict) + def report_open_file(self, options): + """Report that a file was opened to the completion manager.""" + filename = options['filename'] + language = options['language'] + codeeditor = options['codeeditor'] + status = None + if self.main.get_plugin(Plugins.Completions, error=False): + status = ( + self.main.completions.start_completion_services_for_language( + language.lower())) + self.main.completions.register_file( + language.lower(), filename, codeeditor) + if status: + if language.lower() in self.completion_capabilities: + # When this condition is True, it means there's a server + # that can provide completion services for this file. + codeeditor.register_completion_capabilities( + self.completion_capabilities[language.lower()]) + codeeditor.start_completion_services() + elif self.main.completions.is_fallback_only(language.lower()): + # This is required to use fallback completions for files + # without a language server. + codeeditor.start_completion_services() + else: + if codeeditor.language == language.lower(): + logger.debug('Setting {0} completions off'.format(filename)) + codeeditor.completions_available = False + + @Slot(dict, str) + def register_completion_capabilities(self, capabilities, language): + """ + Register completion server capabilities in all editorstacks. + + Parameters + ---------- + capabilities: dict + Capabilities supported by a language server. + language: str + Programming language for the language server (it has to be + in small caps). + """ + logger.debug( + 'Completion server capabilities for {!s} are: {!r}'.format( + language, capabilities) + ) + + # This is required to start workspace before completion + # services when Spyder starts with an open project. + # TODO: Find a better solution for it in the future!! + projects = self.main.get_plugin(Plugins.Projects, error=False) + if projects: + projects.start_workspace_services() + + self.completion_capabilities[language] = dict(capabilities) + for editorstack in self.editorstacks: + editorstack.register_completion_capabilities( + capabilities, language) + + self.start_completion_services(language) + + def start_completion_services(self, language): + """Notify all editorstacks about LSP server availability.""" + for editorstack in self.editorstacks: + editorstack.start_completion_services(language) + + def stop_completion_services(self, language): + """Notify all editorstacks about LSP server unavailability.""" + for editorstack in self.editorstacks: + editorstack.stop_completion_services(language) + + def send_completion_request(self, language, request, params): + logger.debug("Perform request {0} for: {1}".format( + request, params['file'])) + try: + self.main.completions.send_request(language, request, params) + except AttributeError: + # Completions was closed + pass + + @Slot(str, tuple, dict) + def _rpc_call(self, method, args, kwargs): + meth = getattr(self, method) + meth(*args, **kwargs) + + #------ SpyderPluginWidget API --------------------------------------------- + @staticmethod + def get_plugin_title(): + """Return widget title""" + # TODO: This is a temporary measure to get the title of this plugin + # without creating an instance + title = _('Editor') + return title + + def get_plugin_icon(self): + """Return widget icon.""" + return ima.icon('edit') + + def get_focus_widget(self): + """ + Return the widget to give focus to. + + This happens when plugin's dockwidget is raised on top-level. + """ + return self.get_current_editor() + + def _visibility_changed(self, enable): + """DockWidget visibility has changed""" + SpyderPluginWidget._visibility_changed(self, enable) + if self.dockwidget is None: + return + if self.dockwidget.isWindow(): + self.dock_toolbar.show() + else: + self.dock_toolbar.hide() + if enable: + self.refresh_plugin() + self.sig_update_plugin_title.emit() + + def refresh_plugin(self): + """Refresh editor plugin""" + editorstack = self.get_current_editorstack() + editorstack.refresh() + self.refresh_save_all_action() + + def closing_plugin(self, cancelable=False): + """Perform actions before parent main window is closed""" + state = self.splitter.saveState() + self.set_option('splitter_state', qbytearray_to_str(state)) + editorstack = self.editorstacks[0] + + active_project_path = None + if self.projects is not None: + active_project_path = self.projects.get_active_project_path() + if not active_project_path: + self.set_open_filenames() + else: + self.projects.set_project_filenames( + [finfo.filename for finfo in editorstack.data]) + + self.set_option('layout_settings', + self.editorsplitter.get_layout_settings()) + self.set_option('windows_layout_settings', + [win.get_layout_settings() for win in self.editorwindows]) +# self.set_option('filenames', filenames) + self.set_option('recent_files', self.recent_files) + + # Stop autosave timer before closing windows + self.autosave.stop_autosave_timer() + + try: + if not editorstack.save_if_changed(cancelable) and cancelable: + return False + else: + for win in self.editorwindows[:]: + win.close() + return True + except IndexError: + return True + + def get_plugin_actions(self): + """Return a list of actions related to plugin""" + # ---- File menu and toolbar ---- + self.new_action = create_action( + self, + _("&New file..."), + icon=ima.icon('filenew'), tip=_("New file"), + triggered=self.new, + context=Qt.WidgetShortcut + ) + self.register_shortcut(self.new_action, context="Editor", + name="New file", add_shortcut_to_tip=True) + + self.open_last_closed_action = create_action( + self, + _("O&pen last closed"), + tip=_("Open last closed"), + triggered=self.open_last_closed + ) + self.register_shortcut(self.open_last_closed_action, context="Editor", + name="Open last closed") + + self.open_action = create_action(self, _("&Open..."), + icon=ima.icon('fileopen'), tip=_("Open file"), + triggered=self.load, + context=Qt.WidgetShortcut) + self.register_shortcut(self.open_action, context="Editor", + name="Open file", add_shortcut_to_tip=True) + + self.revert_action = create_action(self, _("&Revert"), + icon=ima.icon('revert'), tip=_("Revert file from disk"), + triggered=self.revert) + + self.save_action = create_action(self, _("&Save"), + icon=ima.icon('filesave'), tip=_("Save file"), + triggered=self.save, + context=Qt.WidgetShortcut) + self.register_shortcut(self.save_action, context="Editor", + name="Save file", add_shortcut_to_tip=True) + + self.save_all_action = create_action(self, _("Sav&e all"), + icon=ima.icon('save_all'), tip=_("Save all files"), + triggered=self.save_all, + context=Qt.WidgetShortcut) + self.register_shortcut(self.save_all_action, context="Editor", + name="Save all", add_shortcut_to_tip=True) + + save_as_action = create_action(self, _("Save &as..."), None, + ima.icon('filesaveas'), tip=_("Save current file as..."), + triggered=self.save_as, + context=Qt.WidgetShortcut) + self.register_shortcut(save_as_action, "Editor", "Save As") + + save_copy_as_action = create_action(self, _("Save copy as..."), None, + ima.icon('filesaveas'), _("Save copy of current file as..."), + triggered=self.save_copy_as) + + print_preview_action = create_action(self, _("Print preview..."), + tip=_("Print preview..."), triggered=self.print_preview) + self.print_action = create_action(self, _("&Print..."), + icon=ima.icon('print'), tip=_("Print current file..."), + triggered=self.print_file) + # Shortcut for close_action is defined in widgets/editor.py + self.close_action = create_action(self, _("&Close"), + icon=ima.icon('fileclose'), tip=_("Close current file"), + triggered=self.close_file) + + self.close_all_action = create_action(self, _("C&lose all"), + icon=ima.icon('filecloseall'), tip=_("Close all opened files"), + triggered=self.close_all_files, + context=Qt.WidgetShortcut) + self.register_shortcut(self.close_all_action, context="Editor", + name="Close all") + + # ---- Find menu and toolbar ---- + _text = _("&Find text") + find_action = create_action(self, _text, icon=ima.icon('find'), + tip=_text, triggered=self.find, + context=Qt.WidgetShortcut) + self.register_shortcut(find_action, context="find_replace", + name="Find text", add_shortcut_to_tip=True) + find_next_action = create_action(self, _("Find &next"), + icon=ima.icon('findnext'), + triggered=self.find_next, + context=Qt.WidgetShortcut) + self.register_shortcut(find_next_action, context="find_replace", + name="Find next") + find_previous_action = create_action(self, _("Find &previous"), + icon=ima.icon('findprevious'), + triggered=self.find_previous, + context=Qt.WidgetShortcut) + self.register_shortcut(find_previous_action, context="find_replace", + name="Find previous") + _text = _("&Replace text") + replace_action = create_action(self, _text, icon=ima.icon('replace'), + tip=_text, triggered=self.replace, + context=Qt.WidgetShortcut) + self.register_shortcut(replace_action, context="find_replace", + name="Replace text") + + # ---- Debug menu and toolbar ---- + set_clear_breakpoint_action = create_action(self, + _("Set/Clear breakpoint"), + icon=ima.icon('breakpoint_big'), + triggered=self.set_or_clear_breakpoint, + context=Qt.WidgetShortcut) + self.register_shortcut(set_clear_breakpoint_action, context="Editor", + name="Breakpoint") + + set_cond_breakpoint_action = create_action(self, + _("Set/Edit conditional breakpoint"), + icon=ima.icon('breakpoint_cond_big'), + triggered=self.set_or_edit_conditional_breakpoint, + context=Qt.WidgetShortcut) + self.register_shortcut(set_cond_breakpoint_action, context="Editor", + name="Conditional breakpoint") + + clear_all_breakpoints_action = create_action(self, + _('Clear breakpoints in all files'), + triggered=self.clear_all_breakpoints) + + # --- Debug toolbar --- + self.debug_action = create_action( + self, _("&Debug"), + icon=ima.icon('debug'), + tip=_("Debug file"), + triggered=self.debug_file) + self.register_shortcut(self.debug_action, context="_", name="Debug", + add_shortcut_to_tip=True) + + self.debug_next_action = create_action( + self, _("Step"), + icon=ima.icon('arrow-step-over'), tip=_("Run current line"), + triggered=lambda: self.debug_command("next")) + self.register_shortcut(self.debug_next_action, "_", "Debug Step Over", + add_shortcut_to_tip=True) + + self.debug_continue_action = create_action( + self, _("Continue"), + icon=ima.icon('arrow-continue'), + tip=_("Continue execution until next breakpoint"), + triggered=lambda: self.debug_command("continue")) + self.register_shortcut( + self.debug_continue_action, "_", "Debug Continue", + add_shortcut_to_tip=True) + + self.debug_step_action = create_action( + self, _("Step Into"), + icon=ima.icon('arrow-step-in'), + tip=_("Step into function or method of current line"), + triggered=lambda: self.debug_command("step")) + self.register_shortcut(self.debug_step_action, "_", "Debug Step Into", + add_shortcut_to_tip=True) + + self.debug_return_action = create_action( + self, _("Step Return"), + icon=ima.icon('arrow-step-out'), + tip=_("Run until current function or method returns"), + triggered=lambda: self.debug_command("return")) + self.register_shortcut( + self.debug_return_action, "_", "Debug Step Return", + add_shortcut_to_tip=True) + + self.debug_exit_action = create_action( + self, _("Stop"), + icon=ima.icon('stop_debug'), tip=_("Stop debugging"), + triggered=self.stop_debugging) + self.register_shortcut(self.debug_exit_action, "_", "Debug Exit", + add_shortcut_to_tip=True) + + # --- Run toolbar --- + run_action = create_action(self, _("&Run"), icon=ima.icon('run'), + tip=_("Run file"), + triggered=self.run_file) + self.register_shortcut(run_action, context="_", name="Run", + add_shortcut_to_tip=True) + + configure_action = create_action( + self, + _("&Configuration per file..."), + icon=ima.icon('run_settings'), + tip=_("Run settings"), + menurole=QAction.NoRole, + triggered=self.edit_run_configurations) + + self.register_shortcut(configure_action, context="_", + name="Configure", add_shortcut_to_tip=True) + + re_run_action = create_action(self, _("Re-run &last script"), + icon=ima.icon('run_again'), + tip=_("Run again last file"), + triggered=self.re_run_file) + self.register_shortcut(re_run_action, context="_", + name="Re-run last script", + add_shortcut_to_tip=True) + + run_selected_action = create_action(self, _("Run &selection or " + "current line"), + icon=ima.icon('run_selection'), + tip=_("Run selection or " + "current line"), + triggered=self.run_selection, + context=Qt.WidgetShortcut) + self.register_shortcut(run_selected_action, context="Editor", + name="Run selection", add_shortcut_to_tip=True) + + run_to_line_action = create_action(self, _("Run &to current line"), + tip=_("Run to current line"), + triggered=self.run_to_line, + context=Qt.WidgetShortcut) + self.register_shortcut(run_to_line_action, context="Editor", + name="Run to line", add_shortcut_to_tip=True) + + run_from_line_action = create_action(self, _("Run &from current line"), + tip=_("Run from current line"), + triggered=self.run_from_line, + context=Qt.WidgetShortcut) + self.register_shortcut(run_from_line_action, context="Editor", + name="Run from line", add_shortcut_to_tip=True) + + run_cell_action = create_action(self, + _("Run cell"), + icon=ima.icon('run_cell'), + tip=_("Run current cell \n" + "[Use #%% to create cells]"), + triggered=self.run_cell, + context=Qt.WidgetShortcut) + + self.register_shortcut(run_cell_action, context="Editor", + name="Run cell", add_shortcut_to_tip=True) + + run_cell_advance_action = create_action( + self, + _("Run cell and advance"), + icon=ima.icon('run_cell_advance'), + tip=_("Run current cell and go to the next one "), + triggered=self.run_cell_and_advance, + context=Qt.WidgetShortcut) + + self.register_shortcut(run_cell_advance_action, context="Editor", + name="Run cell and advance", + add_shortcut_to_tip=True) + + self.debug_cell_action = create_action( + self, + _("Debug cell"), + icon=ima.icon('debug_cell'), + tip=_("Debug current cell " + "(Alt+Shift+Enter)"), + triggered=self.debug_cell, + context=Qt.WidgetShortcut) + + self.register_shortcut(self.debug_cell_action, context="Editor", + name="Debug cell", + add_shortcut_to_tip=True) + + re_run_last_cell_action = create_action(self, + _("Re-run last cell"), + tip=_("Re run last cell "), + triggered=self.re_run_last_cell, + context=Qt.WidgetShortcut) + self.register_shortcut(re_run_last_cell_action, + context="Editor", + name='re-run last cell', + add_shortcut_to_tip=True) + + # --- Source code Toolbar --- + self.todo_list_action = create_action(self, + _("Show todo list"), icon=ima.icon('todo_list'), + tip=_("Show comments list (TODO/FIXME/XXX/HINT/TIP/@todo/" + "HACK/BUG/OPTIMIZE/!!!/???)"), + triggered=self.go_to_next_todo) + self.todo_menu = QMenu(self) + self.todo_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") + self.todo_list_action.setMenu(self.todo_menu) + self.todo_menu.aboutToShow.connect(self.update_todo_menu) + + self.warning_list_action = create_action(self, + _("Show warning/error list"), icon=ima.icon('wng_list'), + tip=_("Show code analysis warnings/errors"), + triggered=self.go_to_next_warning) + self.warning_menu = QMenu(self) + self.warning_menu.setStyleSheet("QMenu {menu-scrollable: 1;}") + self.warning_list_action.setMenu(self.warning_menu) + self.warning_menu.aboutToShow.connect(self.update_warning_menu) + self.previous_warning_action = create_action(self, + _("Previous warning/error"), icon=ima.icon('prev_wng'), + tip=_("Go to previous code analysis warning/error"), + triggered=self.go_to_previous_warning, + context=Qt.WidgetShortcut) + self.register_shortcut(self.previous_warning_action, + context="Editor", + name="Previous warning", + add_shortcut_to_tip=True) + self.next_warning_action = create_action(self, + _("Next warning/error"), icon=ima.icon('next_wng'), + tip=_("Go to next code analysis warning/error"), + triggered=self.go_to_next_warning, + context=Qt.WidgetShortcut) + self.register_shortcut(self.next_warning_action, + context="Editor", + name="Next warning", + add_shortcut_to_tip=True) + + self.previous_edit_cursor_action = create_action(self, + _("Last edit location"), icon=ima.icon('last_edit_location'), + tip=_("Go to last edit location"), + triggered=self.go_to_last_edit_location, + context=Qt.WidgetShortcut) + self.register_shortcut(self.previous_edit_cursor_action, + context="Editor", + name="Last edit location", + add_shortcut_to_tip=True) + self.previous_cursor_action = create_action(self, + _("Previous cursor position"), icon=ima.icon('prev_cursor'), + tip=_("Go to previous cursor position"), + triggered=self.go_to_previous_cursor_position, + context=Qt.WidgetShortcut) + self.register_shortcut(self.previous_cursor_action, + context="Editor", + name="Previous cursor position", + add_shortcut_to_tip=True) + self.next_cursor_action = create_action(self, + _("Next cursor position"), icon=ima.icon('next_cursor'), + tip=_("Go to next cursor position"), + triggered=self.go_to_next_cursor_position, + context=Qt.WidgetShortcut) + self.register_shortcut(self.next_cursor_action, + context="Editor", + name="Next cursor position", + add_shortcut_to_tip=True) + + # --- Edit Toolbar --- + self.toggle_comment_action = create_action(self, + _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), + tip=_("Comment current line or selection"), + triggered=self.toggle_comment, context=Qt.WidgetShortcut) + self.register_shortcut(self.toggle_comment_action, context="Editor", + name="Toggle comment") + blockcomment_action = create_action(self, _("Add &block comment"), + tip=_("Add block comment around " + "current line or selection"), + triggered=self.blockcomment, context=Qt.WidgetShortcut) + self.register_shortcut(blockcomment_action, context="Editor", + name="Blockcomment") + unblockcomment_action = create_action(self, + _("R&emove block comment"), + tip = _("Remove comment block around " + "current line or selection"), + triggered=self.unblockcomment, context=Qt.WidgetShortcut) + self.register_shortcut(unblockcomment_action, context="Editor", + name="Unblockcomment") + + # ---------------------------------------------------------------------- + # The following action shortcuts are hard-coded in CodeEditor + # keyPressEvent handler (the shortcut is here only to inform user): + # (context=Qt.WidgetShortcut -> disable shortcut for other widgets) + self.indent_action = create_action(self, + _("Indent"), "Tab", icon=ima.icon('indent'), + tip=_("Indent current line or selection"), + triggered=self.indent, context=Qt.WidgetShortcut) + self.unindent_action = create_action(self, + _("Unindent"), "Shift+Tab", icon=ima.icon('unindent'), + tip=_("Unindent current line or selection"), + triggered=self.unindent, context=Qt.WidgetShortcut) + + self.text_uppercase_action = create_action(self, + _("Toggle Uppercase"), icon=ima.icon('toggle_uppercase'), + tip=_("Change to uppercase current line or selection"), + triggered=self.text_uppercase, context=Qt.WidgetShortcut) + self.register_shortcut(self.text_uppercase_action, context="Editor", + name="transform to uppercase") + + self.text_lowercase_action = create_action(self, + _("Toggle Lowercase"), icon=ima.icon('toggle_lowercase'), + tip=_("Change to lowercase current line or selection"), + triggered=self.text_lowercase, context=Qt.WidgetShortcut) + self.register_shortcut(self.text_lowercase_action, context="Editor", + name="transform to lowercase") + # ---------------------------------------------------------------------- + + self.win_eol_action = create_action( + self, + _("CRLF (Windows)"), + toggled=lambda checked: self.toggle_eol_chars('nt', checked) + ) + self.linux_eol_action = create_action( + self, + _("LF (Unix)"), + toggled=lambda checked: self.toggle_eol_chars('posix', checked) + ) + self.mac_eol_action = create_action( + self, + _("CR (macOS)"), + toggled=lambda checked: self.toggle_eol_chars('mac', checked) + ) + eol_action_group = QActionGroup(self) + eol_actions = (self.win_eol_action, self.linux_eol_action, + self.mac_eol_action) + add_actions(eol_action_group, eol_actions) + eol_menu = QMenu(_("Convert end-of-line characters"), self) + eol_menu.setObjectName('checkbox-padding') + add_actions(eol_menu, eol_actions) + + trailingspaces_action = create_action( + self, + _("Remove trailing spaces"), + triggered=self.remove_trailing_spaces) + + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '') + self.formatting_action = create_action( + self, + _('Format file or selection with {0}').format( + formatter.capitalize()), + shortcut=CONF.get_shortcut('editor', 'autoformatting'), + context=Qt.WidgetShortcut, + triggered=self.format_document_or_selection) + self.formatting_action.setEnabled(False) + + # Checkable actions + showblanks_action = self._create_checkable_action( + _("Show blank spaces"), 'blank_spaces', 'set_blanks_enabled') + + scrollpastend_action = self._create_checkable_action( + _("Scroll past the end"), 'scroll_past_end', + 'set_scrollpastend_enabled') + + showindentguides_action = self._create_checkable_action( + _("Show indent guides"), 'indent_guides', 'set_indent_guides') + + showcodefolding_action = self._create_checkable_action( + _("Show code folding"), 'code_folding', 'set_code_folding_enabled') + + show_classfunc_dropdown_action = self._create_checkable_action( + _("Show selector for classes and functions"), + 'show_class_func_dropdown', 'set_classfunc_dropdown_visible') + + show_codestyle_warnings_action = self._create_checkable_action( + _("Show code style warnings"), 'pycodestyle',) + + show_docstring_warnings_action = self._create_checkable_action( + _("Show docstring style warnings"), 'pydocstyle') + + underline_errors = self._create_checkable_action( + _("Underline errors and warnings"), + 'underline_errors', 'set_underline_errors_enabled') + + self.checkable_actions = { + 'blank_spaces': showblanks_action, + 'scroll_past_end': scrollpastend_action, + 'indent_guides': showindentguides_action, + 'code_folding': showcodefolding_action, + 'show_class_func_dropdown': show_classfunc_dropdown_action, + 'pycodestyle': show_codestyle_warnings_action, + 'pydocstyle': show_docstring_warnings_action, + 'underline_errors': underline_errors} + + fixindentation_action = create_action(self, _("Fix indentation"), + tip=_("Replace tab characters by space characters"), + triggered=self.fix_indentation) + + gotoline_action = create_action(self, _("Go to line..."), + icon=ima.icon('gotoline'), + triggered=self.go_to_line, + context=Qt.WidgetShortcut) + self.register_shortcut(gotoline_action, context="Editor", + name="Go to line") + + workdir_action = create_action(self, + _("Set console working directory"), + icon=ima.icon('DirOpenIcon'), + tip=_("Set current console (and file explorer) working " + "directory to current script directory"), + triggered=self.__set_workdir) + + self.max_recent_action = create_action(self, + _("Maximum number of recent files..."), + triggered=self.change_max_recent_files) + self.clear_recent_action = create_action(self, + _("Clear this list"), tip=_("Clear recent files list"), + triggered=self.clear_recent_files) + + # Fixes spyder-ide/spyder#6055. + # See: https://bugreports.qt.io/browse/QTBUG-8596 + self.tab_navigation_actions = [] + if sys.platform == 'darwin': + self.go_to_next_file_action = create_action( + self, + _("Go to next file"), + shortcut=CONF.get_shortcut('editor', 'go to previous file'), + triggered=self.go_to_next_file, + ) + self.go_to_previous_file_action = create_action( + self, + _("Go to previous file"), + shortcut=CONF.get_shortcut('editor', 'go to next file'), + triggered=self.go_to_previous_file, + ) + self.register_shortcut( + self.go_to_next_file_action, + context="Editor", + name="Go to next file", + ) + self.register_shortcut( + self.go_to_previous_file_action, + context="Editor", + name="Go to previous file", + ) + self.tab_navigation_actions = [ + MENU_SEPARATOR, + self.go_to_previous_file_action, + self.go_to_next_file_action, + ] + + # ---- File menu/toolbar construction ---- + self.recent_file_menu = QMenu(_("Open &recent"), self) + self.recent_file_menu.aboutToShow.connect(self.update_recent_file_menu) + + from spyder.plugins.mainmenu.api import ( + ApplicationMenus, FileMenuSections) + # New Section + self.main.mainmenu.add_item_to_application_menu( + self.new_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.New, + before_section=FileMenuSections.Restart, + omit_id=True) + # Open section + open_actions = [ + self.open_action, + self.open_last_closed_action, + self.recent_file_menu, + ] + for open_action in open_actions: + self.main.mainmenu.add_item_to_application_menu( + open_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Open, + before_section=FileMenuSections.Restart, + omit_id=True) + # Save section + save_actions = [ + self.save_action, + self.save_all_action, + save_as_action, + save_copy_as_action, + self.revert_action, + ] + for save_action in save_actions: + self.main.mainmenu.add_item_to_application_menu( + save_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Save, + before_section=FileMenuSections.Restart, + omit_id=True) + # Print + print_actions = [ + print_preview_action, + self.print_action, + ] + for print_action in print_actions: + self.main.mainmenu.add_item_to_application_menu( + print_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Print, + before_section=FileMenuSections.Restart, + omit_id=True) + # Close + close_actions = [ + self.close_action, + self.close_all_action + ] + for close_action in close_actions: + self.main.mainmenu.add_item_to_application_menu( + close_action, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Close, + before_section=FileMenuSections.Restart, + omit_id=True) + # Navigation + if sys.platform == 'darwin': + self.main.mainmenu.add_item_to_application_menu( + self.tab_navigation_actions, + menu_id=ApplicationMenus.File, + section=FileMenuSections.Navigation, + before_section=FileMenuSections.Restart, + omit_id=True) + + file_toolbar_actions = ([self.new_action, self.open_action, + self.save_action, self.save_all_action] + + self.main.file_toolbar_actions) + + self.main.file_toolbar_actions += file_toolbar_actions + + # ---- Find menu/toolbar construction ---- + search_menu_actions = [find_action, + find_next_action, + find_previous_action, + replace_action, + gotoline_action] + + self.main.search_toolbar_actions = [find_action, + find_next_action, + replace_action] + + # ---- Edit menu/toolbar construction ---- + self.edit_menu_actions = [self.toggle_comment_action, + blockcomment_action, unblockcomment_action, + self.indent_action, self.unindent_action, + self.text_uppercase_action, + self.text_lowercase_action] + + # ---- Search menu/toolbar construction ---- + if not hasattr(self.main, 'search_menu_actions'): + # This list will not exist in the fast tests. + self.main.search_menu_actions = [] + + self.main.search_menu_actions = ( + search_menu_actions + self.main.search_menu_actions) + + # ---- Run menu/toolbar construction ---- + run_menu_actions = [run_action, run_cell_action, + run_cell_advance_action, + re_run_last_cell_action, MENU_SEPARATOR, + run_selected_action, run_to_line_action, + run_from_line_action, re_run_action, + configure_action, MENU_SEPARATOR] + self.main.run_menu_actions = ( + run_menu_actions + self.main.run_menu_actions) + run_toolbar_actions = [run_action, run_cell_action, + run_cell_advance_action, run_selected_action] + self.main.run_toolbar_actions += run_toolbar_actions + + # ---- Debug menu/toolbar construction ---- + debug_menu_actions = [ + self.debug_action, + self.debug_cell_action, + self.debug_next_action, + self.debug_step_action, + self.debug_return_action, + self.debug_continue_action, + self.debug_exit_action, + MENU_SEPARATOR, + set_clear_breakpoint_action, + set_cond_breakpoint_action, + clear_all_breakpoints_action, + ] + self.main.debug_menu_actions = ( + debug_menu_actions + self.main.debug_menu_actions) + debug_toolbar_actions = [ + self.debug_action, + self.debug_next_action, + self.debug_step_action, + self.debug_return_action, + self.debug_continue_action, + self.debug_exit_action + ] + self.main.debug_toolbar_actions += debug_toolbar_actions + + # ---- Source menu/toolbar construction ---- + source_menu_actions = [ + showblanks_action, + scrollpastend_action, + showindentguides_action, + showcodefolding_action, + show_classfunc_dropdown_action, + show_codestyle_warnings_action, + show_docstring_warnings_action, + underline_errors, + MENU_SEPARATOR, + self.todo_list_action, + self.warning_list_action, + self.previous_warning_action, + self.next_warning_action, + MENU_SEPARATOR, + self.previous_edit_cursor_action, + self.previous_cursor_action, + self.next_cursor_action, + MENU_SEPARATOR, + eol_menu, + trailingspaces_action, + fixindentation_action, + self.formatting_action + ] + self.main.source_menu_actions = ( + source_menu_actions + self.main.source_menu_actions) + + # ---- Dock widget and file dependent actions ---- + self.dock_toolbar_actions = ( + file_toolbar_actions + + [MENU_SEPARATOR] + + run_toolbar_actions + + [MENU_SEPARATOR] + + debug_toolbar_actions + ) + self.pythonfile_dependent_actions = [ + run_action, + configure_action, + set_clear_breakpoint_action, + set_cond_breakpoint_action, + self.debug_action, + self.debug_cell_action, + run_selected_action, + run_cell_action, + run_cell_advance_action, + re_run_last_cell_action, + blockcomment_action, + unblockcomment_action, + ] + self.cythonfile_compatible_actions = [run_action, configure_action] + self.file_dependent_actions = ( + self.pythonfile_dependent_actions + + [ + self.save_action, + save_as_action, + save_copy_as_action, + print_preview_action, + self.print_action, + self.save_all_action, + gotoline_action, + workdir_action, + self.close_action, + self.close_all_action, + self.toggle_comment_action, + self.revert_action, + self.indent_action, + self.unindent_action + ] + ) + self.stack_menu_actions = [gotoline_action, workdir_action] + + return self.file_dependent_actions + + def update_pdb_state(self, state, last_step): + """ + Enable/disable debugging actions and handle pdb state change. + + Some examples depending on the debugging state: + self.debug_action.setEnabled(not state) + self.debug_cell_action.setEnabled(not state) + self.debug_next_action.setEnabled(state) + self.debug_step_action.setEnabled(state) + self.debug_return_action.setEnabled(state) + self.debug_continue_action.setEnabled(state) + self.debug_exit_action.setEnabled(state) + """ + current_editor = self.get_current_editor() + if current_editor: + current_editor.update_debugger_panel_state(state, last_step) + + def register_plugin(self): + """Register plugin in Spyder's main window""" + completions = self.main.get_plugin(Plugins.Completions, error=False) + outlineexplorer = self.main.get_plugin( + Plugins.OutlineExplorer, error=False) + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + + self.main.restore_scrollbar_position.connect( + self.restore_scrollbar_position) + self.main.console.sig_edit_goto_requested.connect(self.load) + self.redirect_stdio.connect(self.main.redirect_internalshell_stdio) + + if completions: + self.main.completions.sig_language_completions_available.connect( + self.register_completion_capabilities) + self.main.completions.sig_open_file.connect(self.load) + self.main.completions.sig_editor_rpc.connect(self._rpc_call) + self.main.completions.sig_stop_completions.connect( + self.stop_completion_services) + + self.sig_file_opened_closed_or_updated.connect( + self.main.completions.file_opened_closed_or_updated) + + if outlineexplorer: + self.set_outlineexplorer(self.main.outlineexplorer) + + if ipyconsole: + ipyconsole.register_spyder_kernel_call_handler( + 'cell_count', self.handle_cell_count) + ipyconsole.register_spyder_kernel_call_handler( + 'current_filename', self.handle_current_filename) + ipyconsole.register_spyder_kernel_call_handler( + 'get_file_code', self.handle_get_file_code) + ipyconsole.register_spyder_kernel_call_handler( + 'run_cell', self.handle_run_cell) + + self.add_dockwidget() + self.update_pdb_state(False, {}) + + # Add modes to switcher + self.switcher_manager = EditorSwitcherManager( + self, + self.main.switcher, + lambda: self.get_current_editor(), + lambda: self.get_current_editorstack(), + section=self.get_plugin_title()) + + def update_source_menu(self, options, **kwargs): + option_names = [opt[-1] if isinstance(opt, tuple) else opt + for opt in options] + named_options = dict(zip(option_names, options)) + for name, action in self.checkable_actions.items(): + if name in named_options: + if name == 'underline_errors': + section = 'editor' + opt = 'underline_errors' + else: + section = 'completions' + opt = named_options[name] + + state = self.get_option(opt, section=section) + + # Avoid triggering the action when this action changes state + # See: spyder-ide/spyder#9915 + action.blockSignals(True) + action.setChecked(state) + action.blockSignals(False) + + def update_font(self): + """Update font from Preferences""" + font = self.get_font() + color_scheme = self.get_color_scheme() + for editorstack in self.editorstacks: + editorstack.set_default_font(font, color_scheme) + completion_size = CONF.get('main', 'completion/size') + for finfo in editorstack.data: + comp_widget = finfo.editor.completion_widget + kite_call_to_action = finfo.editor.kite_call_to_action + comp_widget.setup_appearance(completion_size, font) + kite_call_to_action.setFont(font) + + def set_ancestor(self, ancestor): + """ + Set ancestor of child widgets like the CompletionWidget. + + Needed to properly set position of the widget based on the correct + parent/ancestor. + + See spyder-ide/spyder#11076 + """ + for editorstack in self.editorstacks: + for finfo in editorstack.data: + comp_widget = finfo.editor.completion_widget + kite_call_to_action = finfo.editor.kite_call_to_action + + # This is necessary to catch an error when the plugin is + # undocked and docked back, and (probably) a completion is + # in progress. + # Fixes spyder-ide/spyder#17486 + try: + comp_widget.setParent(ancestor) + kite_call_to_action.setParent(ancestor) + except RuntimeError: + pass + + def _create_checkable_action(self, text, conf_name, method=''): + """Helper function to create a checkable action. + + Args: + text (str): Text to be displayed in the action. + conf_name (str): configuration setting associated with the + action + method (str): name of EditorStack class that will be used + to update the changes in each editorstack. + """ + def toogle(checked): + self.switch_to_plugin() + self._toggle_checkable_action(checked, method, conf_name) + + action = create_action(self, text, toggled=toogle) + action.blockSignals(True) + + if conf_name not in ['pycodestyle', 'pydocstyle']: + action.setChecked(self.get_option(conf_name)) + else: + opt = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', conf_name), + False + ) + action.setChecked(opt) + + action.blockSignals(False) + + return action + + @Slot(bool, str, str) + def _toggle_checkable_action(self, checked, method_name, conf_name): + """ + Handle the toogle of a checkable action. + + Update editorstacks, PyLS and CONF. + + Args: + checked (bool): State of the action. + method_name (str): name of EditorStack class that will be used + to update the changes in each editorstack. + conf_name (str): configuration setting associated with the + action. + """ + if method_name: + if self.editorstacks: + for editorstack in self.editorstacks: + try: + method = getattr(editorstack, method_name) + method(checked) + except AttributeError as e: + logger.error(e, exc_info=True) + self.set_option(conf_name, checked) + else: + if conf_name in ('pycodestyle', 'pydocstyle'): + CONF.set( + 'completions', + ('provider_configuration', 'lsp', 'values', conf_name), + checked) + if self.main.get_plugin(Plugins.Completions, error=False): + completions = self.main.completions + completions.after_configuration_update([]) + + #------ Focus tabwidget + def __get_focused_editorstack(self): + fwidget = QApplication.focusWidget() + if isinstance(fwidget, EditorStack): + return fwidget + else: + for editorstack in self.editorstacks: + if editorstack.isAncestorOf(fwidget): + return editorstack + + def set_last_focused_editorstack(self, editorwindow, editorstack): + self.last_focused_editorstack[editorwindow] = editorstack + # very last editorstack + self.last_focused_editorstack[None] = editorstack + + def get_last_focused_editorstack(self, editorwindow=None): + return self.last_focused_editorstack[editorwindow] + + def remove_last_focused_editorstack(self, editorstack): + for editorwindow, widget in list( + self.last_focused_editorstack.items()): + if widget is editorstack: + self.last_focused_editorstack[editorwindow] = None + + def save_focused_editorstack(self): + editorstack = self.__get_focused_editorstack() + if editorstack is not None: + for win in [self]+self.editorwindows: + if win.isAncestorOf(editorstack): + self.set_last_focused_editorstack(win, editorstack) + + # ------ Handling editorstacks + def register_editorstack(self, editorstack): + self.editorstacks.append(editorstack) + self.register_widget_shortcuts(editorstack) + + if self.isAncestorOf(editorstack): + # editorstack is a child of the Editor plugin + self.set_last_focused_editorstack(self, editorstack) + editorstack.set_closable(len(self.editorstacks) > 1) + if self.outlineexplorer is not None: + editorstack.set_outlineexplorer( + self.outlineexplorer.get_widget()) + editorstack.set_find_widget(self.find_widget) + editorstack.reset_statusbar.connect(self.readwrite_status.hide) + editorstack.reset_statusbar.connect(self.encoding_status.hide) + editorstack.reset_statusbar.connect(self.cursorpos_status.hide) + editorstack.readonly_changed.connect( + self.readwrite_status.update_readonly) + editorstack.encoding_changed.connect( + self.encoding_status.update_encoding) + editorstack.sig_editor_cursor_position_changed.connect( + self.cursorpos_status.update_cursor_position) + editorstack.sig_editor_cursor_position_changed.connect( + self.current_editor_cursor_changed) + editorstack.sig_refresh_eol_chars.connect( + self.eol_status.update_eol) + editorstack.current_file_changed.connect( + self.vcs_status.update_vcs) + editorstack.file_saved.connect( + self.vcs_status.update_vcs_state) + + editorstack.set_io_actions(self.new_action, self.open_action, + self.save_action, self.revert_action) + editorstack.set_tempfile_path(self.TEMPFILE_PATH) + + settings = ( + ('set_todolist_enabled', 'todo_list'), + ('set_blanks_enabled', 'blank_spaces'), + ('set_underline_errors_enabled', 'underline_errors'), + ('set_scrollpastend_enabled', 'scroll_past_end'), + ('set_linenumbers_enabled', 'line_numbers'), + ('set_edgeline_enabled', 'edge_line'), + ('set_indent_guides', 'indent_guides'), + ('set_code_folding_enabled', 'code_folding'), + ('set_focus_to_editor', 'focus_to_editor'), + ('set_run_cell_copy', 'run_cell_copy'), + ('set_close_parentheses_enabled', 'close_parentheses'), + ('set_close_quotes_enabled', 'close_quotes'), + ('set_add_colons_enabled', 'add_colons'), + ('set_auto_unindent_enabled', 'auto_unindent'), + ('set_indent_chars', 'indent_chars'), + ('set_tab_stop_width_spaces', 'tab_stop_width_spaces'), + ('set_wrap_enabled', 'wrap'), + ('set_tabmode_enabled', 'tab_always_indent'), + ('set_stripmode_enabled', 'strip_trailing_spaces_on_modify'), + ('set_intelligent_backspace_enabled', 'intelligent_backspace'), + ('set_automatic_completions_enabled', 'automatic_completions'), + ('set_automatic_completions_after_chars', + 'automatic_completions_after_chars'), + ('set_automatic_completions_after_ms', + 'automatic_completions_after_ms'), + ('set_completions_hint_enabled', 'completions_hint'), + ('set_completions_hint_after_ms', + 'completions_hint_after_ms'), + ('set_highlight_current_line_enabled', 'highlight_current_line'), + ('set_highlight_current_cell_enabled', 'highlight_current_cell'), + ('set_occurrence_highlighting_enabled', 'occurrence_highlighting'), + ('set_occurrence_highlighting_timeout', 'occurrence_highlighting/timeout'), + ('set_checkeolchars_enabled', 'check_eol_chars'), + ('set_tabbar_visible', 'show_tab_bar'), + ('set_classfunc_dropdown_visible', 'show_class_func_dropdown'), + ('set_always_remove_trailing_spaces', 'always_remove_trailing_spaces'), + ('set_remove_trailing_newlines', 'always_remove_trailing_newlines'), + ('set_add_newline', 'add_newline'), + ('set_convert_eol_on_save', 'convert_eol_on_save'), + ('set_convert_eol_on_save_to', 'convert_eol_on_save_to'), + ) + + for method, setting in settings: + getattr(editorstack, method)(self.get_option(setting)) + + editorstack.set_help_enabled(CONF.get('help', 'connect/editor')) + + hover_hints = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', + 'enable_hover_hints'), + True + ) + + format_on_save = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'format_on_save'), + False + ) + + edge_line_columns = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', + 'pycodestyle/max_line_length'), + 79 + ) + + editorstack.set_hover_hints_enabled(hover_hints) + editorstack.set_format_on_save(format_on_save) + editorstack.set_edgeline_columns(edge_line_columns) + color_scheme = self.get_color_scheme() + editorstack.set_default_font(self.get_font(), color_scheme) + + editorstack.starting_long_process.connect(self.starting_long_process) + editorstack.ending_long_process.connect(self.ending_long_process) + + # Redirect signals + editorstack.sig_option_changed.connect(self.sig_option_changed) + editorstack.redirect_stdio.connect( + lambda state: self.redirect_stdio.emit(state)) + editorstack.exec_in_extconsole.connect( + lambda text, option: + self.exec_in_extconsole.emit(text, option)) + editorstack.run_cell_in_ipyclient.connect(self.run_cell_in_ipyclient) + editorstack.debug_cell_in_ipyclient.connect( + self.debug_cell_in_ipyclient) + editorstack.update_plugin_title.connect( + lambda: self.sig_update_plugin_title.emit()) + editorstack.editor_focus_changed.connect(self.save_focused_editorstack) + editorstack.editor_focus_changed.connect(self.main.plugin_focus_changed) + editorstack.editor_focus_changed.connect(self.sig_editor_focus_changed) + editorstack.zoom_in.connect(lambda: self.zoom(1)) + editorstack.zoom_out.connect(lambda: self.zoom(-1)) + editorstack.zoom_reset.connect(lambda: self.zoom(0)) + editorstack.sig_open_file.connect(self.report_open_file) + editorstack.sig_new_file.connect(lambda s: self.new(text=s)) + editorstack.sig_new_file[()].connect(self.new) + editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) + editorstack.sig_close_file.connect(self.remove_file_cursor_history) + editorstack.file_saved.connect(self.file_saved_in_editorstack) + editorstack.file_renamed_in_data.connect( + self.file_renamed_in_data_in_editorstack) + editorstack.opened_files_list_changed.connect( + self.opened_files_list_changed) + editorstack.active_languages_stats.connect( + self.update_active_languages) + editorstack.sig_go_to_definition.connect( + lambda fname, line, col: self.load( + fname, line, start_column=col)) + editorstack.sig_perform_completion_request.connect( + self.send_completion_request) + editorstack.todo_results_changed.connect(self.todo_results_changed) + editorstack.update_code_analysis_actions.connect( + self.update_code_analysis_actions) + editorstack.update_code_analysis_actions.connect( + self.update_todo_actions) + editorstack.refresh_file_dependent_actions.connect( + self.refresh_file_dependent_actions) + editorstack.refresh_save_all_action.connect(self.refresh_save_all_action) + editorstack.sig_refresh_eol_chars.connect(self.refresh_eol_chars) + editorstack.sig_refresh_formatting.connect(self.refresh_formatting) + editorstack.sig_breakpoints_saved.connect(self.breakpoints_saved) + editorstack.text_changed_at.connect(self.text_changed_at) + editorstack.current_file_changed.connect(self.current_file_changed) + editorstack.plugin_load.connect(self.load) + editorstack.plugin_load[()].connect(self.load) + editorstack.edit_goto.connect(self.load) + editorstack.sig_save_as.connect(self.save_as) + editorstack.sig_prev_edit_pos.connect(self.go_to_last_edit_location) + editorstack.sig_prev_cursor.connect(self.go_to_previous_cursor_position) + editorstack.sig_next_cursor.connect(self.go_to_next_cursor_position) + editorstack.sig_prev_warning.connect(self.go_to_previous_warning) + editorstack.sig_next_warning.connect(self.go_to_next_warning) + editorstack.sig_save_bookmark.connect(self.save_bookmark) + editorstack.sig_load_bookmark.connect(self.load_bookmark) + editorstack.sig_save_bookmarks.connect(self.save_bookmarks) + editorstack.sig_help_requested.connect(self.sig_help_requested) + + # Register editorstack's autosave component with plugin's autosave + # component + self.autosave.register_autosave_for_stack(editorstack.autosave) + + def unregister_editorstack(self, editorstack): + """Removing editorstack only if it's not the last remaining""" + self.remove_last_focused_editorstack(editorstack) + if len(self.editorstacks) > 1: + index = self.editorstacks.index(editorstack) + self.editorstacks.pop(index) + return True + else: + # editorstack was not removed! + return False + + def clone_editorstack(self, editorstack): + editorstack.clone_from(self.editorstacks[0]) + for finfo in editorstack.data: + self.register_widget_shortcuts(finfo.editor) + + @Slot(str, str) + def close_file_in_all_editorstacks(self, editorstack_id_str, filename): + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.blockSignals(True) + index = editorstack.get_index_from_filename(filename) + editorstack.close_file(index, force=True) + editorstack.blockSignals(False) + + @Slot(str, str, str) + def file_saved_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was saved in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.file_saved_in_other_editorstack(original_filename, + filename) + + @Slot(str, str, str) + def file_renamed_in_data_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was renamed in data in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.rename_in_data(original_filename, filename) + + #------ Handling editor windows + def setup_other_windows(self): + """Setup toolbars and menus for 'New window' instances""" + # TODO: All the actions here should be taken from + # the MainMenus plugin + file_menu_actions = self.main.mainmenu.get_application_menu( + ApplicationMenus.File).get_actions() + tools_menu_actions = self.main.mainmenu.get_application_menu( + ApplicationMenus.Tools).get_actions() + help_menu_actions = self.main.mainmenu.get_application_menu( + ApplicationMenus.Help).get_actions() + + self.toolbar_list = ((_("File toolbar"), "file_toolbar", + self.main.file_toolbar_actions), + (_("Run toolbar"), "run_toolbar", + self.main.run_toolbar_actions), + (_("Debug toolbar"), "debug_toolbar", + self.main.debug_toolbar_actions)) + + self.menu_list = ((_("&File"), file_menu_actions), + (_("&Edit"), self.main.edit_menu_actions), + (_("&Search"), self.main.search_menu_actions), + (_("Sour&ce"), self.main.source_menu_actions), + (_("&Run"), self.main.run_menu_actions), + (_("&Tools"), tools_menu_actions), + (_("&View"), []), + (_("&Help"), help_menu_actions)) + # Create pending new windows: + for layout_settings in self.editorwindows_to_be_created: + win = self.create_new_window() + win.set_layout_settings(layout_settings) + + def switch_to_plugin(self): + """ + Reimplemented method to deactivate shortcut when + opening a new window. + """ + if not self.editorwindows: + super(Editor, self).switch_to_plugin() + + def create_new_window(self): + window = EditorMainWindow( + self, self.stack_menu_actions, self.toolbar_list, self.menu_list) + window.add_toolbars_to_menu("&View", window.get_toolbars()) + window.load_toolbars() + window.resize(self.size()) + window.show() + window.editorwidget.editorsplitter.editorstack.new_window = True + self.register_editorwindow(window) + window.destroyed.connect(lambda: self.unregister_editorwindow(window)) + return window + + def register_editorwindow(self, window): + self.editorwindows.append(window) + + def unregister_editorwindow(self, window): + self.editorwindows.pop(self.editorwindows.index(window)) + + + #------ Accessors + def get_filenames(self): + return [finfo.filename for finfo in self.editorstacks[0].data] + + def get_filename_index(self, filename): + return self.editorstacks[0].has_filename(filename) + + def get_current_editorstack(self, editorwindow=None): + if self.editorstacks is not None: + if len(self.editorstacks) == 1: + editorstack = self.editorstacks[0] + else: + editorstack = self.__get_focused_editorstack() + if editorstack is None or editorwindow is not None: + editorstack = self.get_last_focused_editorstack( + editorwindow) + if editorstack is None: + editorstack = self.editorstacks[0] + return editorstack + + def get_current_editor(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_editor() + + def get_current_finfo(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_finfo() + + def get_current_filename(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_filename() + + def get_current_language(self): + editorstack = self.get_current_editorstack() + if editorstack is not None: + return editorstack.get_current_language() + + def is_file_opened(self, filename=None): + return self.editorstacks[0].is_file_opened(filename) + + def set_current_filename(self, filename, editorwindow=None, focus=True): + """Set focus to *filename* if this file has been opened. + + Return the editor instance associated to *filename*. + """ + editorstack = self.get_current_editorstack(editorwindow) + return editorstack.set_current_filename(filename, focus) + + def set_path(self): + for finfo in self.editorstacks[0].data: + finfo.path = self.main.get_spyder_pythonpath() + + #------ Refresh methods + def refresh_file_dependent_actions(self): + """Enable/disable file dependent actions + (only if dockwidget is visible)""" + if self.dockwidget and self.dockwidget.isVisible(): + enable = self.get_current_editor() is not None + for action in self.file_dependent_actions: + action.setEnabled(enable) + + def refresh_save_all_action(self): + """Enable 'Save All' if there are files to be saved""" + editorstack = self.get_current_editorstack() + if editorstack: + state = any(finfo.editor.document().isModified() or finfo.newly_created + for finfo in editorstack.data) + self.save_all_action.setEnabled(state) + + def update_warning_menu(self): + """Update warning list menu""" + editor = self.get_current_editor() + check_results = editor.get_current_warnings() + self.warning_menu.clear() + filename = self.get_current_filename() + for message, line_number in check_results: + error = 'syntax' in message + text = message[:1].upper() + message[1:] + icon = ima.icon('error') if error else ima.icon('warning') + slot = lambda _checked, _l=line_number: self.load(filename, goto=_l) + action = create_action(self, text=text, icon=icon) + action.triggered[bool].connect(slot) + self.warning_menu.addAction(action) + + def update_todo_menu(self): + """Update todo list menu""" + editorstack = self.get_current_editorstack() + results = editorstack.get_todo_results() + self.todo_menu.clear() + filename = self.get_current_filename() + for text, line0 in results: + icon = ima.icon('todo') + slot = lambda _checked, _l=line0: self.load(filename, goto=_l) + action = create_action(self, text=text, icon=icon) + action.triggered[bool].connect(slot) + self.todo_menu.addAction(action) + self.update_todo_actions() + + def todo_results_changed(self): + """ + Synchronize todo results between editorstacks + Refresh todo list navigation buttons + """ + editorstack = self.get_current_editorstack() + results = editorstack.get_todo_results() + index = editorstack.get_stack_index() + if index != -1: + filename = editorstack.data[index].filename + for other_editorstack in self.editorstacks: + if other_editorstack is not editorstack: + other_editorstack.set_todo_results(filename, results) + self.update_todo_actions() + + def refresh_eol_chars(self, os_name): + os_name = to_text_string(os_name) + self.__set_eol_chars = False + if os_name == 'nt': + self.win_eol_action.setChecked(True) + elif os_name == 'posix': + self.linux_eol_action.setChecked(True) + else: + self.mac_eol_action.setChecked(True) + self.__set_eol_chars = True + + def refresh_formatting(self, status): + self.formatting_action.setEnabled(status) + + def refresh_formatter_name(self): + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '') + self.formatting_action.setText( + _('Format file or selection with {0}').format( + formatter.capitalize())) + + #------ Slots + def opened_files_list_changed(self): + """ + Opened files list has changed: + --> open/close file action + --> modification ('*' added to title) + --> current edited file has changed + """ + # Refresh Python file dependent actions: + editor = self.get_current_editor() + if editor: + python_enable = editor.is_python_or_ipython() + cython_enable = python_enable or ( + programs.is_module_installed('Cython') and editor.is_cython()) + for action in self.pythonfile_dependent_actions: + if action in self.cythonfile_compatible_actions: + enable = cython_enable + else: + enable = python_enable + action.setEnabled(enable) + self.sig_file_opened_closed_or_updated.emit( + self.get_current_filename(), self.get_current_language()) + + def update_code_analysis_actions(self): + """Update actions in the warnings menu.""" + editor = self.get_current_editor() + + # To fix an error at startup + if editor is None: + return + + # Update actions state if there are errors present + for action in (self.warning_list_action, self.previous_warning_action, + self.next_warning_action): + action.setEnabled(editor.errors_present()) + + def update_todo_actions(self): + editorstack = self.get_current_editorstack() + results = editorstack.get_todo_results() + state = (self.get_option('todo_list') and + results is not None and len(results)) + if state is not None: + self.todo_list_action.setEnabled(state) + + @Slot(set) + def update_active_languages(self, languages): + if self.main.get_plugin(Plugins.Completions, error=False): + self.main.completions.update_client_status(languages) + + # ------ Bookmarks + def save_bookmarks(self, filename, bookmarks): + """Receive bookmark changes and save them.""" + filename = to_text_string(filename) + bookmarks = to_text_string(bookmarks) + filename = osp.normpath(osp.abspath(filename)) + bookmarks = eval(bookmarks) + save_bookmarks(filename, bookmarks) + + #------ File I/O + def __load_temp_file(self): + """Load temporary file from a text file in user home directory""" + if not osp.isfile(self.TEMPFILE_PATH): + # Creating temporary file + default = ['# -*- coding: utf-8 -*-', + '"""', _("Spyder Editor"), '', + _("This is a temporary script file."), + '"""', '', ''] + text = os.linesep.join([encoding.to_unicode(qstr) + for qstr in default]) + try: + encoding.write(to_text_string(text), self.TEMPFILE_PATH, + 'utf-8') + except EnvironmentError: + self.new() + return + + self.load(self.TEMPFILE_PATH) + + @Slot() + def __set_workdir(self): + """Set current script directory as working directory""" + fname = self.get_current_filename() + if fname is not None: + directory = osp.dirname(osp.abspath(fname)) + self.sig_dir_opened.emit(directory) + + def __add_recent_file(self, fname): + """Add to recent file list""" + if fname is None: + return + if fname in self.recent_files: + self.recent_files.remove(fname) + self.recent_files.insert(0, fname) + if len(self.recent_files) > self.get_option('max_recent_files'): + self.recent_files.pop(-1) + + def _clone_file_everywhere(self, finfo): + """Clone file (*src_editor* widget) in all editorstacks + Cloning from the first editorstack in which every single new editor + is created (when loading or creating a new file)""" + for editorstack in self.editorstacks[1:]: + editor = editorstack.clone_editor_from(finfo, set_current=False) + self.register_widget_shortcuts(editor) + + + @Slot() + @Slot(str) + def new(self, fname=None, editorstack=None, text=None): + """ + Create a new file - Untitled + + fname=None --> fname will be 'untitledXX.py' but do not create file + fname= --> create file + """ + # If no text is provided, create default content + empty = False + try: + if text is None: + default_content = True + text, enc = encoding.read(self.TEMPLATE_PATH) + enc_match = re.search(r'-*- coding: ?([a-z0-9A-Z\-]*) -*-', + text) + if enc_match: + enc = enc_match.group(1) + # Initialize template variables + # Windows + username = encoding.to_unicode_from_fs( + os.environ.get('USERNAME', '')) + # Linux, Mac OS X + if not username: + username = encoding.to_unicode_from_fs( + os.environ.get('USER', '-')) + VARS = { + 'date': time.ctime(), + 'username': username, + } + try: + text = text % VARS + except Exception: + pass + else: + default_content = False + enc = encoding.read(self.TEMPLATE_PATH)[1] + except (IOError, OSError): + text = '' + enc = 'utf-8' + default_content = True + + create_fname = lambda n: to_text_string(_("untitled")) + ("%d.py" % n) + # Creating editor widget + if editorstack is None: + current_es = self.get_current_editorstack() + else: + current_es = editorstack + created_from_here = fname is None + if created_from_here: + if self.untitled_num == 0: + for finfo in current_es.data: + current_filename = finfo.editor.filename + if _("untitled") in current_filename: + # Start the counter of the untitled_num with respect + # to this number if there's other untitled file in + # spyder. Please see spyder-ide/spyder#7831 + fname_data = osp.splitext(current_filename) + try: + act_num = int( + fname_data[0].split(_("untitled"))[-1]) + self.untitled_num = act_num + 1 + except ValueError: + # Catch the error in case the user has something + # different from a number after the untitled + # part. + # Please see spyder-ide/spyder#12892 + self.untitled_num = 0 + while True: + fname = create_fname(self.untitled_num) + self.untitled_num += 1 + if not osp.isfile(fname): + break + basedir = getcwd_or_home() + + projects = self.main.get_plugin(Plugins.Projects, error=False) + if projects and projects.get_active_project() is not None: + basedir = projects.get_active_project_path() + else: + c_fname = self.get_current_filename() + if c_fname is not None and c_fname != self.TEMPFILE_PATH: + basedir = osp.dirname(c_fname) + fname = osp.abspath(osp.join(basedir, fname)) + else: + # QString when triggered by a Qt signal + fname = osp.abspath(to_text_string(fname)) + index = current_es.has_filename(fname) + if index is not None and not current_es.close_file(index): + return + + # Creating the editor widget in the first editorstack (the one that + # can't be destroyed), then cloning this editor widget in all other + # editorstacks: + # Setting empty to True by default to avoid the additional space + # created at the end of the templates. + # See: spyder-ide/spyder#12596 + finfo = self.editorstacks[0].new(fname, enc, text, default_content, + empty=True) + finfo.path = self.main.get_spyder_pythonpath() + self._clone_file_everywhere(finfo) + current_editor = current_es.set_current_filename(finfo.filename) + self.register_widget_shortcuts(current_editor) + if not created_from_here: + self.save(force=True) + + def edit_template(self): + """Edit new file template""" + self.load(self.TEMPLATE_PATH) + + def update_recent_file_menu(self): + """Update recent file menu""" + recent_files = [] + for fname in self.recent_files: + if osp.isfile(fname): + recent_files.append(fname) + self.recent_file_menu.clear() + if recent_files: + for fname in recent_files: + action = create_action( + self, fname, + icon=ima.get_icon_by_extension_or_type( + fname, scale_factor=1.0)) + action.triggered[bool].connect(self.load) + action.setData(to_qvariant(fname)) + self.recent_file_menu.addAction(action) + self.clear_recent_action.setEnabled(len(recent_files) > 0) + add_actions(self.recent_file_menu, (None, self.max_recent_action, + self.clear_recent_action)) + + @Slot() + def clear_recent_files(self): + """Clear recent files list""" + self.recent_files = [] + + @Slot() + def change_max_recent_files(self): + "Change max recent files entries""" + editorstack = self.get_current_editorstack() + mrf, valid = QInputDialog.getInt(editorstack, _('Editor'), + _('Maximum number of recent files'), + self.get_option('max_recent_files'), 1, 35) + if valid: + self.set_option('max_recent_files', mrf) + + @Slot() + @Slot(str) + @Slot(str, int, str) + @Slot(str, int, str, object) + def load(self, filenames=None, goto=None, word='', + editorwindow=None, processevents=True, start_column=None, + end_column=None, set_focus=True, add_where='end'): + """ + Load a text file + editorwindow: load in this editorwindow (useful when clicking on + outline explorer with multiple editor windows) + processevents: determines if processEvents() should be called at the + end of this method (set to False to prevent keyboard events from + creeping through to the editor during debugging) + If goto is not none it represent a line to go to. start_column is + the start position in this line and end_column the length + (So that the end position is start_column + end_column) + Alternatively, the first match of word is used as a position. + """ + cursor_history_state = self.__ignore_cursor_history + self.__ignore_cursor_history = True + # Switch to editor before trying to load a file + try: + self.switch_to_plugin() + except AttributeError: + pass + + editor0 = self.get_current_editor() + if editor0 is not None: + filename0 = self.get_current_filename() + else: + filename0 = None + if not filenames: + # Recent files action + action = self.sender() + if isinstance(action, QAction): + filenames = from_qvariant(action.data(), to_text_string) + if not filenames: + basedir = getcwd_or_home() + if self.edit_filetypes is None: + self.edit_filetypes = get_edit_filetypes() + if self.edit_filters is None: + self.edit_filters = get_edit_filters() + + c_fname = self.get_current_filename() + if c_fname is not None and c_fname != self.TEMPFILE_PATH: + basedir = osp.dirname(c_fname) + + self.redirect_stdio.emit(False) + parent_widget = self.get_current_editorstack() + if filename0 is not None: + selectedfilter = get_filter(self.edit_filetypes, + osp.splitext(filename0)[1]) + else: + selectedfilter = '' + + if not running_under_pytest(): + # See: spyder-ide/spyder#3291 + if sys.platform == 'darwin': + dialog = QFileDialog( + parent=parent_widget, + caption=_("Open file"), + directory=basedir, + ) + dialog.setNameFilters(self.edit_filters.split(';;')) + dialog.setOption(QFileDialog.HideNameFilterDetails, True) + dialog.setFilter(QDir.AllDirs | QDir.Files | QDir.Drives + | QDir.Hidden) + dialog.setFileMode(QFileDialog.ExistingFiles) + + if dialog.exec_(): + filenames = dialog.selectedFiles() + else: + filenames, _sf = getopenfilenames( + parent_widget, + _("Open file"), + basedir, + self.edit_filters, + selectedfilter=selectedfilter, + options=QFileDialog.HideNameFilterDetails, + ) + else: + # Use a Qt (i.e. scriptable) dialog for pytest + dialog = QFileDialog(parent_widget, _("Open file"), + options=QFileDialog.DontUseNativeDialog) + if dialog.exec_(): + filenames = dialog.selectedFiles() + + self.redirect_stdio.emit(True) + + if filenames: + filenames = [osp.normpath(fname) for fname in filenames] + else: + self.__ignore_cursor_history = cursor_history_state + return + + focus_widget = QApplication.focusWidget() + if self.editorwindows and not self.dockwidget.isVisible(): + # We override the editorwindow variable to force a focus on + # the editor window instead of the hidden editor dockwidget. + # See spyder-ide/spyder#5742. + if editorwindow not in self.editorwindows: + editorwindow = self.editorwindows[0] + editorwindow.setFocus() + editorwindow.raise_() + elif (self.dockwidget and not self._ismaximized + and not self.dockwidget.isAncestorOf(focus_widget) + and not isinstance(focus_widget, CodeEditor)): + self.switch_to_plugin() + + def _convert(fname): + fname = osp.abspath(encoding.to_unicode_from_fs(fname)) + if os.name == 'nt' and len(fname) >= 2 and fname[1] == ':': + fname = fname[0].upper()+fname[1:] + return fname + + if hasattr(filenames, 'replaceInStrings'): + # This is a QStringList instance (PyQt API #1), converting to list: + filenames = list(filenames) + if not isinstance(filenames, list): + filenames = [_convert(filenames)] + else: + filenames = [_convert(fname) for fname in list(filenames)] + if isinstance(goto, int): + goto = [goto] + elif goto is not None and len(goto) != len(filenames): + goto = None + + for index, filename in enumerate(filenames): + # -- Do not open an already opened file + focus = set_focus and index == 0 + current_editor = self.set_current_filename(filename, + editorwindow, + focus=focus) + if current_editor is None: + # -- Not a valid filename: + if not osp.isfile(filename): + continue + # -- + current_es = self.get_current_editorstack(editorwindow) + # Creating the editor widget in the first editorstack + # (the one that can't be destroyed), then cloning this + # editor widget in all other editorstacks: + finfo = self.editorstacks[0].load( + filename, set_current=False, add_where=add_where, + processevents=processevents) + finfo.path = self.main.get_spyder_pythonpath() + self._clone_file_everywhere(finfo) + current_editor = current_es.set_current_filename(filename, + focus=focus) + current_editor.debugger.load_breakpoints() + current_editor.set_bookmarks(load_bookmarks(filename)) + self.register_widget_shortcuts(current_editor) + current_es.analyze_script() + self.__add_recent_file(filename) + if goto is not None: # 'word' is assumed to be None as well + current_editor.go_to_line(goto[index], word=word, + start_column=start_column, + end_column=end_column) + current_editor.clearFocus() + current_editor.setFocus() + current_editor.window().raise_() + if processevents: + QApplication.processEvents() + else: + # processevents is false only when calling from debugging + current_editor.sig_debug_stop.emit(goto[index]) + + ipyconsole = self.main.get_plugin( + Plugins.IPythonConsole, error=False) + if ipyconsole: + current_sw = ipyconsole.get_current_shellwidget() + current_sw.sig_prompt_ready.connect( + current_editor.sig_debug_stop[()]) + current_pdb_state = ipyconsole.get_pdb_state() + pdb_last_step = ipyconsole.get_pdb_last_step() + self.update_pdb_state(current_pdb_state, pdb_last_step) + + self.__ignore_cursor_history = cursor_history_state + self.add_cursor_to_history() + + def _create_print_editor(self): + """Create a SimpleCodeEditor instance to print file contents.""" + editor = SimpleCodeEditor(self) + editor.setup_editor( + color_scheme="scintilla", highlight_current_line=False + ) + return editor + + @Slot() + def print_file(self): + """Print current file.""" + editor = self.get_current_editor() + filename = self.get_current_filename() + + # Set print editor + self._print_editor.set_text(editor.toPlainText()) + self._print_editor.set_language(editor.language) + self._print_editor.set_font(self.get_font()) + + # Create printer + printer = Printer(mode=QPrinter.HighResolution, + header_font=self.get_font()) + print_dialog = QPrintDialog(printer, self._print_editor) + + # Adjust print options when user has selected text + if editor.has_selected_text(): + print_dialog.setOption(QAbstractPrintDialog.PrintSelection, True) + + # Copy selection from current editor to print editor + cursor_1 = editor.textCursor() + start, end = cursor_1.selectionStart(), cursor_1.selectionEnd() + + cursor_2 = self._print_editor.textCursor() + cursor_2.setPosition(start) + cursor_2.setPosition(end, QTextCursor.KeepAnchor) + self._print_editor.setTextCursor(cursor_2) + + # Print + self.redirect_stdio.emit(False) + answer = print_dialog.exec_() + self.redirect_stdio.emit(True) + + if answer == QDialog.Accepted: + self.starting_long_process(_("Printing...")) + printer.setDocName(filename) + self._print_editor.print_(printer) + self.ending_long_process() + + # Clear selection + self._print_editor.textCursor().removeSelectedText() + + @Slot() + def print_preview(self): + """Print preview for current file.""" + editor = self.get_current_editor() + + # Set print editor + self._print_editor.set_text(editor.toPlainText()) + self._print_editor.set_language(editor.language) + self._print_editor.set_font(self.get_font()) + + # Create printer + printer = Printer(mode=QPrinter.HighResolution, + header_font=self.get_font()) + + # Create preview + preview = QPrintPreviewDialog(printer, self) + preview.setWindowFlags(Qt.Window) + preview.paintRequested.connect( + lambda printer: self._print_editor.print_(printer) + ) + + # Show preview + self.redirect_stdio.emit(False) + preview.exec_() + self.redirect_stdio.emit(True) + + def can_close_file(self, filename=None): + """ + Check if a file can be closed taking into account debugging state. + """ + if not CONF.get('ipython_console', 'pdb_prevent_closing'): + return True + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + + debugging = False + last_pdb_step = {} + if ipyconsole: + debugging = ipyconsole.get_pdb_state() + last_pdb_step = ipyconsole.get_pdb_last_step() + + can_close = True + if debugging and 'fname' in last_pdb_step and filename: + if osp.normcase(last_pdb_step['fname']) == osp.normcase(filename): + can_close = False + self.sig_file_debug_message_requested.emit() + elif debugging: + can_close = False + self.sig_file_debug_message_requested.emit() + return can_close + + @Slot() + def close_file(self): + """Close current file""" + filename = self.get_current_filename() + if self.can_close_file(filename=filename): + editorstack = self.get_current_editorstack() + editorstack.close_file() + + @Slot() + def close_all_files(self): + """Close all opened scripts""" + self.editorstacks[0].close_all_files() + + @Slot() + def save(self, index=None, force=False): + """Save file""" + editorstack = self.get_current_editorstack() + return editorstack.save(index=index, force=force) + + @Slot() + def save_as(self): + """Save *as* the currently edited file""" + editorstack = self.get_current_editorstack() + if editorstack.save_as(): + fname = editorstack.get_current_filename() + self.__add_recent_file(fname) + + @Slot() + def save_copy_as(self): + """Save *copy as* the currently edited file""" + editorstack = self.get_current_editorstack() + editorstack.save_copy_as() + + @Slot() + def save_all(self, save_new_files=True): + """Save all opened files""" + self.get_current_editorstack().save_all(save_new_files=save_new_files) + + @Slot() + def revert(self): + """Revert the currently edited file from disk""" + editorstack = self.get_current_editorstack() + editorstack.revert() + + @Slot() + def find(self): + """Find slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.show() + editorstack.find_widget.search_text.setFocus() + + @Slot() + def find_next(self): + """Fnd next slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.find_next() + + @Slot() + def find_previous(self): + """Find previous slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.find_previous() + + @Slot() + def replace(self): + """Replace slot""" + editorstack = self.get_current_editorstack() + editorstack.find_widget.show_replace() + + def open_last_closed(self): + """ Reopens the last closed tab.""" + editorstack = self.get_current_editorstack() + last_closed_files = editorstack.get_last_closed_files() + if (len(last_closed_files) > 0): + file_to_open = last_closed_files[0] + last_closed_files.remove(file_to_open) + editorstack.set_last_closed_files(last_closed_files) + self.load(file_to_open) + + #------ Explorer widget + def close_file_from_name(self, filename): + """Close file from its name""" + filename = osp.abspath(to_text_string(filename)) + index = self.editorstacks[0].has_filename(filename) + if index is not None: + self.editorstacks[0].close_file(index) + + def removed(self, filename): + """File was removed in file explorer widget or in project explorer""" + self.close_file_from_name(filename) + + def removed_tree(self, dirname): + """Directory was removed in project explorer widget""" + dirname = osp.abspath(to_text_string(dirname)) + for fname in self.get_filenames(): + if osp.abspath(fname).startswith(dirname): + self.close_file_from_name(fname) + + def renamed(self, source, dest): + """ + Propagate file rename to editor stacks and autosave component. + + This function is called when a file is renamed in the file explorer + widget or the project explorer. The file may not be opened in the + editor. + """ + filename = osp.abspath(to_text_string(source)) + index = self.editorstacks[0].has_filename(filename) + if index is not None: + for editorstack in self.editorstacks: + editorstack.rename_in_data(filename, + new_filename=to_text_string(dest)) + self.editorstacks[0].autosave.file_renamed( + filename, to_text_string(dest)) + + def renamed_tree(self, source, dest): + """Directory was renamed in file explorer or in project explorer.""" + dirname = osp.abspath(to_text_string(source)) + tofile = to_text_string(dest) + for fname in self.get_filenames(): + if osp.abspath(fname).startswith(dirname): + new_filename = fname.replace(dirname, tofile) + self.renamed(source=fname, dest=new_filename) + + #------ Source code + @Slot() + def indent(self): + """Indent current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.indent() + + @Slot() + def unindent(self): + """Unindent current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.unindent() + + @Slot() + def text_uppercase(self): + """Change current line or selection to uppercase.""" + editor = self.get_current_editor() + if editor is not None: + editor.transform_to_uppercase() + + @Slot() + def text_lowercase(self): + """Change current line or selection to lowercase.""" + editor = self.get_current_editor() + if editor is not None: + editor.transform_to_lowercase() + + @Slot() + def toggle_comment(self): + """Comment current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.toggle_comment() + + @Slot() + def blockcomment(self): + """Block comment current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.blockcomment() + + @Slot() + def unblockcomment(self): + """Un-block comment current line or selection""" + editor = self.get_current_editor() + if editor is not None: + editor.unblockcomment() + @Slot() + def go_to_next_todo(self): + self.switch_to_plugin() + editor = self.get_current_editor() + editor.go_to_next_todo() + filename = self.get_current_filename() + cursor = editor.textCursor() + self.add_cursor_to_history(filename, cursor) + + @Slot() + def go_to_next_warning(self): + self.switch_to_plugin() + editor = self.get_current_editor() + editor.go_to_next_warning() + filename = self.get_current_filename() + cursor = editor.textCursor() + self.add_cursor_to_history(filename, cursor) + + @Slot() + def go_to_previous_warning(self): + self.switch_to_plugin() + editor = self.get_current_editor() + editor.go_to_previous_warning() + filename = self.get_current_filename() + cursor = editor.textCursor() + self.add_cursor_to_history(filename, cursor) + + def toggle_eol_chars(self, os_name, checked): + if checked: + editor = self.get_current_editor() + if self.__set_eol_chars: + self.switch_to_plugin() + editor.set_eol_chars( + eol_chars=sourcecode.get_eol_chars_from_os_name(os_name) + ) + + @Slot() + def remove_trailing_spaces(self): + self.switch_to_plugin() + editorstack = self.get_current_editorstack() + editorstack.remove_trailing_spaces() + + @Slot() + def format_document_or_selection(self): + self.switch_to_plugin() + editorstack = self.get_current_editorstack() + editorstack.format_document_or_selection() + + @Slot() + def fix_indentation(self): + self.switch_to_plugin() + editorstack = self.get_current_editorstack() + editorstack.fix_indentation() + + #------ Cursor position history management + def update_cursorpos_actions(self): + self.previous_edit_cursor_action.setEnabled( + self.last_edit_cursor_pos is not None) + self.previous_cursor_action.setEnabled( + len(self.cursor_undo_history) > 0) + self.next_cursor_action.setEnabled( + len(self.cursor_redo_history) > 0) + + def add_cursor_to_history(self, filename=None, cursor=None): + if self.__ignore_cursor_history: + return + if filename is None: + filename = self.get_current_filename() + if cursor is None: + editor = self._get_editor(filename) + if editor is None: + return + cursor = editor.textCursor() + + replace_last_entry = False + if len(self.cursor_undo_history) > 0: + fname, hist_cursor = self.cursor_undo_history[-1] + if fname == filename: + if cursor.blockNumber() == hist_cursor.blockNumber(): + # Only one cursor per line + replace_last_entry = True + + if replace_last_entry: + self.cursor_undo_history.pop() + else: + # Drop redo stack as we moved + self.cursor_redo_history = [] + + self.cursor_undo_history.append((filename, cursor)) + self.update_cursorpos_actions() + + def text_changed_at(self, filename, position): + self.last_edit_cursor_pos = (to_text_string(filename), position) + + def current_file_changed(self, filename, position, line, column): + cursor = self.get_current_editor().textCursor() + self.add_cursor_to_history(to_text_string(filename), cursor) + + # Hide any open tooltips + current_stack = self.get_current_editorstack() + if current_stack is not None: + current_stack.hide_tooltip() + + # Update debugging state + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + if ipyconsole is not None: + pdb_state = ipyconsole.get_pdb_state() + pdb_last_step = ipyconsole.get_pdb_last_step() + self.update_pdb_state(pdb_state, pdb_last_step) + + def current_editor_cursor_changed(self, line, column): + """Handles the change of the cursor inside the current editor.""" + code_editor = self.get_current_editor() + filename = code_editor.filename + cursor = code_editor.textCursor() + self.add_cursor_to_history( + to_text_string(filename), cursor) + + def remove_file_cursor_history(self, id, filename): + """Remove the cursor history of a file if the file is closed.""" + new_history = [] + for i, (cur_filename, cursor) in enumerate( + self.cursor_undo_history): + if cur_filename != filename: + new_history.append((cur_filename, cursor)) + self.cursor_undo_history = new_history + + new_redo_history = [] + for i, (cur_filename, cursor) in enumerate( + self.cursor_redo_history): + if cur_filename != filename: + new_redo_history.append((cur_filename, cursor)) + self.cursor_redo_history = new_redo_history + + @Slot() + def go_to_last_edit_location(self): + if self.last_edit_cursor_pos is not None: + filename, position = self.last_edit_cursor_pos + if not osp.isfile(filename): + self.last_edit_cursor_pos = None + return + else: + self.load(filename) + editor = self.get_current_editor() + if position < editor.document().characterCount(): + editor.set_cursor_position(position) + + def _pop_next_cursor_diff(self, history, current_filename, current_cursor): + """Get the next cursor from history that is different from current.""" + while history: + filename, cursor = history.pop() + if (filename != current_filename or + cursor.position() != current_cursor.position()): + return filename, cursor + return None, None + + def _history_steps(self, number_steps, + backwards_history, forwards_history, + current_filename, current_cursor): + """ + Move number_steps in the forwards_history, filling backwards_history. + """ + for i in range(number_steps): + if len(forwards_history) > 0: + # Put the current cursor in history + backwards_history.append( + (current_filename, current_cursor)) + # Extract the next different cursor + current_filename, current_cursor = ( + self._pop_next_cursor_diff( + forwards_history, + current_filename, current_cursor)) + if current_cursor is None: + # Went too far, back up once + current_filename, current_cursor = ( + backwards_history.pop()) + return current_filename, current_cursor + + + def __move_cursor_position(self, index_move): + """ + Move the cursor position forward or backward in the cursor + position history by the specified index increment. + """ + self.__ignore_cursor_history = True + # Remove last position as it will be replaced by the current position + if self.cursor_undo_history: + self.cursor_undo_history.pop() + + # Update last position on the line + current_filename = self.get_current_filename() + current_cursor = self.get_current_editor().textCursor() + + if index_move < 0: + # Undo + current_filename, current_cursor = self._history_steps( + -index_move, + self.cursor_redo_history, + self.cursor_undo_history, + current_filename, current_cursor) + + else: + # Redo + current_filename, current_cursor = self._history_steps( + index_move, + self.cursor_undo_history, + self.cursor_redo_history, + current_filename, current_cursor) + + # Place current cursor in history + self.cursor_undo_history.append( + (current_filename, current_cursor)) + filenames = self.get_current_editorstack().get_filenames() + if (not osp.isfile(current_filename) + and current_filename not in filenames): + self.cursor_undo_history.pop() + else: + self.load(current_filename) + editor = self.get_current_editor() + editor.setTextCursor(current_cursor) + editor.ensureCursorVisible() + self.__ignore_cursor_history = False + self.update_cursorpos_actions() + + @Slot() + def go_to_previous_cursor_position(self): + self.__ignore_cursor_history = True + self.switch_to_plugin() + self.__move_cursor_position(-1) + + @Slot() + def go_to_next_cursor_position(self): + self.__ignore_cursor_history = True + self.switch_to_plugin() + self.__move_cursor_position(1) + + @Slot() + def go_to_line(self, line=None): + """Open 'go to line' dialog""" + if isinstance(line, bool): + line = None + editorstack = self.get_current_editorstack() + if editorstack is not None: + editorstack.go_to_line(line) + + @Slot() + def set_or_clear_breakpoint(self): + """Set/Clear breakpoint""" + editorstack = self.get_current_editorstack() + if editorstack is not None: + self.switch_to_plugin() + editorstack.set_or_clear_breakpoint() + + @Slot() + def set_or_edit_conditional_breakpoint(self): + """Set/Edit conditional breakpoint""" + editorstack = self.get_current_editorstack() + if editorstack is not None: + self.switch_to_plugin() + editorstack.set_or_edit_conditional_breakpoint() + + @Slot() + def clear_all_breakpoints(self): + """Clear breakpoints in all files""" + self.switch_to_plugin() + clear_all_breakpoints() + self.breakpoints_saved.emit() + editorstack = self.get_current_editorstack() + if editorstack is not None: + for data in editorstack.data: + data.editor.debugger.clear_breakpoints() + self.refresh_plugin() + + def clear_breakpoint(self, filename, lineno): + """Remove a single breakpoint""" + clear_breakpoint(filename, lineno) + self.breakpoints_saved.emit() + editorstack = self.get_current_editorstack() + if editorstack is not None: + index = self.is_file_opened(filename) + if index is not None: + editorstack.data[index].editor.debugger.toogle_breakpoint( + lineno) + + def stop_debugging(self): + """Stop debugging""" + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + if ipyconsole: + ipyconsole.stop_debugging() + + def debug_command(self, command): + """Debug actions""" + self.switch_to_plugin() + ipyconsole = self.main.get_plugin(Plugins.IPythonConsole, error=False) + if ipyconsole: + ipyconsole.pdb_execute_command(command) + ipyconsole.switch_to_plugin() + + # ----- Handlers for the IPython Console kernels + def _get_editorstack(self): + """ + Get the current editorstack. + + Raises an exception in case no editorstack is found + """ + editorstack = self.get_current_editorstack() + if editorstack is None: + raise RuntimeError('No editorstack found.') + + return editorstack + + def _get_editor(self, filename): + """Get editor for filename and set it as the current editor.""" + editorstack = self._get_editorstack() + if editorstack is None: + return None + + if not filename: + return None + + index = editorstack.has_filename(filename) + if index is None: + return None + + return editorstack.data[index].editor + + def handle_run_cell(self, cell_name, filename): + """ + Get cell code from cell name and file name. + """ + editorstack = self._get_editorstack() + editor = self._get_editor(filename) + + if editor is None: + raise RuntimeError( + "File {} not open in the editor".format(filename)) + + editorstack.last_cell_call = (filename, cell_name) + + # The file is open, load code from editor + return editor.get_cell_code(cell_name) + + def handle_cell_count(self, filename): + """Get number of cells in file to loop.""" + editor = self._get_editor(filename) + + if editor is None: + raise RuntimeError( + "File {} not open in the editor".format(filename)) + + # The file is open, get cell count from editor + return editor.get_cell_count() + + def handle_current_filename(self, filename): + """Get the current filename.""" + return self._get_editorstack().get_current_finfo().filename + + def handle_get_file_code(self, filename, save_all=True): + """ + Return the bytes that compose the file. + + Bytes are returned instead of str to support non utf-8 files. + """ + editorstack = self._get_editorstack() + if save_all and CONF.get( + 'editor', 'save_all_before_run', default=True): + editorstack.save_all(save_new_files=False) + editor = self._get_editor(filename) + + if editor is None: + # Load it from file instead + text, _enc = encoding.read(filename) + return text + + return editor.toPlainText() + + #------ Run Python script + @Slot() + def edit_run_configurations(self): + dialog = RunConfigDialog(self) + dialog.size_change.connect(lambda s: self.set_dialog_size(s)) + if self.dialog_size is not None: + dialog.resize(self.dialog_size) + fname = osp.abspath(self.get_current_filename()) + dialog.setup(fname) + if dialog.exec_(): + fname = dialog.file_to_run + if fname is not None: + self.load(fname) + self.run_file() + + @Slot() + def run_file(self, debug=False): + """Run script inside current interpreter or in a new one""" + editorstack = self.get_current_editorstack() + + editor = self.get_current_editor() + fname = osp.abspath(self.get_current_filename()) + + # Get fname's dirname before we escape the single and double + # quotes. Fixes spyder-ide/spyder#6771. + dirname = osp.dirname(fname) + + # Escape single and double quotes in fname and dirname. + # Fixes spyder-ide/spyder#2158. + fname = fname.replace("'", r"\'").replace('"', r'\"') + dirname = dirname.replace("'", r"\'").replace('"', r'\"') + + runconf = get_run_configuration(fname) + if runconf is None: + dialog = RunConfigOneDialog(self) + dialog.size_change.connect(lambda s: self.set_dialog_size(s)) + if self.dialog_size is not None: + dialog.resize(self.dialog_size) + dialog.setup(fname) + if CONF.get('run', 'open_at_least_once', + not running_under_pytest()): + # Open Run Config dialog at least once: the first time + # a script is ever run in Spyder, so that the user may + # see it at least once and be conscious that it exists + show_dlg = True + CONF.set('run', 'open_at_least_once', False) + else: + # Open Run Config dialog only + # if ALWAYS_OPEN_FIRST_RUN_OPTION option is enabled + show_dlg = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION) + if show_dlg and not dialog.exec_(): + return + runconf = dialog.get_configuration() + + if runconf.default: + # use global run preferences settings + runconf = RunConfiguration() + + args = runconf.get_arguments() + python_args = runconf.get_python_arguments() + interact = runconf.interact + post_mortem = runconf.post_mortem + current = runconf.current + systerm = runconf.systerm + clear_namespace = runconf.clear_namespace + console_namespace = runconf.console_namespace + + if runconf.file_dir: + wdir = dirname + elif runconf.cw_dir: + wdir = '' + elif osp.isdir(runconf.dir): + wdir = runconf.dir + else: + wdir = '' + + python = True # Note: in the future, it may be useful to run + # something in a terminal instead of a Python interp. + self.__last_ec_exec = (fname, wdir, args, interact, debug, + python, python_args, current, systerm, + post_mortem, clear_namespace, + console_namespace) + self.re_run_file(save_new_files=False) + if not interact and not debug: + # If external console dockwidget is hidden, it will be + # raised in top-level and so focus will be given to the + # current external shell automatically + # (see SpyderPluginWidget.visibility_changed method) + editor.setFocus() + + def set_dialog_size(self, size): + self.dialog_size = size + + @Slot() + def debug_file(self): + """Debug current script""" + self.switch_to_plugin() + current_editor = self.get_current_editor() + if current_editor is not None: + current_editor.sig_debug_start.emit() + self.run_file(debug=True) + + @Slot() + def re_run_file(self, save_new_files=True): + """Re-run last script""" + if self.get_option('save_all_before_run'): + all_saved = self.save_all(save_new_files=save_new_files) + if all_saved is not None and not all_saved: + return + if self.__last_ec_exec is None: + return + (fname, wdir, args, interact, debug, + python, python_args, current, systerm, + post_mortem, clear_namespace, + console_namespace) = self.__last_ec_exec + if not systerm: + self.run_in_current_ipyclient.emit(fname, wdir, args, + debug, post_mortem, + current, clear_namespace, + console_namespace) + else: + self.main.open_external_console(fname, wdir, args, interact, + debug, python, python_args, + systerm, post_mortem) + + @Slot() + def run_selection(self): + """Run selection or current line in external console""" + editorstack = self.get_current_editorstack() + editorstack.run_selection() + + @Slot() + def run_to_line(self): + """Run all lines from beginning up to current line""" + editorstack = self.get_current_editorstack() + editorstack.run_to_line() + + @Slot() + def run_from_line(self): + """Run all lines from current line to end""" + editorstack = self.get_current_editorstack() + editorstack.run_from_line() + + @Slot() + def run_cell(self): + """Run current cell""" + editorstack = self.get_current_editorstack() + editorstack.run_cell() + + @Slot() + def run_cell_and_advance(self): + """Run current cell and advance to the next one""" + editorstack = self.get_current_editorstack() + editorstack.run_cell_and_advance() + + @Slot() + def debug_cell(self): + '''Debug Current cell.''' + editorstack = self.get_current_editorstack() + editorstack.debug_cell() + + @Slot() + def re_run_last_cell(self): + """Run last executed cell.""" + editorstack = self.get_current_editorstack() + editorstack.re_run_last_cell() + + # ------ Code bookmarks + @Slot(int) + def save_bookmark(self, slot_num): + """Save current line and position as bookmark.""" + bookmarks = CONF.get('editor', 'bookmarks') + editorstack = self.get_current_editorstack() + if slot_num in bookmarks: + filename, line_num, column = bookmarks[slot_num] + if osp.isfile(filename): + index = editorstack.has_filename(filename) + if index is not None: + block = (editorstack.tabs.widget(index).document() + .findBlockByNumber(line_num)) + block.userData().bookmarks.remove((slot_num, column)) + if editorstack is not None: + self.switch_to_plugin() + editorstack.set_bookmark(slot_num) + + @Slot(int) + def load_bookmark(self, slot_num): + """Set cursor to bookmarked file and position.""" + bookmarks = CONF.get('editor', 'bookmarks') + if slot_num in bookmarks: + filename, line_num, column = bookmarks[slot_num] + else: + return + if not osp.isfile(filename): + self.last_edit_cursor_pos = None + return + self.load(filename) + editor = self.get_current_editor() + if line_num < editor.document().lineCount(): + linelength = len(editor.document() + .findBlockByNumber(line_num).text()) + if column <= linelength: + editor.go_to_line(line_num + 1, column) + else: + # Last column + editor.go_to_line(line_num + 1, linelength) + + #------ Zoom in/out/reset + def zoom(self, factor): + """Zoom in/out/reset""" + editor = self.get_current_editorstack().get_current_editor() + if factor == 0: + font = self.get_font() + editor.set_font(font) + else: + font = editor.font() + size = font.pointSize() + factor + if size > 0: + font.setPointSize(size) + editor.set_font(font) + editor.update_tab_stop_width_spaces() + + #------ Options + def apply_plugin_settings(self, options): + """Apply configuration file's plugin settings""" + if self.editorstacks is not None: + # --- syntax highlight and text rendering settings + currentline_n = 'highlight_current_line' + currentline_o = self.get_option(currentline_n) + currentcell_n = 'highlight_current_cell' + currentcell_o = self.get_option(currentcell_n) + occurrence_n = 'occurrence_highlighting' + occurrence_o = self.get_option(occurrence_n) + occurrence_timeout_n = 'occurrence_highlighting/timeout' + occurrence_timeout_o = self.get_option(occurrence_timeout_n) + focus_to_editor_n = 'focus_to_editor' + focus_to_editor_o = self.get_option(focus_to_editor_n) + + for editorstack in self.editorstacks: + if currentline_n in options: + editorstack.set_highlight_current_line_enabled( + currentline_o) + if currentcell_n in options: + editorstack.set_highlight_current_cell_enabled( + currentcell_o) + if occurrence_n in options: + editorstack.set_occurrence_highlighting_enabled(occurrence_o) + if occurrence_timeout_n in options: + editorstack.set_occurrence_highlighting_timeout( + occurrence_timeout_o) + if focus_to_editor_n in options: + editorstack.set_focus_to_editor(focus_to_editor_o) + + # --- everything else + tabbar_n = 'show_tab_bar' + tabbar_o = self.get_option(tabbar_n) + classfuncdropdown_n = 'show_class_func_dropdown' + classfuncdropdown_o = self.get_option(classfuncdropdown_n) + linenb_n = 'line_numbers' + linenb_o = self.get_option(linenb_n) + blanks_n = 'blank_spaces' + blanks_o = self.get_option(blanks_n) + scrollpastend_n = 'scroll_past_end' + scrollpastend_o = self.get_option(scrollpastend_n) + wrap_n = 'wrap' + wrap_o = self.get_option(wrap_n) + indentguides_n = 'indent_guides' + indentguides_o = self.get_option(indentguides_n) + codefolding_n = 'code_folding' + codefolding_o = self.get_option(codefolding_n) + tabindent_n = 'tab_always_indent' + tabindent_o = self.get_option(tabindent_n) + stripindent_n = 'strip_trailing_spaces_on_modify' + stripindent_o = self.get_option(stripindent_n) + ibackspace_n = 'intelligent_backspace' + ibackspace_o = self.get_option(ibackspace_n) + removetrail_n = 'always_remove_trailing_spaces' + removetrail_o = self.get_option(removetrail_n) + add_newline_n = 'add_newline' + add_newline_o = self.get_option(add_newline_n) + removetrail_newlines_n = 'always_remove_trailing_newlines' + removetrail_newlines_o = self.get_option(removetrail_newlines_n) + converteol_n = 'convert_eol_on_save' + converteol_o = self.get_option(converteol_n) + converteolto_n = 'convert_eol_on_save_to' + converteolto_o = self.get_option(converteolto_n) + runcellcopy_n = 'run_cell_copy' + runcellcopy_o = self.get_option(runcellcopy_n) + closepar_n = 'close_parentheses' + closepar_o = self.get_option(closepar_n) + close_quotes_n = 'close_quotes' + close_quotes_o = self.get_option(close_quotes_n) + add_colons_n = 'add_colons' + add_colons_o = self.get_option(add_colons_n) + autounindent_n = 'auto_unindent' + autounindent_o = self.get_option(autounindent_n) + indent_chars_n = 'indent_chars' + indent_chars_o = self.get_option(indent_chars_n) + tab_stop_width_spaces_n = 'tab_stop_width_spaces' + tab_stop_width_spaces_o = self.get_option(tab_stop_width_spaces_n) + help_n = 'connect_to_oi' + help_o = CONF.get('help', 'connect/editor') + todo_n = 'todo_list' + todo_o = self.get_option(todo_n) + + finfo = self.get_current_finfo() + + for editorstack in self.editorstacks: + # Checkable options + if blanks_n in options: + editorstack.set_blanks_enabled(blanks_o) + if scrollpastend_n in options: + editorstack.set_scrollpastend_enabled(scrollpastend_o) + if indentguides_n in options: + editorstack.set_indent_guides(indentguides_o) + if codefolding_n in options: + editorstack.set_code_folding_enabled(codefolding_o) + if classfuncdropdown_n in options: + editorstack.set_classfunc_dropdown_visible( + classfuncdropdown_o) + if tabbar_n in options: + editorstack.set_tabbar_visible(tabbar_o) + if linenb_n in options: + editorstack.set_linenumbers_enabled(linenb_o, + current_finfo=finfo) + if wrap_n in options: + editorstack.set_wrap_enabled(wrap_o) + if tabindent_n in options: + editorstack.set_tabmode_enabled(tabindent_o) + if stripindent_n in options: + editorstack.set_stripmode_enabled(stripindent_o) + if ibackspace_n in options: + editorstack.set_intelligent_backspace_enabled(ibackspace_o) + if removetrail_n in options: + editorstack.set_always_remove_trailing_spaces(removetrail_o) + if add_newline_n in options: + editorstack.set_add_newline(add_newline_o) + if removetrail_newlines_n in options: + editorstack.set_remove_trailing_newlines( + removetrail_newlines_o) + if converteol_n in options: + editorstack.set_convert_eol_on_save(converteol_o) + if converteolto_n in options: + editorstack.set_convert_eol_on_save_to(converteolto_o) + if runcellcopy_n in options: + editorstack.set_run_cell_copy(runcellcopy_o) + if closepar_n in options: + editorstack.set_close_parentheses_enabled(closepar_o) + if close_quotes_n in options: + editorstack.set_close_quotes_enabled(close_quotes_o) + if add_colons_n in options: + editorstack.set_add_colons_enabled(add_colons_o) + if autounindent_n in options: + editorstack.set_auto_unindent_enabled(autounindent_o) + if indent_chars_n in options: + editorstack.set_indent_chars(indent_chars_o) + if tab_stop_width_spaces_n in options: + editorstack.set_tab_stop_width_spaces(tab_stop_width_spaces_o) + if help_n in options: + editorstack.set_help_enabled(help_o) + if todo_n in options: + editorstack.set_todolist_enabled(todo_o, + current_finfo=finfo) + + for name, action in self.checkable_actions.items(): + if name in options: + # Avoid triggering the action when this action changes state + action.blockSignals(True) + state = self.get_option(name) + action.setChecked(state) + action.blockSignals(False) + # See: spyder-ide/spyder#9915 + + # Multiply by 1000 to convert seconds to milliseconds + self.autosave.interval = ( + self.get_option('autosave_interval') * 1000) + self.autosave.enabled = self.get_option('autosave_enabled') + + # We must update the current editor after the others: + # (otherwise, code analysis buttons state would correspond to the + # last editor instead of showing the one of the current editor) + if finfo is not None: + if todo_n in options and todo_o: + finfo.run_todo_finder() + + @on_conf_change(option='edge_line') + def set_edgeline_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set edge line to {value}") + for editorstack in self.editorstacks: + editorstack.set_edgeline_enabled(value) + + @on_conf_change( + option=('provider_configuration', 'lsp', 'values', + 'pycodestyle/max_line_length'), + section='completions' + ) + def set_edgeline_columns(self, value): + if self.editorstacks is not None: + logger.debug(f"Set edge line columns to {value}") + for editorstack in self.editorstacks: + editorstack.set_edgeline_columns(value) + + @on_conf_change(option='enable_code_snippets', section='completions') + def set_code_snippets_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set code snippets to {value}") + for editorstack in self.editorstacks: + editorstack.set_code_snippets_enabled(value) + + @on_conf_change(option='automatic_completions') + def set_automatic_completions_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set automatic completions to {value}") + for editorstack in self.editorstacks: + editorstack.set_automatic_completions_enabled(value) + + @on_conf_change(option='automatic_completions_after_chars') + def set_automatic_completions_after_chars(self, value): + if self.editorstacks is not None: + logger.debug(f"Set chars for automatic completions to {value}") + for editorstack in self.editorstacks: + editorstack.set_automatic_completions_after_chars(value) + + @on_conf_change(option='automatic_completions_after_ms') + def set_automatic_completions_after_ms(self, value): + if self.editorstacks is not None: + logger.debug(f"Set automatic completions after {value} ms") + for editorstack in self.editorstacks: + editorstack.set_automatic_completions_after_ms(value) + + @on_conf_change(option='completions_hint') + def set_completions_hint_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set completions hint to {value}") + for editorstack in self.editorstacks: + editorstack.set_completions_hint_enabled(value) + + @on_conf_change(option='completions_hint_after_ms') + def set_completions_hint_after_ms(self, value): + if self.editorstacks is not None: + logger.debug(f"Set completions hint after {value} ms") + for editorstack in self.editorstacks: + editorstack.set_completions_hint_after_ms(value) + + @on_conf_change( + option=('provider_configuration', 'lsp', 'values', + 'enable_hover_hints'), + section='completions' + ) + def set_hover_hints_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set hover hints to {value}") + for editorstack in self.editorstacks: + editorstack.set_hover_hints_enabled(value) + + @on_conf_change( + option=('provider_configuration', 'lsp', 'values', 'format_on_save'), + section='completions' + ) + def set_format_on_save(self, value): + if self.editorstacks is not None: + logger.debug(f"Set format on save to {value}") + for editorstack in self.editorstacks: + editorstack.set_format_on_save(value) + + @on_conf_change(option='underline_errors') + def set_underline_errors_enabled(self, value): + if self.editorstacks is not None: + logger.debug(f"Set underline errors to {value}") + for editorstack in self.editorstacks: + editorstack.set_underline_errors_enabled(value) + + @on_conf_change(option='selected', section='appearance') + def set_color_scheme(self, value): + if self.editorstacks is not None: + logger.debug(f"Set color scheme to {value}") + for editorstack in self.editorstacks: + editorstack.set_color_scheme(value) + + # --- Open files + def get_open_filenames(self): + """Get the list of open files in the current stack""" + editorstack = self.editorstacks[0] + filenames = [] + filenames += [finfo.filename for finfo in editorstack.data] + return filenames + + def set_open_filenames(self): + """ + Set the recent opened files on editor based on active project. + + If no project is active, then editor filenames are saved, otherwise + the opened filenames are stored in the project config info. + """ + if self.projects is not None: + if not self.projects.get_active_project(): + filenames = self.get_open_filenames() + self.set_option('filenames', filenames) + + def setup_open_files(self, close_previous_files=True): + """ + Open the list of saved files per project. + + Also open any files that the user selected in the recovery dialog. + """ + self.set_create_new_file_if_empty(False) + active_project_path = None + if self.projects is not None: + active_project_path = self.projects.get_active_project_path() + + if active_project_path: + filenames = self.projects.get_project_filenames() + else: + filenames = self.get_option('filenames', default=[]) + + if close_previous_files: + self.close_all_files() + + all_filenames = self.autosave.recover_files_to_open + filenames + if all_filenames and any([osp.isfile(f) for f in all_filenames]): + layout = self.get_option('layout_settings', None) + # Check if no saved layout settings exist, e.g. clean prefs file. + # If not, load with default focus/layout, to fix + # spyder-ide/spyder#8458. + if layout: + is_vertical, cfname, clines = layout.get('splitsettings')[0] + # Check that a value for current line exist for each filename + # in the available settings. See spyder-ide/spyder#12201 + if cfname in filenames and len(filenames) == len(clines): + index = filenames.index(cfname) + # First we load the last focused file. + self.load(filenames[index], goto=clines[index], set_focus=True) + # Then we load the files located to the left of the last + # focused file in the tabbar, while keeping the focus on + # the last focused file. + if index > 0: + self.load(filenames[index::-1], goto=clines[index::-1], + set_focus=False, add_where='start') + # Then we load the files located to the right of the last + # focused file in the tabbar, while keeping the focus on + # the last focused file. + if index < (len(filenames) - 1): + self.load(filenames[index+1:], goto=clines[index:], + set_focus=False, add_where='end') + # Finally we load any recovered files at the end of the tabbar, + # while keeping focus on the last focused file. + if self.autosave.recover_files_to_open: + self.load(self.autosave.recover_files_to_open, + set_focus=False, add_where='end') + else: + if filenames: + self.load(filenames, goto=clines) + if self.autosave.recover_files_to_open: + self.load(self.autosave.recover_files_to_open) + else: + if filenames: + self.load(filenames) + if self.autosave.recover_files_to_open: + self.load(self.autosave.recover_files_to_open) + + if self.__first_open_files_setup: + self.__first_open_files_setup = False + if layout is not None: + self.editorsplitter.set_layout_settings( + layout, + dont_goto=filenames[0]) + win_layout = self.get_option('windows_layout_settings', []) + if win_layout: + for layout_settings in win_layout: + self.editorwindows_to_be_created.append( + layout_settings) + self.set_last_focused_editorstack(self, self.editorstacks[0]) + + # This is necessary to update the statusbar widgets after files + # have been loaded. + editorstack = self.get_current_editorstack() + if editorstack: + self.get_current_editorstack().refresh() + else: + self.__load_temp_file() + self.set_create_new_file_if_empty(True) + self.sig_open_files_finished.emit() + + def save_open_files(self): + """Save the list of open files""" + self.set_option('filenames', self.get_open_filenames()) + + def set_create_new_file_if_empty(self, value): + """Change the value of create_new_file_if_empty""" + for editorstack in self.editorstacks: + editorstack.create_new_file_if_empty = value + + # --- File Menu actions (Mac only) + @Slot() + def go_to_next_file(self): + """Switch to next file tab on the current editor stack.""" + editorstack = self.get_current_editorstack() + editorstack.tabs.tab_navigate(+1) + + @Slot() + def go_to_previous_file(self): + """Switch to previous file tab on the current editor stack.""" + editorstack = self.get_current_editorstack() + editorstack.tabs.tab_navigate(-1) + + def set_current_project_path(self, root_path=None): + """ + Set the current active project root path. + + Parameters + ---------- + root_path: str or None, optional + Path to current project root path. Default is None. + """ + for editorstack in self.editorstacks: + editorstack.set_current_project_path(root_path) + + def register_panel(self, panel_class, *args, position=Panel.Position.LEFT, + **kwargs): + """Register a panel in all the editorstacks in the given position.""" + for editorstack in self.editorstacks: + editorstack.register_panel( + panel_class, *args, position=position, **kwargs) + + # TODO: To be updated after migration + def on_mainwindow_visible(self): + return diff --git a/spyder/plugins/editor/utils/findtasks.py b/spyder/plugins/editor/utils/findtasks.py index 87026d35323..1e7ce44716c 100644 --- a/spyder/plugins/editor/utils/findtasks.py +++ b/spyder/plugins/editor/utils/findtasks.py @@ -1,33 +1,33 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Source code analysis utilities. -""" - -import re - -# Local import -from spyder.config.base import get_debug_level - -DEBUG_EDITOR = get_debug_level() >= 3 - -# ============================================================================= -# Find tasks - TODOs -# ============================================================================= -TASKS_PATTERN = r"(^|#)[ ]*(TODO|FIXME|XXX|HINT|TIP|@todo|" \ - r"HACK|BUG|OPTIMIZE|!!!|\?\?\?)([^#]*)" - - -def find_tasks(source_code): - """Find tasks in source code (TODO, FIXME, XXX, ...).""" - results = [] - for line, text in enumerate(source_code.splitlines()): - for todo in re.findall(TASKS_PATTERN, text): - todo_text = (todo[-1].strip(' :').capitalize() if todo[-1] - else todo[-2]) - results.append((todo_text, line + 1)) - return results +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Source code analysis utilities. +""" + +import re + +# Local import +from spyder.config.base import get_debug_level + +DEBUG_EDITOR = get_debug_level() >= 3 + +# ============================================================================= +# Find tasks - TODOs +# ============================================================================= +TASKS_PATTERN = r"(^|#)[ ]*(TODO|FIXME|XXX|HINT|TIP|@todo|" \ + r"HACK|BUG|OPTIMIZE|!!!|\?\?\?)([^#]*)" + + +def find_tasks(source_code): + """Find tasks in source code (TODO, FIXME, XXX, ...).""" + results = [] + for line, text in enumerate(source_code.splitlines()): + for todo in re.findall(TASKS_PATTERN, text): + todo_text = (todo[-1].strip(' :').capitalize() if todo[-1] + else todo[-2]) + results.append((todo_text, line + 1)) + return results diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py index 07baa7a0715..ae797a29b01 100644 --- a/spyder/plugins/editor/widgets/base.py +++ b/spyder/plugins/editor/widgets/base.py @@ -1,1157 +1,1157 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""QPlainTextEdit base class""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os -import sys - -# Third party imports -from qtpy.compat import to_qvariant -from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot -from qtpy.QtGui import (QClipboard, QColor, QMouseEvent, QTextFormat, - QTextOption, QTextCursor) -from qtpy.QtWidgets import QApplication, QMainWindow, QPlainTextEdit, QToolTip - -# Local imports -from spyder.config.gui import get_font -from spyder.config.manager import CONF -from spyder.py3compat import PY3, to_text_string -from spyder.widgets.calltip import CallTipWidget, ToolTipWidget -from spyder.widgets.mixins import BaseEditMixin -from spyder.plugins.editor.api.decoration import TextDecoration, DRAW_ORDERS -from spyder.plugins.editor.utils.decoration import TextDecorationsManager -from spyder.plugins.editor.widgets.completion import CompletionWidget -from spyder.plugins.outlineexplorer.api import is_cell_header, document_cells -from spyder.utils.palette import SpyderPalette - -class TextEditBaseWidget(QPlainTextEdit, BaseEditMixin): - """Text edit base widget""" - BRACE_MATCHING_SCOPE = ('sof', 'eof') - focus_in = Signal() - zoom_in = Signal() - zoom_out = Signal() - zoom_reset = Signal() - focus_changed = Signal() - sig_insert_completion = Signal(str) - sig_eol_chars_changed = Signal(str) - sig_prev_cursor = Signal() - sig_next_cursor = Signal() - - def __init__(self, parent=None): - QPlainTextEdit.__init__(self, parent) - BaseEditMixin.__init__(self) - - self.has_cell_separators = False - self.setAttribute(Qt.WA_DeleteOnClose) - - self._restore_selection_pos = None - - # Trailing newlines/spaces trimming - self.remove_trailing_spaces = False - self.remove_trailing_newlines = False - - # Add a new line when saving - self.add_newline = False - - # Code snippets - self.code_snippets = True - - self.cursorPositionChanged.connect(self.cursor_position_changed) - - self.indent_chars = " "*4 - self.tab_stop_width_spaces = 4 - - # Code completion / calltips - if parent is not None: - mainwin = parent - while not isinstance(mainwin, QMainWindow): - mainwin = mainwin.parent() - if mainwin is None: - break - if mainwin is not None: - parent = mainwin - - self.completion_widget = CompletionWidget(self, parent) - self.codecompletion_auto = False - self.setup_completion() - - self.calltip_widget = CallTipWidget(self, hide_timer_on=False) - self.tooltip_widget = ToolTipWidget(self, as_tooltip=True) - - self.highlight_current_cell_enabled = False - - # The color values may be overridden by the syntax highlighter - # Highlight current line color - self.currentline_color = QColor( - SpyderPalette.COLOR_ERROR_2).lighter(190) - self.currentcell_color = QColor( - SpyderPalette.COLOR_ERROR_2).lighter(194) - - # Brace matching - self.bracepos = None - self.matched_p_color = QColor(SpyderPalette.COLOR_SUCCESS_1) - self.unmatched_p_color = QColor(SpyderPalette.COLOR_ERROR_2) - - self.decorations = TextDecorationsManager(self) - - # Save current cell. This is invalidated as soon as the text changes. - # Useful to avoid recomputing while scrolling. - self.current_cell = None - - def reset_current_cell(): - self.current_cell = None - self.highlight_current_cell() - - self.textChanged.connect(reset_current_cell) - - # Cache - self._current_cell_cursor = None - self._current_line_block = None - - def setup_completion(self): - size = CONF.get('main', 'completion/size') - font = get_font() - self.completion_widget.setup_appearance(size, font) - - def set_indent_chars(self, indent_chars): - self.indent_chars = indent_chars - - def set_tab_stop_width_spaces(self, tab_stop_width_spaces): - self.tab_stop_width_spaces = tab_stop_width_spaces - self.update_tab_stop_width_spaces() - - def set_remove_trailing_spaces(self, flag): - self.remove_trailing_spaces = flag - - def set_add_newline(self, add_newline): - self.add_newline = add_newline - - def set_remove_trailing_newlines(self, flag): - self.remove_trailing_newlines = flag - - def update_tab_stop_width_spaces(self): - self.setTabStopWidth(self.fontMetrics().width( - ' ' * self.tab_stop_width_spaces)) - - def set_palette(self, background, foreground): - """ - Set text editor palette colors: - background color and caret (text cursor) color - """ - # Because QtStylsheet overrides QPalette and because some style do not - # use the palette for all drawing (e.g. macOS styles), the background - # and foreground color of each TextEditBaseWidget instance must be set - # with a stylesheet extended with an ID Selector. - # Fixes spyder-ide/spyder#2028, spyder-ide/spyder#8069 and - # spyder-ide/spyder#9248. - if not self.objectName(): - self.setObjectName(self.__class__.__name__ + str(id(self))) - style = "QPlainTextEdit#%s {background: %s; color: %s;}" % \ - (self.objectName(), background.name(), foreground.name()) - self.setStyleSheet(style) - - # ---- Extra selections - def get_extra_selections(self, key): - """Return editor extra selections. - - Args: - key (str) name of the extra selections group - - Returns: - list of sourcecode.api.TextDecoration. - """ - return self.decorations.get(key, []) - - def set_extra_selections(self, key, extra_selections): - """Set extra selections for a key. - - Also assign draw orders to leave current_cell and current_line - in the background (and avoid them to cover other decorations) - - NOTE: This will remove previous decorations added to the same key. - - Args: - key (str) name of the extra selections group. - extra_selections (list of sourcecode.api.TextDecoration). - """ - # use draw orders to highlight current_cell and current_line first - draw_order = DRAW_ORDERS.get(key) - if draw_order is None: - draw_order = DRAW_ORDERS.get('on_top') - - for selection in extra_selections: - selection.draw_order = draw_order - selection.kind = key - - self.decorations.add_key(key, extra_selections) - self.update() - - def clear_extra_selections(self, key): - """Remove decorations added through set_extra_selections. - - Args: - key (str) name of the extra selections group. - """ - self.decorations.remove_key(key) - self.update() - - def get_visible_block_numbers(self): - """Get the first and last visible block numbers.""" - first = self.firstVisibleBlock().blockNumber() - bottom_right = QPoint(self.viewport().width() - 1, - self.viewport().height() - 1) - last = self.cursorForPosition(bottom_right).blockNumber() - return (first, last) - - def get_buffer_block_numbers(self): - """ - Get the first and last block numbers of a region that covers - the visible one plus a buffer of half that region above and - below to make more fluid certain operations. - """ - first_visible, last_visible = self.get_visible_block_numbers() - buffer_height = round((last_visible - first_visible) / 2) - - first = first_visible - buffer_height - first = 0 if first < 0 else first - - last = last_visible + buffer_height - last = self.blockCount() if last > self.blockCount() else last - - return (first, last) - - # ------Highlight current line - def highlight_current_line(self): - """Highlight current line""" - cursor = self.textCursor() - block = cursor.block() - if self._current_line_block == block: - return - self._current_line_block = block - selection = TextDecoration(cursor) - selection.format.setProperty(QTextFormat.FullWidthSelection, - to_qvariant(True)) - selection.format.setBackground(self.currentline_color) - selection.cursor.clearSelection() - self.set_extra_selections('current_line', [selection]) - - def unhighlight_current_line(self): - """Unhighlight current line""" - self._current_line_block = None - self.clear_extra_selections('current_line') - - # ------Highlight current cell - def highlight_current_cell(self): - """Highlight current cell""" - if (not self.has_cell_separators or - not self.highlight_current_cell_enabled): - self._current_cell_cursor = None - return - cursor, whole_file_selected = self.select_current_cell() - - def same_selection(c1, c2): - if c1 is None or c2 is None: - return False - return ( - c1.selectionStart() == c2.selectionStart() and - c1.selectionEnd() == c2.selectionEnd() - ) - - if same_selection(self._current_cell_cursor, cursor): - # Already correct - return - self._current_cell_cursor = cursor - selection = TextDecoration(cursor) - selection.format.setProperty(QTextFormat.FullWidthSelection, - to_qvariant(True)) - selection.format.setBackground(self.currentcell_color) - - if whole_file_selected: - self.clear_extra_selections('current_cell') - else: - self.set_extra_selections('current_cell', [selection]) - - def unhighlight_current_cell(self): - """Unhighlight current cell""" - self._current_cell_cursor = None - self.clear_extra_selections('current_cell') - - def in_comment(self, cursor=None, position=None): - """Returns True if the given position is inside a comment. - - Trivial default implementation. To be overridden by subclass. - This function is used to define the default behaviour of - self.find_brace_match. - """ - return False - - def in_string(self, cursor=None, position=None): - """Returns True if the given position is inside a string. - - Trivial default implementation. To be overridden by subclass. - This function is used to define the default behaviour of - self.find_brace_match. - """ - return False - - def find_brace_match(self, position, brace, forward, - ignore_brace=None, stop=None): - """Returns position of matching brace. - - Parameters - ---------- - position : int - The position of the brace to be matched. - brace : {'[', ']', '(', ')', '{', '}'} - The brace character to be matched. - [ <-> ], ( <-> ), { <-> } - forward : boolean - Whether to search forwards or backwards for a match. - ignore_brace : callable taking int returning boolean, optional - Whether to ignore a brace (as function of position). - stop : callable taking int returning boolean, optional - Whether to stop the search early (as function of position). - - If both *ignore_brace* and *stop* are None, then brace matching - is handled differently depending on whether *position* is - inside a string, comment or regular code. If in regular code, - then any braces inside strings and comments are ignored. If in a - string/comment, then only braces in the same string/comment are - considered potential matches. The functions self.in_comment and - self.in_string are used to determine string/comment/code status - of characters in this case. - - If exactly one of *ignore_brace* and *stop* is None, then it is - replaced by a function returning False for every position. I.e.: - lambda pos: False - - Returns - ------- - The position of the matching brace. If no matching brace - exists, then None is returned. - """ - - if ignore_brace is None and stop is None: - if self.in_string(position=position): - # Only search inside the current string - def stop(pos): - return not self.in_string(position=pos) - elif self.in_comment(position=position): - # Only search inside the current comment - def stop(pos): - return not self.in_comment(position=pos) - else: - # Ignore braces inside strings and comments - def ignore_brace(pos): - return (self.in_string(position=pos) or - self.in_comment(position=pos)) - - # Deal with search range and direction - start_pos, end_pos = self.BRACE_MATCHING_SCOPE - if forward: - closing_brace = {'(': ')', '[': ']', '{': '}'}[brace] - text = self.get_text(position, end_pos, remove_newlines=False) - else: - # Handle backwards search with the same code as forwards - # by reversing the string to be searched. - closing_brace = {')': '(', ']': '[', '}': '{'}[brace] - text = self.get_text(start_pos, position+1, remove_newlines=False) - text = text[-1::-1] # reverse - - def ind2pos(index): - """Computes editor position from search index.""" - return (position + index) if forward else (position - index) - - # Search starts at the first position after the given one - # (which is assumed to contain a brace). - i_start_close = 1 - i_start_open = 1 - while True: - i_close = text.find(closing_brace, i_start_close) - i_start_close = i_close+1 # next potential start - if i_close == -1: - return # no matching brace exists - elif ignore_brace is None or not ignore_brace(ind2pos(i_close)): - while True: - i_open = text.find(brace, i_start_open, i_close) - i_start_open = i_open+1 # next potential start - if i_open == -1: - # found matching brace, but should we have - # stopped before this point? - if stop is not None: - # There's room for optimization here... - for i in range(1, i_close+1): - if stop(ind2pos(i)): - return - return ind2pos(i_close) - elif (ignore_brace is None or - not ignore_brace(ind2pos(i_open))): - break # must find new closing brace - - def __highlight(self, positions, color=None, cancel=False): - if cancel: - self.clear_extra_selections('brace_matching') - return - extra_selections = [] - for position in positions: - if position > self.get_position('eof'): - return - selection = TextDecoration(self.textCursor()) - selection.format.setBackground(color) - selection.cursor.clearSelection() - selection.cursor.setPosition(position) - selection.cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - extra_selections.append(selection) - self.set_extra_selections('brace_matching', extra_selections) - - def cursor_position_changed(self): - """Handle brace matching.""" - # Clear last brace highlight (if any) - if self.bracepos is not None: - self.__highlight(self.bracepos, cancel=True) - self.bracepos = None - - # Get the current cursor position, check if it is at a brace, - # and, if so, determine the direction in which to search for able - # matching brace. - cursor = self.textCursor() - if cursor.position() == 0: - return - cursor.movePosition(QTextCursor.PreviousCharacter, - QTextCursor.KeepAnchor) - text = to_text_string(cursor.selectedText()) - if text in (')', ']', '}'): - forward = False - elif text in ('(', '[', '{'): - forward = True - else: - return - - pos1 = cursor.position() - pos2 = self.find_brace_match(pos1, text, forward=forward) - - # Set a new brace highlight - if pos2 is not None: - self.bracepos = (pos1, pos2) - self.__highlight(self.bracepos, color=self.matched_p_color) - else: - self.bracepos = (pos1,) - self.__highlight(self.bracepos, color=self.unmatched_p_color) - - # -----Widget setup and options - def set_wrap_mode(self, mode=None): - """ - Set wrap mode - Valid *mode* values: None, 'word', 'character' - """ - if mode == 'word': - wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere - elif mode == 'character': - wrap_mode = QTextOption.WrapAnywhere - else: - wrap_mode = QTextOption.NoWrap - self.setWordWrapMode(wrap_mode) - - # ------Reimplementing Qt methods - @Slot() - def copy(self): - """ - Reimplement Qt method - Copy text to clipboard with correct EOL chars - """ - if self.get_selected_text(): - QApplication.clipboard().setText(self.get_selected_text()) - - def toPlainText(self): - """ - Reimplement Qt method - Fix PyQt4 bug on Windows and Python 3 - """ - # Fix what appears to be a PyQt4 bug when getting file - # contents under Windows and PY3. This bug leads to - # corruptions when saving files with certain combinations - # of unicode chars on them (like the one attached on - # spyder-ide/spyder#1546). - if os.name == 'nt' and PY3: - text = self.get_text('sof', 'eof') - return text.replace('\u2028', '\n').replace('\u2029', '\n')\ - .replace('\u0085', '\n') - return super(TextEditBaseWidget, self).toPlainText() - - def keyPressEvent(self, event): - key = event.key() - ctrl = event.modifiers() & Qt.ControlModifier - meta = event.modifiers() & Qt.MetaModifier - # Use our own copy method for {Ctrl,Cmd}+C to avoid Qt - # copying text in HTML. See spyder-ide/spyder#2285. - if (ctrl or meta) and key == Qt.Key_C: - self.copy() - else: - super(TextEditBaseWidget, self).keyPressEvent(event) - - # ------Text: get, set, ... - def get_cell_list(self): - """Get all cells.""" - # Reimplemented in childrens - return [] - - def get_selection_as_executable_code(self, cursor=None): - """Return selected text as a processed text, - to be executable in a Python/IPython interpreter""" - ls = self.get_line_separator() - - _indent = lambda line: len(line)-len(line.lstrip()) - - line_from, line_to = self.get_selection_bounds(cursor) - text = self.get_selected_text(cursor) - if not text: - return - - lines = text.split(ls) - if len(lines) > 1: - # Multiline selection -> eventually fixing indentation - original_indent = _indent(self.get_text_line(line_from)) - text = (" "*(original_indent-_indent(lines[0])))+text - - # If there is a common indent to all lines, find it. - # Moving from bottom line to top line ensures that blank - # lines inherit the indent of the line *below* it, - # which is the desired behavior. - min_indent = 999 - current_indent = 0 - lines = text.split(ls) - for i in range(len(lines)-1, -1, -1): - line = lines[i] - if line.strip(): - current_indent = _indent(line) - min_indent = min(current_indent, min_indent) - else: - lines[i] = ' ' * current_indent - if min_indent: - lines = [line[min_indent:] for line in lines] - - # Remove any leading whitespace or comment lines - # since they confuse the reserved word detector that follows below - lines_removed = 0 - while lines: - first_line = lines[0].lstrip() - if first_line == '' or first_line[0] == '#': - lines_removed += 1 - lines.pop(0) - else: - break - - # Add an EOL character after the last line of code so that it gets - # evaluated automatically by the console and any quote characters - # are separated from the triple quotes of runcell - lines.append(ls) - - # Add removed lines back to have correct traceback line numbers - leading_lines_str = ls * lines_removed - - return leading_lines_str + ls.join(lines) - - def get_cell_as_executable_code(self, cursor=None): - """Return cell contents as executable code.""" - if cursor is None: - cursor = self.textCursor() - ls = self.get_line_separator() - cursor, __ = self.select_current_cell(cursor) - line_from, __ = self.get_selection_bounds(cursor) - # Get the block for the first cell line - start = cursor.selectionStart() - block = self.document().findBlock(start) - if not is_cell_header(block) and start > 0: - block = self.document().findBlock(start - 1) - # Get text - text = self.get_selection_as_executable_code(cursor) - if text is not None: - text = ls * line_from + text - return text, block - - def select_current_cell(self, cursor=None): - """ - Select cell under cursor in the visible portion of the file - cell = group of lines separated by CELL_SEPARATORS - returns - -the textCursor - -a boolean indicating if the entire file is selected - """ - if cursor is None: - cursor = self.textCursor() - - if self.current_cell: - current_cell, cell_full_file = self.current_cell - cell_start_pos = current_cell.selectionStart() - cell_end_position = current_cell.selectionEnd() - # Check if the saved current cell is still valid - if cell_start_pos <= cursor.position() < cell_end_position: - return current_cell, cell_full_file - else: - self.current_cell = None - - block = cursor.block() - try: - if is_cell_header(block): - header = block.userData().oedata - else: - header = next(document_cells( - block, forward=False, - cell_list=self.get_cell_list())) - cell_start_pos = header.block.position() - cell_at_file_start = False - cursor.setPosition(cell_start_pos) - except StopIteration: - # This cell has no header, so it is the first cell. - cell_at_file_start = True - cursor.movePosition(QTextCursor.Start) - - try: - footer = next(document_cells( - block, forward=True, - cell_list=self.get_cell_list())) - cell_end_position = footer.block.position() - cell_at_file_end = False - cursor.setPosition(cell_end_position, QTextCursor.KeepAnchor) - except StopIteration: - # This cell has no next header, so it is the last cell. - cell_at_file_end = True - cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - - cell_full_file = cell_at_file_start and cell_at_file_end - self.current_cell = (cursor, cell_full_file) - - return cursor, cell_full_file - - def go_to_next_cell(self): - """Go to the next cell of lines""" - cursor = self.textCursor() - block = cursor.block() - try: - footer = next(document_cells( - block, forward=True, - cell_list=self.get_cell_list())) - cursor.setPosition(footer.block.position()) - except StopIteration: - return - self.setTextCursor(cursor) - - def go_to_previous_cell(self): - """Go to the previous cell of lines""" - cursor = self.textCursor() - block = cursor.block() - if is_cell_header(block): - block = block.previous() - try: - header = next(document_cells( - block, forward=False, - cell_list=self.get_cell_list())) - cursor.setPosition(header.block.position()) - except StopIteration: - return - self.setTextCursor(cursor) - - def get_line_count(self): - """Return document total line number""" - return self.blockCount() - - def paintEvent(self, e): - """ - Override Qt method to restore text selection after text gets inserted - at the current position of the cursor. - - See spyder-ide/spyder#11089 for more info. - """ - if self._restore_selection_pos is not None: - self.__restore_selection(*self._restore_selection_pos) - self._restore_selection_pos = None - super(TextEditBaseWidget, self).paintEvent(e) - - def __save_selection(self): - """Save current cursor selection and return position bounds""" - cursor = self.textCursor() - return cursor.selectionStart(), cursor.selectionEnd() - - def __restore_selection(self, start_pos, end_pos): - """Restore cursor selection from position bounds""" - cursor = self.textCursor() - cursor.setPosition(start_pos) - cursor.setPosition(end_pos, QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def __duplicate_line_or_selection(self, after_current_line=True): - """Duplicate current line or selected text""" - cursor = self.textCursor() - cursor.beginEditBlock() - cur_pos = cursor.position() - start_pos, end_pos = self.__save_selection() - end_pos_orig = end_pos - if to_text_string(cursor.selectedText()): - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - cursor.movePosition(QTextCursor.StartOfBlock, - QTextCursor.KeepAnchor) - if not to_text_string(cursor.selectedText()): - cursor.movePosition(QTextCursor.PreviousBlock) - end_pos = cursor.position() - - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) - if cursor.atEnd(): - cursor_temp = QTextCursor(cursor) - cursor_temp.clearSelection() - cursor_temp.insertText(self.get_line_separator()) - break - cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) - text = cursor.selectedText() - cursor.clearSelection() - - if not after_current_line: - # Moving cursor before current line/selected text - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - start_pos += len(text) - end_pos_orig += len(text) - cur_pos += len(text) - - # We save the end and start position of the selection, so that it - # can be restored within the paint event that is triggered by the - # text insertion. This is done to prevent a graphical glitch that - # occurs when text gets inserted at the current position of the cursor. - # See spyder-ide/spyder#11089 for more info. - if cur_pos == start_pos: - self._restore_selection_pos = (end_pos_orig, start_pos) - else: - self._restore_selection_pos = (start_pos, end_pos_orig) - cursor.insertText(text) - cursor.endEditBlock() - - def duplicate_line_down(self): - """ - Copy current line or selected text and paste the duplicated text - *after* the current line or selected text. - """ - self.__duplicate_line_or_selection(after_current_line=False) - - def duplicate_line_up(self): - """ - Copy current line or selected text and paste the duplicated text - *before* the current line or selected text. - """ - self.__duplicate_line_or_selection(after_current_line=True) - - def __move_line_or_selection(self, after_current_line=True): - """Move current line or selected text""" - cursor = self.textCursor() - cursor.beginEditBlock() - start_pos, end_pos = self.__save_selection() - last_line = False - - # ------ Select text - # Get selection start location - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - start_pos = cursor.position() - - # Get selection end location - cursor.setPosition(end_pos) - if not cursor.atBlockStart() or end_pos == start_pos: - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.movePosition(QTextCursor.NextBlock) - end_pos = cursor.position() - - # Check if selection ends on the last line of the document - if cursor.atEnd(): - if not cursor.atBlockStart() or end_pos == start_pos: - last_line = True - - # ------ Stop if at document boundary - cursor.setPosition(start_pos) - if cursor.atStart() and not after_current_line: - # Stop if selection is already at top of the file while moving up - cursor.endEditBlock() - self.setTextCursor(cursor) - self.__restore_selection(start_pos, end_pos) - return - - cursor.setPosition(end_pos, QTextCursor.KeepAnchor) - if last_line and after_current_line: - # Stop if selection is already at end of the file while moving down - cursor.endEditBlock() - self.setTextCursor(cursor) - self.__restore_selection(start_pos, end_pos) - return - - # ------ Move text - sel_text = to_text_string(cursor.selectedText()) - cursor.removeSelectedText() - - if after_current_line: - # Shift selection down - text = to_text_string(cursor.block().text()) - sel_text = os.linesep + sel_text[0:-1] # Move linesep at the start - cursor.movePosition(QTextCursor.EndOfBlock) - start_pos += len(text)+1 - end_pos += len(text) - if not cursor.atEnd(): - end_pos += 1 - else: - # Shift selection up - if last_line: - # Remove the last linesep and add it to the selected text - cursor.deletePreviousChar() - sel_text = sel_text + os.linesep - cursor.movePosition(QTextCursor.StartOfBlock) - end_pos += 1 - else: - cursor.movePosition(QTextCursor.PreviousBlock) - text = to_text_string(cursor.block().text()) - start_pos -= len(text)+1 - end_pos -= len(text)+1 - - cursor.insertText(sel_text) - - cursor.endEditBlock() - self.setTextCursor(cursor) - self.__restore_selection(start_pos, end_pos) - - def move_line_up(self): - """Move up current line or selected text""" - self.__move_line_or_selection(after_current_line=False) - - def move_line_down(self): - """Move down current line or selected text""" - self.__move_line_or_selection(after_current_line=True) - - def go_to_new_line(self): - """Go to the end of the current line and create a new line""" - self.stdkey_end(False, False) - self.insert_text(self.get_line_separator()) - - def extend_selection_to_complete_lines(self): - """Extend current selection to complete lines""" - cursor = self.textCursor() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start_pos) - cursor.setPosition(end_pos, QTextCursor.KeepAnchor) - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock, - QTextCursor.KeepAnchor) - cursor.movePosition(QTextCursor.EndOfBlock, - QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def delete_line(self, cursor=None): - """Delete current line.""" - if cursor is None: - cursor = self.textCursor() - if self.has_selected_text(): - self.extend_selection_to_complete_lines() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - cursor.setPosition(start_pos) - else: - start_pos = end_pos = cursor.position() - cursor.beginEditBlock() - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - cursor.endEditBlock() - self.ensureCursorVisible() - - def set_selection(self, start, end): - cursor = self.textCursor() - cursor.setPosition(start) - cursor.setPosition(end, QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def truncate_selection(self, position_from): - """Unselect read-only parts in shell, like prompt""" - position_from = self.get_position(position_from) - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - if start < end: - start = max([position_from, start]) - else: - end = max([position_from, end]) - self.set_selection(start, end) - - def restrict_cursor_position(self, position_from, position_to): - """In shell, avoid editing text except between prompt and EOF""" - position_from = self.get_position(position_from) - position_to = self.get_position(position_to) - cursor = self.textCursor() - cursor_position = cursor.position() - if cursor_position < position_from or cursor_position > position_to: - self.set_cursor_position(position_to) - - # ------Code completion / Calltips - def select_completion_list(self): - """Completion list is active, Enter was just pressed""" - self.completion_widget.item_selected() - - def insert_completion(self, completion, completion_position): - """Insert a completion into the editor. - - completion_position is where the completion was generated. - - The replacement range is computed using the (LSP) completion's - textEdit field if it exists. Otherwise, we replace from the - start of the word under the cursor. - """ - if not completion: - return - - cursor = self.textCursor() - - has_selected_text = self.has_selected_text() - selection_start, selection_end = self.get_selection_start_end() - - if isinstance(completion, dict) and 'textEdit' in completion: - completion_range = completion['textEdit']['range'] - start = completion_range['start'] - end = completion_range['end'] - if isinstance(completion_range['start'], dict): - start_line, start_col = start['line'], start['character'] - start = self.get_position_line_number(start_line, start_col) - if isinstance(completion_range['start'], dict): - end_line, end_col = end['line'], end['character'] - end = self.get_position_line_number(end_line, end_col) - cursor.setPosition(start) - cursor.setPosition(end, QTextCursor.KeepAnchor) - text = to_text_string(completion['textEdit']['newText']) - else: - text = completion - if isinstance(completion, dict): - text = completion['insertText'] - text = to_text_string(text) - - # Get word on the left of the cursor. - result = self.get_current_word_and_position(completion=True) - if result is not None: - current_text, start_position = result - end_position = start_position + len(current_text) - # Check if the completion position is in the expected range - if not start_position <= completion_position <= end_position: - return - cursor.setPosition(start_position) - # Remove the word under the cursor - cursor.setPosition(end_position, - QTextCursor.KeepAnchor) - else: - # Check if we are in the correct position - if cursor.position() != completion_position: - return - - if has_selected_text: - self.sig_will_remove_selection.emit(selection_start, selection_end) - - cursor.removeSelectedText() - self.setTextCursor(cursor) - - # Add text - if self.objectName() == 'console': - # Handle completions for the internal console - self.insert_text(text) - else: - self.sig_insert_completion.emit(text) - - def is_completion_widget_visible(self): - """Return True is completion list widget is visible""" - try: - return self.completion_widget.isVisible() - except RuntimeError: - # This is to avoid a RuntimeError exception when the widget is - # already been deleted. See spyder-ide/spyder#13248. - return False - - def hide_completion_widget(self, focus_to_parent=True): - """Hide completion widget and tooltip.""" - self.completion_widget.hide(focus_to_parent=focus_to_parent) - QToolTip.hideText() - - # ------Standard keys - def stdkey_clear(self): - if not self.has_selected_text(): - self.moveCursor(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) - self.remove_selected_text() - - def stdkey_backspace(self): - if not self.has_selected_text(): - self.moveCursor(QTextCursor.PreviousCharacter, - QTextCursor.KeepAnchor) - self.remove_selected_text() - - def __get_move_mode(self, shift): - return QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor - - def stdkey_up(self, shift): - self.moveCursor(QTextCursor.Up, self.__get_move_mode(shift)) - - def stdkey_down(self, shift): - self.moveCursor(QTextCursor.Down, self.__get_move_mode(shift)) - - def stdkey_tab(self): - self.insert_text(self.indent_chars) - - def stdkey_home(self, shift, ctrl, prompt_pos=None): - """Smart HOME feature: cursor is first moved at - indentation position, then at the start of the line""" - move_mode = self.__get_move_mode(shift) - if ctrl: - self.moveCursor(QTextCursor.Start, move_mode) - else: - cursor = self.textCursor() - if prompt_pos is None: - start_position = self.get_position('sol') - else: - start_position = self.get_position(prompt_pos) - text = self.get_text(start_position, 'eol') - indent_pos = start_position+len(text)-len(text.lstrip()) - if cursor.position() != indent_pos: - cursor.setPosition(indent_pos, move_mode) - else: - cursor.setPosition(start_position, move_mode) - self.setTextCursor(cursor) - - def stdkey_end(self, shift, ctrl): - move_mode = self.__get_move_mode(shift) - if ctrl: - self.moveCursor(QTextCursor.End, move_mode) - else: - self.moveCursor(QTextCursor.EndOfBlock, move_mode) - - # ----Qt Events - def mousePressEvent(self, event): - """Reimplement Qt method""" - - # mouse buttons for forward and backward navigation - if event.button() == Qt.XButton1: - self.sig_prev_cursor.emit() - elif event.button() == Qt.XButton2: - self.sig_next_cursor.emit() - - if sys.platform.startswith('linux') and event.button() == Qt.MidButton: - self.calltip_widget.hide() - self.setFocus() - event = QMouseEvent(QEvent.MouseButtonPress, event.pos(), - Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) - QPlainTextEdit.mousePressEvent(self, event) - QPlainTextEdit.mouseReleaseEvent(self, event) - # Send selection text to clipboard to be able to use - # the paste method and avoid the strange spyder-ide/spyder#1445. - # NOTE: This issue seems a focusing problem but it - # seems really hard to track - mode_clip = QClipboard.Clipboard - mode_sel = QClipboard.Selection - text_clip = QApplication.clipboard().text(mode=mode_clip) - text_sel = QApplication.clipboard().text(mode=mode_sel) - QApplication.clipboard().setText(text_sel, mode=mode_clip) - self.paste() - QApplication.clipboard().setText(text_clip, mode=mode_clip) - else: - self.calltip_widget.hide() - QPlainTextEdit.mousePressEvent(self, event) - - def focusInEvent(self, event): - """Reimplemented to handle focus""" - self.focus_changed.emit() - self.focus_in.emit() - QPlainTextEdit.focusInEvent(self, event) - - def focusOutEvent(self, event): - """Reimplemented to handle focus""" - self.focus_changed.emit() - QPlainTextEdit.focusOutEvent(self, event) - - def wheelEvent(self, event): - """Reimplemented to emit zoom in/out signals when Ctrl is pressed""" - # This feature is disabled on MacOS, see spyder-ide/spyder#1510. - if sys.platform != 'darwin': - if event.modifiers() & Qt.ControlModifier: - if hasattr(event, 'angleDelta'): - if event.angleDelta().y() < 0: - self.zoom_out.emit() - elif event.angleDelta().y() > 0: - self.zoom_in.emit() - elif hasattr(event, 'delta'): - if event.delta() < 0: - self.zoom_out.emit() - elif event.delta() > 0: - self.zoom_in.emit() - return - - QPlainTextEdit.wheelEvent(self, event) - - # Needed to prevent stealing focus when scrolling. - # If the current widget with focus is the CompletionWidget, it means - # it's being displayed in the editor, so we need to hide it and give - # focus back to the editor. If not, we need to leave the focus in - # the widget that currently has it. - # See spyder-ide/spyder#11502 - current_widget = QApplication.focusWidget() - if isinstance(current_widget, CompletionWidget): - self.hide_completion_widget(focus_to_parent=True) - else: - self.hide_completion_widget(focus_to_parent=False) - - def position_widget_at_cursor(self, widget): - # Retrieve current screen height - desktop = QApplication.desktop() - srect = desktop.availableGeometry(desktop.screenNumber(widget)) - - left, top, right, bottom = (srect.left(), srect.top(), - srect.right(), srect.bottom()) - ancestor = widget.parent() - if ancestor: - left = max(left, ancestor.x()) - top = max(top, ancestor.y()) - right = min(right, ancestor.x() + ancestor.width()) - bottom = min(bottom, ancestor.y() + ancestor.height()) - - point = self.cursorRect().bottomRight() - point = self.calculate_real_position(point) - point = self.mapToGlobal(point) - # Move to left of cursor if not enough space on right - widget_right = point.x() + widget.width() - if widget_right > right: - point.setX(point.x() - widget.width()) - # Push to right if not enough space on left - if point.x() < left: - point.setX(left) - - # Moving widget above if there is not enough space below - widget_bottom = point.y() + widget.height() - x_position = point.x() - if widget_bottom > bottom: - point = self.cursorRect().topRight() - point = self.mapToGlobal(point) - point.setX(x_position) - point.setY(point.y() - widget.height()) - - if ancestor is not None: - # Useful only if we set parent to 'ancestor' in __init__ - point = ancestor.mapFromGlobal(point) - - widget.move(point) - - def calculate_real_position(self, point): - return point +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""QPlainTextEdit base class""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os +import sys + +# Third party imports +from qtpy.compat import to_qvariant +from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot +from qtpy.QtGui import (QClipboard, QColor, QMouseEvent, QTextFormat, + QTextOption, QTextCursor) +from qtpy.QtWidgets import QApplication, QMainWindow, QPlainTextEdit, QToolTip + +# Local imports +from spyder.config.gui import get_font +from spyder.config.manager import CONF +from spyder.py3compat import PY3, to_text_string +from spyder.widgets.calltip import CallTipWidget, ToolTipWidget +from spyder.widgets.mixins import BaseEditMixin +from spyder.plugins.editor.api.decoration import TextDecoration, DRAW_ORDERS +from spyder.plugins.editor.utils.decoration import TextDecorationsManager +from spyder.plugins.editor.widgets.completion import CompletionWidget +from spyder.plugins.outlineexplorer.api import is_cell_header, document_cells +from spyder.utils.palette import SpyderPalette + +class TextEditBaseWidget(QPlainTextEdit, BaseEditMixin): + """Text edit base widget""" + BRACE_MATCHING_SCOPE = ('sof', 'eof') + focus_in = Signal() + zoom_in = Signal() + zoom_out = Signal() + zoom_reset = Signal() + focus_changed = Signal() + sig_insert_completion = Signal(str) + sig_eol_chars_changed = Signal(str) + sig_prev_cursor = Signal() + sig_next_cursor = Signal() + + def __init__(self, parent=None): + QPlainTextEdit.__init__(self, parent) + BaseEditMixin.__init__(self) + + self.has_cell_separators = False + self.setAttribute(Qt.WA_DeleteOnClose) + + self._restore_selection_pos = None + + # Trailing newlines/spaces trimming + self.remove_trailing_spaces = False + self.remove_trailing_newlines = False + + # Add a new line when saving + self.add_newline = False + + # Code snippets + self.code_snippets = True + + self.cursorPositionChanged.connect(self.cursor_position_changed) + + self.indent_chars = " "*4 + self.tab_stop_width_spaces = 4 + + # Code completion / calltips + if parent is not None: + mainwin = parent + while not isinstance(mainwin, QMainWindow): + mainwin = mainwin.parent() + if mainwin is None: + break + if mainwin is not None: + parent = mainwin + + self.completion_widget = CompletionWidget(self, parent) + self.codecompletion_auto = False + self.setup_completion() + + self.calltip_widget = CallTipWidget(self, hide_timer_on=False) + self.tooltip_widget = ToolTipWidget(self, as_tooltip=True) + + self.highlight_current_cell_enabled = False + + # The color values may be overridden by the syntax highlighter + # Highlight current line color + self.currentline_color = QColor( + SpyderPalette.COLOR_ERROR_2).lighter(190) + self.currentcell_color = QColor( + SpyderPalette.COLOR_ERROR_2).lighter(194) + + # Brace matching + self.bracepos = None + self.matched_p_color = QColor(SpyderPalette.COLOR_SUCCESS_1) + self.unmatched_p_color = QColor(SpyderPalette.COLOR_ERROR_2) + + self.decorations = TextDecorationsManager(self) + + # Save current cell. This is invalidated as soon as the text changes. + # Useful to avoid recomputing while scrolling. + self.current_cell = None + + def reset_current_cell(): + self.current_cell = None + self.highlight_current_cell() + + self.textChanged.connect(reset_current_cell) + + # Cache + self._current_cell_cursor = None + self._current_line_block = None + + def setup_completion(self): + size = CONF.get('main', 'completion/size') + font = get_font() + self.completion_widget.setup_appearance(size, font) + + def set_indent_chars(self, indent_chars): + self.indent_chars = indent_chars + + def set_tab_stop_width_spaces(self, tab_stop_width_spaces): + self.tab_stop_width_spaces = tab_stop_width_spaces + self.update_tab_stop_width_spaces() + + def set_remove_trailing_spaces(self, flag): + self.remove_trailing_spaces = flag + + def set_add_newline(self, add_newline): + self.add_newline = add_newline + + def set_remove_trailing_newlines(self, flag): + self.remove_trailing_newlines = flag + + def update_tab_stop_width_spaces(self): + self.setTabStopWidth(self.fontMetrics().width( + ' ' * self.tab_stop_width_spaces)) + + def set_palette(self, background, foreground): + """ + Set text editor palette colors: + background color and caret (text cursor) color + """ + # Because QtStylsheet overrides QPalette and because some style do not + # use the palette for all drawing (e.g. macOS styles), the background + # and foreground color of each TextEditBaseWidget instance must be set + # with a stylesheet extended with an ID Selector. + # Fixes spyder-ide/spyder#2028, spyder-ide/spyder#8069 and + # spyder-ide/spyder#9248. + if not self.objectName(): + self.setObjectName(self.__class__.__name__ + str(id(self))) + style = "QPlainTextEdit#%s {background: %s; color: %s;}" % \ + (self.objectName(), background.name(), foreground.name()) + self.setStyleSheet(style) + + # ---- Extra selections + def get_extra_selections(self, key): + """Return editor extra selections. + + Args: + key (str) name of the extra selections group + + Returns: + list of sourcecode.api.TextDecoration. + """ + return self.decorations.get(key, []) + + def set_extra_selections(self, key, extra_selections): + """Set extra selections for a key. + + Also assign draw orders to leave current_cell and current_line + in the background (and avoid them to cover other decorations) + + NOTE: This will remove previous decorations added to the same key. + + Args: + key (str) name of the extra selections group. + extra_selections (list of sourcecode.api.TextDecoration). + """ + # use draw orders to highlight current_cell and current_line first + draw_order = DRAW_ORDERS.get(key) + if draw_order is None: + draw_order = DRAW_ORDERS.get('on_top') + + for selection in extra_selections: + selection.draw_order = draw_order + selection.kind = key + + self.decorations.add_key(key, extra_selections) + self.update() + + def clear_extra_selections(self, key): + """Remove decorations added through set_extra_selections. + + Args: + key (str) name of the extra selections group. + """ + self.decorations.remove_key(key) + self.update() + + def get_visible_block_numbers(self): + """Get the first and last visible block numbers.""" + first = self.firstVisibleBlock().blockNumber() + bottom_right = QPoint(self.viewport().width() - 1, + self.viewport().height() - 1) + last = self.cursorForPosition(bottom_right).blockNumber() + return (first, last) + + def get_buffer_block_numbers(self): + """ + Get the first and last block numbers of a region that covers + the visible one plus a buffer of half that region above and + below to make more fluid certain operations. + """ + first_visible, last_visible = self.get_visible_block_numbers() + buffer_height = round((last_visible - first_visible) / 2) + + first = first_visible - buffer_height + first = 0 if first < 0 else first + + last = last_visible + buffer_height + last = self.blockCount() if last > self.blockCount() else last + + return (first, last) + + # ------Highlight current line + def highlight_current_line(self): + """Highlight current line""" + cursor = self.textCursor() + block = cursor.block() + if self._current_line_block == block: + return + self._current_line_block = block + selection = TextDecoration(cursor) + selection.format.setProperty(QTextFormat.FullWidthSelection, + to_qvariant(True)) + selection.format.setBackground(self.currentline_color) + selection.cursor.clearSelection() + self.set_extra_selections('current_line', [selection]) + + def unhighlight_current_line(self): + """Unhighlight current line""" + self._current_line_block = None + self.clear_extra_selections('current_line') + + # ------Highlight current cell + def highlight_current_cell(self): + """Highlight current cell""" + if (not self.has_cell_separators or + not self.highlight_current_cell_enabled): + self._current_cell_cursor = None + return + cursor, whole_file_selected = self.select_current_cell() + + def same_selection(c1, c2): + if c1 is None or c2 is None: + return False + return ( + c1.selectionStart() == c2.selectionStart() and + c1.selectionEnd() == c2.selectionEnd() + ) + + if same_selection(self._current_cell_cursor, cursor): + # Already correct + return + self._current_cell_cursor = cursor + selection = TextDecoration(cursor) + selection.format.setProperty(QTextFormat.FullWidthSelection, + to_qvariant(True)) + selection.format.setBackground(self.currentcell_color) + + if whole_file_selected: + self.clear_extra_selections('current_cell') + else: + self.set_extra_selections('current_cell', [selection]) + + def unhighlight_current_cell(self): + """Unhighlight current cell""" + self._current_cell_cursor = None + self.clear_extra_selections('current_cell') + + def in_comment(self, cursor=None, position=None): + """Returns True if the given position is inside a comment. + + Trivial default implementation. To be overridden by subclass. + This function is used to define the default behaviour of + self.find_brace_match. + """ + return False + + def in_string(self, cursor=None, position=None): + """Returns True if the given position is inside a string. + + Trivial default implementation. To be overridden by subclass. + This function is used to define the default behaviour of + self.find_brace_match. + """ + return False + + def find_brace_match(self, position, brace, forward, + ignore_brace=None, stop=None): + """Returns position of matching brace. + + Parameters + ---------- + position : int + The position of the brace to be matched. + brace : {'[', ']', '(', ')', '{', '}'} + The brace character to be matched. + [ <-> ], ( <-> ), { <-> } + forward : boolean + Whether to search forwards or backwards for a match. + ignore_brace : callable taking int returning boolean, optional + Whether to ignore a brace (as function of position). + stop : callable taking int returning boolean, optional + Whether to stop the search early (as function of position). + + If both *ignore_brace* and *stop* are None, then brace matching + is handled differently depending on whether *position* is + inside a string, comment or regular code. If in regular code, + then any braces inside strings and comments are ignored. If in a + string/comment, then only braces in the same string/comment are + considered potential matches. The functions self.in_comment and + self.in_string are used to determine string/comment/code status + of characters in this case. + + If exactly one of *ignore_brace* and *stop* is None, then it is + replaced by a function returning False for every position. I.e.: + lambda pos: False + + Returns + ------- + The position of the matching brace. If no matching brace + exists, then None is returned. + """ + + if ignore_brace is None and stop is None: + if self.in_string(position=position): + # Only search inside the current string + def stop(pos): + return not self.in_string(position=pos) + elif self.in_comment(position=position): + # Only search inside the current comment + def stop(pos): + return not self.in_comment(position=pos) + else: + # Ignore braces inside strings and comments + def ignore_brace(pos): + return (self.in_string(position=pos) or + self.in_comment(position=pos)) + + # Deal with search range and direction + start_pos, end_pos = self.BRACE_MATCHING_SCOPE + if forward: + closing_brace = {'(': ')', '[': ']', '{': '}'}[brace] + text = self.get_text(position, end_pos, remove_newlines=False) + else: + # Handle backwards search with the same code as forwards + # by reversing the string to be searched. + closing_brace = {')': '(', ']': '[', '}': '{'}[brace] + text = self.get_text(start_pos, position+1, remove_newlines=False) + text = text[-1::-1] # reverse + + def ind2pos(index): + """Computes editor position from search index.""" + return (position + index) if forward else (position - index) + + # Search starts at the first position after the given one + # (which is assumed to contain a brace). + i_start_close = 1 + i_start_open = 1 + while True: + i_close = text.find(closing_brace, i_start_close) + i_start_close = i_close+1 # next potential start + if i_close == -1: + return # no matching brace exists + elif ignore_brace is None or not ignore_brace(ind2pos(i_close)): + while True: + i_open = text.find(brace, i_start_open, i_close) + i_start_open = i_open+1 # next potential start + if i_open == -1: + # found matching brace, but should we have + # stopped before this point? + if stop is not None: + # There's room for optimization here... + for i in range(1, i_close+1): + if stop(ind2pos(i)): + return + return ind2pos(i_close) + elif (ignore_brace is None or + not ignore_brace(ind2pos(i_open))): + break # must find new closing brace + + def __highlight(self, positions, color=None, cancel=False): + if cancel: + self.clear_extra_selections('brace_matching') + return + extra_selections = [] + for position in positions: + if position > self.get_position('eof'): + return + selection = TextDecoration(self.textCursor()) + selection.format.setBackground(color) + selection.cursor.clearSelection() + selection.cursor.setPosition(position) + selection.cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + extra_selections.append(selection) + self.set_extra_selections('brace_matching', extra_selections) + + def cursor_position_changed(self): + """Handle brace matching.""" + # Clear last brace highlight (if any) + if self.bracepos is not None: + self.__highlight(self.bracepos, cancel=True) + self.bracepos = None + + # Get the current cursor position, check if it is at a brace, + # and, if so, determine the direction in which to search for able + # matching brace. + cursor = self.textCursor() + if cursor.position() == 0: + return + cursor.movePosition(QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor) + text = to_text_string(cursor.selectedText()) + if text in (')', ']', '}'): + forward = False + elif text in ('(', '[', '{'): + forward = True + else: + return + + pos1 = cursor.position() + pos2 = self.find_brace_match(pos1, text, forward=forward) + + # Set a new brace highlight + if pos2 is not None: + self.bracepos = (pos1, pos2) + self.__highlight(self.bracepos, color=self.matched_p_color) + else: + self.bracepos = (pos1,) + self.__highlight(self.bracepos, color=self.unmatched_p_color) + + # -----Widget setup and options + def set_wrap_mode(self, mode=None): + """ + Set wrap mode + Valid *mode* values: None, 'word', 'character' + """ + if mode == 'word': + wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere + elif mode == 'character': + wrap_mode = QTextOption.WrapAnywhere + else: + wrap_mode = QTextOption.NoWrap + self.setWordWrapMode(wrap_mode) + + # ------Reimplementing Qt methods + @Slot() + def copy(self): + """ + Reimplement Qt method + Copy text to clipboard with correct EOL chars + """ + if self.get_selected_text(): + QApplication.clipboard().setText(self.get_selected_text()) + + def toPlainText(self): + """ + Reimplement Qt method + Fix PyQt4 bug on Windows and Python 3 + """ + # Fix what appears to be a PyQt4 bug when getting file + # contents under Windows and PY3. This bug leads to + # corruptions when saving files with certain combinations + # of unicode chars on them (like the one attached on + # spyder-ide/spyder#1546). + if os.name == 'nt' and PY3: + text = self.get_text('sof', 'eof') + return text.replace('\u2028', '\n').replace('\u2029', '\n')\ + .replace('\u0085', '\n') + return super(TextEditBaseWidget, self).toPlainText() + + def keyPressEvent(self, event): + key = event.key() + ctrl = event.modifiers() & Qt.ControlModifier + meta = event.modifiers() & Qt.MetaModifier + # Use our own copy method for {Ctrl,Cmd}+C to avoid Qt + # copying text in HTML. See spyder-ide/spyder#2285. + if (ctrl or meta) and key == Qt.Key_C: + self.copy() + else: + super(TextEditBaseWidget, self).keyPressEvent(event) + + # ------Text: get, set, ... + def get_cell_list(self): + """Get all cells.""" + # Reimplemented in childrens + return [] + + def get_selection_as_executable_code(self, cursor=None): + """Return selected text as a processed text, + to be executable in a Python/IPython interpreter""" + ls = self.get_line_separator() + + _indent = lambda line: len(line)-len(line.lstrip()) + + line_from, line_to = self.get_selection_bounds(cursor) + text = self.get_selected_text(cursor) + if not text: + return + + lines = text.split(ls) + if len(lines) > 1: + # Multiline selection -> eventually fixing indentation + original_indent = _indent(self.get_text_line(line_from)) + text = (" "*(original_indent-_indent(lines[0])))+text + + # If there is a common indent to all lines, find it. + # Moving from bottom line to top line ensures that blank + # lines inherit the indent of the line *below* it, + # which is the desired behavior. + min_indent = 999 + current_indent = 0 + lines = text.split(ls) + for i in range(len(lines)-1, -1, -1): + line = lines[i] + if line.strip(): + current_indent = _indent(line) + min_indent = min(current_indent, min_indent) + else: + lines[i] = ' ' * current_indent + if min_indent: + lines = [line[min_indent:] for line in lines] + + # Remove any leading whitespace or comment lines + # since they confuse the reserved word detector that follows below + lines_removed = 0 + while lines: + first_line = lines[0].lstrip() + if first_line == '' or first_line[0] == '#': + lines_removed += 1 + lines.pop(0) + else: + break + + # Add an EOL character after the last line of code so that it gets + # evaluated automatically by the console and any quote characters + # are separated from the triple quotes of runcell + lines.append(ls) + + # Add removed lines back to have correct traceback line numbers + leading_lines_str = ls * lines_removed + + return leading_lines_str + ls.join(lines) + + def get_cell_as_executable_code(self, cursor=None): + """Return cell contents as executable code.""" + if cursor is None: + cursor = self.textCursor() + ls = self.get_line_separator() + cursor, __ = self.select_current_cell(cursor) + line_from, __ = self.get_selection_bounds(cursor) + # Get the block for the first cell line + start = cursor.selectionStart() + block = self.document().findBlock(start) + if not is_cell_header(block) and start > 0: + block = self.document().findBlock(start - 1) + # Get text + text = self.get_selection_as_executable_code(cursor) + if text is not None: + text = ls * line_from + text + return text, block + + def select_current_cell(self, cursor=None): + """ + Select cell under cursor in the visible portion of the file + cell = group of lines separated by CELL_SEPARATORS + returns + -the textCursor + -a boolean indicating if the entire file is selected + """ + if cursor is None: + cursor = self.textCursor() + + if self.current_cell: + current_cell, cell_full_file = self.current_cell + cell_start_pos = current_cell.selectionStart() + cell_end_position = current_cell.selectionEnd() + # Check if the saved current cell is still valid + if cell_start_pos <= cursor.position() < cell_end_position: + return current_cell, cell_full_file + else: + self.current_cell = None + + block = cursor.block() + try: + if is_cell_header(block): + header = block.userData().oedata + else: + header = next(document_cells( + block, forward=False, + cell_list=self.get_cell_list())) + cell_start_pos = header.block.position() + cell_at_file_start = False + cursor.setPosition(cell_start_pos) + except StopIteration: + # This cell has no header, so it is the first cell. + cell_at_file_start = True + cursor.movePosition(QTextCursor.Start) + + try: + footer = next(document_cells( + block, forward=True, + cell_list=self.get_cell_list())) + cell_end_position = footer.block.position() + cell_at_file_end = False + cursor.setPosition(cell_end_position, QTextCursor.KeepAnchor) + except StopIteration: + # This cell has no next header, so it is the last cell. + cell_at_file_end = True + cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + + cell_full_file = cell_at_file_start and cell_at_file_end + self.current_cell = (cursor, cell_full_file) + + return cursor, cell_full_file + + def go_to_next_cell(self): + """Go to the next cell of lines""" + cursor = self.textCursor() + block = cursor.block() + try: + footer = next(document_cells( + block, forward=True, + cell_list=self.get_cell_list())) + cursor.setPosition(footer.block.position()) + except StopIteration: + return + self.setTextCursor(cursor) + + def go_to_previous_cell(self): + """Go to the previous cell of lines""" + cursor = self.textCursor() + block = cursor.block() + if is_cell_header(block): + block = block.previous() + try: + header = next(document_cells( + block, forward=False, + cell_list=self.get_cell_list())) + cursor.setPosition(header.block.position()) + except StopIteration: + return + self.setTextCursor(cursor) + + def get_line_count(self): + """Return document total line number""" + return self.blockCount() + + def paintEvent(self, e): + """ + Override Qt method to restore text selection after text gets inserted + at the current position of the cursor. + + See spyder-ide/spyder#11089 for more info. + """ + if self._restore_selection_pos is not None: + self.__restore_selection(*self._restore_selection_pos) + self._restore_selection_pos = None + super(TextEditBaseWidget, self).paintEvent(e) + + def __save_selection(self): + """Save current cursor selection and return position bounds""" + cursor = self.textCursor() + return cursor.selectionStart(), cursor.selectionEnd() + + def __restore_selection(self, start_pos, end_pos): + """Restore cursor selection from position bounds""" + cursor = self.textCursor() + cursor.setPosition(start_pos) + cursor.setPosition(end_pos, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def __duplicate_line_or_selection(self, after_current_line=True): + """Duplicate current line or selected text""" + cursor = self.textCursor() + cursor.beginEditBlock() + cur_pos = cursor.position() + start_pos, end_pos = self.__save_selection() + end_pos_orig = end_pos + if to_text_string(cursor.selectedText()): + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + cursor.movePosition(QTextCursor.StartOfBlock, + QTextCursor.KeepAnchor) + if not to_text_string(cursor.selectedText()): + cursor.movePosition(QTextCursor.PreviousBlock) + end_pos = cursor.position() + + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + if cursor.atEnd(): + cursor_temp = QTextCursor(cursor) + cursor_temp.clearSelection() + cursor_temp.insertText(self.get_line_separator()) + break + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + text = cursor.selectedText() + cursor.clearSelection() + + if not after_current_line: + # Moving cursor before current line/selected text + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + start_pos += len(text) + end_pos_orig += len(text) + cur_pos += len(text) + + # We save the end and start position of the selection, so that it + # can be restored within the paint event that is triggered by the + # text insertion. This is done to prevent a graphical glitch that + # occurs when text gets inserted at the current position of the cursor. + # See spyder-ide/spyder#11089 for more info. + if cur_pos == start_pos: + self._restore_selection_pos = (end_pos_orig, start_pos) + else: + self._restore_selection_pos = (start_pos, end_pos_orig) + cursor.insertText(text) + cursor.endEditBlock() + + def duplicate_line_down(self): + """ + Copy current line or selected text and paste the duplicated text + *after* the current line or selected text. + """ + self.__duplicate_line_or_selection(after_current_line=False) + + def duplicate_line_up(self): + """ + Copy current line or selected text and paste the duplicated text + *before* the current line or selected text. + """ + self.__duplicate_line_or_selection(after_current_line=True) + + def __move_line_or_selection(self, after_current_line=True): + """Move current line or selected text""" + cursor = self.textCursor() + cursor.beginEditBlock() + start_pos, end_pos = self.__save_selection() + last_line = False + + # ------ Select text + # Get selection start location + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + start_pos = cursor.position() + + # Get selection end location + cursor.setPosition(end_pos) + if not cursor.atBlockStart() or end_pos == start_pos: + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.movePosition(QTextCursor.NextBlock) + end_pos = cursor.position() + + # Check if selection ends on the last line of the document + if cursor.atEnd(): + if not cursor.atBlockStart() or end_pos == start_pos: + last_line = True + + # ------ Stop if at document boundary + cursor.setPosition(start_pos) + if cursor.atStart() and not after_current_line: + # Stop if selection is already at top of the file while moving up + cursor.endEditBlock() + self.setTextCursor(cursor) + self.__restore_selection(start_pos, end_pos) + return + + cursor.setPosition(end_pos, QTextCursor.KeepAnchor) + if last_line and after_current_line: + # Stop if selection is already at end of the file while moving down + cursor.endEditBlock() + self.setTextCursor(cursor) + self.__restore_selection(start_pos, end_pos) + return + + # ------ Move text + sel_text = to_text_string(cursor.selectedText()) + cursor.removeSelectedText() + + if after_current_line: + # Shift selection down + text = to_text_string(cursor.block().text()) + sel_text = os.linesep + sel_text[0:-1] # Move linesep at the start + cursor.movePosition(QTextCursor.EndOfBlock) + start_pos += len(text)+1 + end_pos += len(text) + if not cursor.atEnd(): + end_pos += 1 + else: + # Shift selection up + if last_line: + # Remove the last linesep and add it to the selected text + cursor.deletePreviousChar() + sel_text = sel_text + os.linesep + cursor.movePosition(QTextCursor.StartOfBlock) + end_pos += 1 + else: + cursor.movePosition(QTextCursor.PreviousBlock) + text = to_text_string(cursor.block().text()) + start_pos -= len(text)+1 + end_pos -= len(text)+1 + + cursor.insertText(sel_text) + + cursor.endEditBlock() + self.setTextCursor(cursor) + self.__restore_selection(start_pos, end_pos) + + def move_line_up(self): + """Move up current line or selected text""" + self.__move_line_or_selection(after_current_line=False) + + def move_line_down(self): + """Move down current line or selected text""" + self.__move_line_or_selection(after_current_line=True) + + def go_to_new_line(self): + """Go to the end of the current line and create a new line""" + self.stdkey_end(False, False) + self.insert_text(self.get_line_separator()) + + def extend_selection_to_complete_lines(self): + """Extend current selection to complete lines""" + cursor = self.textCursor() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start_pos) + cursor.setPosition(end_pos, QTextCursor.KeepAnchor) + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock, + QTextCursor.KeepAnchor) + cursor.movePosition(QTextCursor.EndOfBlock, + QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def delete_line(self, cursor=None): + """Delete current line.""" + if cursor is None: + cursor = self.textCursor() + if self.has_selected_text(): + self.extend_selection_to_complete_lines() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + cursor.setPosition(start_pos) + else: + start_pos = end_pos = cursor.position() + cursor.beginEditBlock() + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + cursor.endEditBlock() + self.ensureCursorVisible() + + def set_selection(self, start, end): + cursor = self.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def truncate_selection(self, position_from): + """Unselect read-only parts in shell, like prompt""" + position_from = self.get_position(position_from) + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + if start < end: + start = max([position_from, start]) + else: + end = max([position_from, end]) + self.set_selection(start, end) + + def restrict_cursor_position(self, position_from, position_to): + """In shell, avoid editing text except between prompt and EOF""" + position_from = self.get_position(position_from) + position_to = self.get_position(position_to) + cursor = self.textCursor() + cursor_position = cursor.position() + if cursor_position < position_from or cursor_position > position_to: + self.set_cursor_position(position_to) + + # ------Code completion / Calltips + def select_completion_list(self): + """Completion list is active, Enter was just pressed""" + self.completion_widget.item_selected() + + def insert_completion(self, completion, completion_position): + """Insert a completion into the editor. + + completion_position is where the completion was generated. + + The replacement range is computed using the (LSP) completion's + textEdit field if it exists. Otherwise, we replace from the + start of the word under the cursor. + """ + if not completion: + return + + cursor = self.textCursor() + + has_selected_text = self.has_selected_text() + selection_start, selection_end = self.get_selection_start_end() + + if isinstance(completion, dict) and 'textEdit' in completion: + completion_range = completion['textEdit']['range'] + start = completion_range['start'] + end = completion_range['end'] + if isinstance(completion_range['start'], dict): + start_line, start_col = start['line'], start['character'] + start = self.get_position_line_number(start_line, start_col) + if isinstance(completion_range['start'], dict): + end_line, end_col = end['line'], end['character'] + end = self.get_position_line_number(end_line, end_col) + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + text = to_text_string(completion['textEdit']['newText']) + else: + text = completion + if isinstance(completion, dict): + text = completion['insertText'] + text = to_text_string(text) + + # Get word on the left of the cursor. + result = self.get_current_word_and_position(completion=True) + if result is not None: + current_text, start_position = result + end_position = start_position + len(current_text) + # Check if the completion position is in the expected range + if not start_position <= completion_position <= end_position: + return + cursor.setPosition(start_position) + # Remove the word under the cursor + cursor.setPosition(end_position, + QTextCursor.KeepAnchor) + else: + # Check if we are in the correct position + if cursor.position() != completion_position: + return + + if has_selected_text: + self.sig_will_remove_selection.emit(selection_start, selection_end) + + cursor.removeSelectedText() + self.setTextCursor(cursor) + + # Add text + if self.objectName() == 'console': + # Handle completions for the internal console + self.insert_text(text) + else: + self.sig_insert_completion.emit(text) + + def is_completion_widget_visible(self): + """Return True is completion list widget is visible""" + try: + return self.completion_widget.isVisible() + except RuntimeError: + # This is to avoid a RuntimeError exception when the widget is + # already been deleted. See spyder-ide/spyder#13248. + return False + + def hide_completion_widget(self, focus_to_parent=True): + """Hide completion widget and tooltip.""" + self.completion_widget.hide(focus_to_parent=focus_to_parent) + QToolTip.hideText() + + # ------Standard keys + def stdkey_clear(self): + if not self.has_selected_text(): + self.moveCursor(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) + self.remove_selected_text() + + def stdkey_backspace(self): + if not self.has_selected_text(): + self.moveCursor(QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor) + self.remove_selected_text() + + def __get_move_mode(self, shift): + return QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor + + def stdkey_up(self, shift): + self.moveCursor(QTextCursor.Up, self.__get_move_mode(shift)) + + def stdkey_down(self, shift): + self.moveCursor(QTextCursor.Down, self.__get_move_mode(shift)) + + def stdkey_tab(self): + self.insert_text(self.indent_chars) + + def stdkey_home(self, shift, ctrl, prompt_pos=None): + """Smart HOME feature: cursor is first moved at + indentation position, then at the start of the line""" + move_mode = self.__get_move_mode(shift) + if ctrl: + self.moveCursor(QTextCursor.Start, move_mode) + else: + cursor = self.textCursor() + if prompt_pos is None: + start_position = self.get_position('sol') + else: + start_position = self.get_position(prompt_pos) + text = self.get_text(start_position, 'eol') + indent_pos = start_position+len(text)-len(text.lstrip()) + if cursor.position() != indent_pos: + cursor.setPosition(indent_pos, move_mode) + else: + cursor.setPosition(start_position, move_mode) + self.setTextCursor(cursor) + + def stdkey_end(self, shift, ctrl): + move_mode = self.__get_move_mode(shift) + if ctrl: + self.moveCursor(QTextCursor.End, move_mode) + else: + self.moveCursor(QTextCursor.EndOfBlock, move_mode) + + # ----Qt Events + def mousePressEvent(self, event): + """Reimplement Qt method""" + + # mouse buttons for forward and backward navigation + if event.button() == Qt.XButton1: + self.sig_prev_cursor.emit() + elif event.button() == Qt.XButton2: + self.sig_next_cursor.emit() + + if sys.platform.startswith('linux') and event.button() == Qt.MidButton: + self.calltip_widget.hide() + self.setFocus() + event = QMouseEvent(QEvent.MouseButtonPress, event.pos(), + Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QPlainTextEdit.mousePressEvent(self, event) + QPlainTextEdit.mouseReleaseEvent(self, event) + # Send selection text to clipboard to be able to use + # the paste method and avoid the strange spyder-ide/spyder#1445. + # NOTE: This issue seems a focusing problem but it + # seems really hard to track + mode_clip = QClipboard.Clipboard + mode_sel = QClipboard.Selection + text_clip = QApplication.clipboard().text(mode=mode_clip) + text_sel = QApplication.clipboard().text(mode=mode_sel) + QApplication.clipboard().setText(text_sel, mode=mode_clip) + self.paste() + QApplication.clipboard().setText(text_clip, mode=mode_clip) + else: + self.calltip_widget.hide() + QPlainTextEdit.mousePressEvent(self, event) + + def focusInEvent(self, event): + """Reimplemented to handle focus""" + self.focus_changed.emit() + self.focus_in.emit() + QPlainTextEdit.focusInEvent(self, event) + + def focusOutEvent(self, event): + """Reimplemented to handle focus""" + self.focus_changed.emit() + QPlainTextEdit.focusOutEvent(self, event) + + def wheelEvent(self, event): + """Reimplemented to emit zoom in/out signals when Ctrl is pressed""" + # This feature is disabled on MacOS, see spyder-ide/spyder#1510. + if sys.platform != 'darwin': + if event.modifiers() & Qt.ControlModifier: + if hasattr(event, 'angleDelta'): + if event.angleDelta().y() < 0: + self.zoom_out.emit() + elif event.angleDelta().y() > 0: + self.zoom_in.emit() + elif hasattr(event, 'delta'): + if event.delta() < 0: + self.zoom_out.emit() + elif event.delta() > 0: + self.zoom_in.emit() + return + + QPlainTextEdit.wheelEvent(self, event) + + # Needed to prevent stealing focus when scrolling. + # If the current widget with focus is the CompletionWidget, it means + # it's being displayed in the editor, so we need to hide it and give + # focus back to the editor. If not, we need to leave the focus in + # the widget that currently has it. + # See spyder-ide/spyder#11502 + current_widget = QApplication.focusWidget() + if isinstance(current_widget, CompletionWidget): + self.hide_completion_widget(focus_to_parent=True) + else: + self.hide_completion_widget(focus_to_parent=False) + + def position_widget_at_cursor(self, widget): + # Retrieve current screen height + desktop = QApplication.desktop() + srect = desktop.availableGeometry(desktop.screenNumber(widget)) + + left, top, right, bottom = (srect.left(), srect.top(), + srect.right(), srect.bottom()) + ancestor = widget.parent() + if ancestor: + left = max(left, ancestor.x()) + top = max(top, ancestor.y()) + right = min(right, ancestor.x() + ancestor.width()) + bottom = min(bottom, ancestor.y() + ancestor.height()) + + point = self.cursorRect().bottomRight() + point = self.calculate_real_position(point) + point = self.mapToGlobal(point) + # Move to left of cursor if not enough space on right + widget_right = point.x() + widget.width() + if widget_right > right: + point.setX(point.x() - widget.width()) + # Push to right if not enough space on left + if point.x() < left: + point.setX(left) + + # Moving widget above if there is not enough space below + widget_bottom = point.y() + widget.height() + x_position = point.x() + if widget_bottom > bottom: + point = self.cursorRect().topRight() + point = self.mapToGlobal(point) + point.setX(x_position) + point.setY(point.y() - widget.height()) + + if ancestor is not None: + # Useful only if we set parent to 'ancestor' in __init__ + point = ancestor.mapFromGlobal(point) + + widget.move(point) + + def calculate_real_position(self, point): + return point diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py index 18e2f6eb0ae..7094309a60e 100644 --- a/spyder/plugins/editor/widgets/codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor.py @@ -1,5595 +1,5595 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Editor widget based on QtGui.QPlainTextEdit -""" - -# TODO: Try to separate this module from spyder to create a self -# consistent editor module (Qt source code and shell widgets library) - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from unicodedata import category -import logging -import functools -import os -import os.path as osp -import re -import sre_constants -import sys -import textwrap -from pkg_resources import parse_version - -# Third party imports -from diff_match_patch import diff_match_patch -from IPython.core.inputtransformer2 import TransformerManager -from qtpy import QT_VERSION -from qtpy.compat import to_qvariant -from qtpy.QtCore import (QEvent, QEventLoop, QRegExp, Qt, QTimer, QThread, - QUrl, Signal, Slot) -from qtpy.QtGui import (QColor, QCursor, QFont, QKeySequence, QPaintEvent, - QPainter, QMouseEvent, QTextCursor, QDesktopServices, - QKeyEvent, QTextDocument, QTextFormat, QTextOption, - QTextCharFormat, QTextLayout) -from qtpy.QtWidgets import (QApplication, QMenu, QMessageBox, QSplitter, - QScrollBar) -from spyder_kernels.utils.dochelpers import getobj -from three_merge import merge - - -# Local imports -from spyder.api.panel import Panel -from spyder.config.base import _, get_debug_level, running_under_pytest -from spyder.config.manager import CONF -from spyder.plugins.editor.api.decoration import TextDecoration -from spyder.plugins.editor.extensions import (CloseBracketsExtension, - CloseQuotesExtension, - DocstringWriterExtension, - QMenuOnlyForEnter, - EditorExtensionsManager, - SnippetsExtension) -from spyder.plugins.completion.providers.kite.widgets import KiteCallToAction -from spyder.plugins.completion.api import (CompletionRequestTypes, - TextDocumentSyncKind, - DiagnosticSeverity) -from spyder.plugins.editor.panels import (ClassFunctionDropdown, - DebuggerPanel, EdgeLine, - FoldingPanel, IndentationGuide, - LineNumberArea, PanelsManager, - ScrollFlagArea) -from spyder.plugins.editor.utils.editor import (TextHelper, BlockUserData, - get_file_language) -from spyder.plugins.editor.utils.debugger import DebuggerManager -from spyder.plugins.editor.utils.kill_ring import QtKillRing -from spyder.plugins.editor.utils.languages import ALL_LANGUAGES, CELL_LANGUAGES -from spyder.plugins.editor.panels.utils import ( - merge_folding, collect_folding_regions) -from spyder.plugins.completion.decorators import ( - request, handles, class_register) -from spyder.plugins.editor.widgets.codeeditor_widgets import GoToLineDialog -from spyder.plugins.editor.widgets.base import TextEditBaseWidget -from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED, - is_cell_header) -from spyder.py3compat import PY2, to_text_string, is_string, is_text_string -from spyder.utils import encoding, sourcecode -from spyder.utils.clipboard_helper import CLIPBOARD_HELPER -from spyder.utils.icon_manager import ima -from spyder.utils import syntaxhighlighters as sh -from spyder.utils.palette import SpyderPalette, QStylePalette -from spyder.utils.qthelpers import (add_actions, create_action, file_uri, - mimedata2url, start_file) -from spyder.utils.vcs import get_git_remotes, remote_to_url -from spyder.utils.qstringhelpers import qstring_length - - -try: - import nbformat as nbformat - from nbconvert import PythonExporter as nbexporter -except Exception: - nbformat = None # analysis:ignore - -logger = logging.getLogger(__name__) - - -# Regexp to detect noqa inline comments. -NOQA_INLINE_REGEXP = re.compile(r"#?noqa", re.IGNORECASE) - - -def schedule_request(req=None, method=None, requires_response=True): - """Call function req and then emit its results to the completion server.""" - if req is None: - return functools.partial(schedule_request, method=method, - requires_response=requires_response) - - @functools.wraps(req) - def wrapper(self, *args, **kwargs): - params = req(self, *args, **kwargs) - if params is not None and self.completions_available: - self._pending_server_requests.append( - (method, params, requires_response)) - self._server_requests_timer.start() - return wrapper - - -@class_register -class CodeEditor(TextEditBaseWidget): - """Source Code Editor Widget based exclusively on Qt""" - - LANGUAGES = { - 'Python': (sh.PythonSH, '#'), - 'IPython': (sh.IPythonSH, '#'), - 'Cython': (sh.CythonSH, '#'), - 'Fortran77': (sh.Fortran77SH, 'c'), - 'Fortran': (sh.FortranSH, '!'), - 'Idl': (sh.IdlSH, ';'), - 'Diff': (sh.DiffSH, ''), - 'GetText': (sh.GetTextSH, '#'), - 'Nsis': (sh.NsisSH, '#'), - 'Html': (sh.HtmlSH, ''), - 'Yaml': (sh.YamlSH, '#'), - 'Cpp': (sh.CppSH, '//'), - 'OpenCL': (sh.OpenCLSH, '//'), - 'Enaml': (sh.EnamlSH, '#'), - 'Markdown': (sh.MarkdownSH, '#'), - # Every other language - 'None': (sh.TextSH, ''), - } - - TAB_ALWAYS_INDENTS = ( - 'py', 'pyw', 'python', 'ipy', 'c', 'cpp', 'cl', 'h', 'pyt', 'pyi' - ) - - # Timeout to update decorations (through a QTimer) when a position - # changed is detected in the vertical scrollbar or when releasing - # the up/down arrow keys. - UPDATE_DECORATIONS_TIMEOUT = 500 # milliseconds - - # Timeouts (in milliseconds) to sychronize symbols and folding after - # linting results arrive, according to the number of lines in the file. - SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS = { - # Lines: Timeout - 500: 350, - 1500: 800, - 2500: 1200, - 6500: 1800 - } - - # Custom signal to be emitted upon completion of the editor's paintEvent - painted = Signal(QPaintEvent) - - # To have these attrs when early viewportEvent's are triggered - edge_line = None - indent_guides = None - - sig_breakpoints_changed = Signal() - sig_repaint_breakpoints = Signal() - sig_debug_stop = Signal((int,), ()) - sig_debug_start = Signal() - sig_breakpoints_saved = Signal() - sig_filename_changed = Signal(str) - sig_bookmarks_changed = Signal() - go_to_definition = Signal(str, int, int) - sig_show_object_info = Signal(int) - sig_run_selection = Signal() - sig_run_to_line = Signal() - sig_run_from_line = Signal() - sig_run_cell_and_advance = Signal() - sig_run_cell = Signal() - sig_re_run_last_cell = Signal() - sig_debug_cell = Signal() - sig_cursor_position_changed = Signal(int, int) - sig_new_file = Signal(str) - sig_refresh_formatting = Signal(bool) - - #: Signal emitted when the editor loses focus - sig_focus_changed = Signal() - - #: Signal emitted when a key is pressed - sig_key_pressed = Signal(QKeyEvent) - - #: Signal emitted when a key is released - sig_key_released = Signal(QKeyEvent) - - #: Signal emitted when the alt key is pressed and the left button of the - # mouse is clicked - sig_alt_left_mouse_pressed = Signal(QMouseEvent) - - #: Signal emitted when the alt key is pressed and the cursor moves over - # the editor - sig_alt_mouse_moved = Signal(QMouseEvent) - - #: Signal emitted when the cursor leaves the editor - sig_leave_out = Signal() - - #: Signal emitted when the flags need to be updated in the scrollflagarea - sig_flags_changed = Signal() - - #: Signal emitted when the syntax color theme of the editor. - sig_theme_colors_changed = Signal(dict) - - #: Signal emitted when a new text is set on the widget - new_text_set = Signal() - - # -- LSP signals - #: Signal emitted when an LSP request is sent to the LSP manager - sig_perform_completion_request = Signal(str, str, dict) - - #: Signal emitted when a response is received from the completion plugin - # For now it's only used on tests, but it could be used to track - # and profile completion diagnostics. - completions_response_signal = Signal(str, object) - - #: Signal to display object information on the Help plugin - sig_display_object_info = Signal(str, bool) - - #: Signal only used for tests - # TODO: Remove it! - sig_signature_invoked = Signal(dict) - - #: Signal emitted when processing code analysis warnings is finished - sig_process_code_analysis = Signal() - - # Used for testing. When the mouse moves with Ctrl/Cmd pressed and - # a URI is found, this signal is emitted - sig_uri_found = Signal(str) - - sig_file_uri_preprocessed = Signal(str) - """ - This signal is emitted when the go to uri for a file has been - preprocessed. - - Parameters - ---------- - fpath: str - The preprocessed file path. - """ - - # Signal with the info about the current completion item documentation - # str: object name - # str: object signature/documentation - # bool: force showing the info - sig_show_completion_object_info = Signal(str, str, bool) - - # Used to indicate if text was inserted into the editor - sig_text_was_inserted = Signal() - - # Used to indicate that text will be inserted into the editor - sig_will_insert_text = Signal(str) - - # Used to indicate that a text selection will be removed - sig_will_remove_selection = Signal(tuple, tuple) - - # Used to indicate that text will be pasted - sig_will_paste_text = Signal(str) - - # Used to indicate that an undo operation will take place - sig_undo = Signal() - - # Used to indicate that an undo operation will take place - sig_redo = Signal() - - # Used to start the status spinner in the editor - sig_start_operation_in_progress = Signal() - - # Used to start the status spinner in the editor - sig_stop_operation_in_progress = Signal() - - # Used to signal font change - sig_font_changed = Signal() - - def __init__(self, parent=None): - TextEditBaseWidget.__init__(self, parent) - - self.setFocusPolicy(Qt.StrongFocus) - - # Projects - self.current_project_path = None - - # Caret (text cursor) - self.setCursorWidth(CONF.get('main', 'cursor/width')) - - self.text_helper = TextHelper(self) - - self._panels = PanelsManager(self) - - # Mouse moving timer / Hover hints handling - # See: mouseMoveEvent - self.tooltip_widget.sig_help_requested.connect( - self.show_object_info) - self.tooltip_widget.sig_completion_help_requested.connect( - self.show_completion_object_info) - self._last_point = None - self._last_hover_word = None - self._last_hover_cursor = None - self._timer_mouse_moving = QTimer(self) - self._timer_mouse_moving.setInterval(350) - self._timer_mouse_moving.setSingleShot(True) - self._timer_mouse_moving.timeout.connect(self._handle_hover) - - # Typing keys / handling on the fly completions - # See: keyPressEvent - self._last_key_pressed_text = '' - self._last_pressed_key = None - self._timer_autocomplete = QTimer(self) - self._timer_autocomplete.setSingleShot(True) - self._timer_autocomplete.timeout.connect(self._handle_completions) - - # Handle completions hints - self._completions_hint_idle = False - self._timer_completions_hint = QTimer(self) - self._timer_completions_hint.setSingleShot(True) - self._timer_completions_hint.timeout.connect( - self._set_completions_hint_idle) - self.completion_widget.sig_completion_hint.connect( - self.show_hint_for_completion) - - # Request symbols and folding after a timeout. - # See: process_diagnostics - self._timer_sync_symbols_and_folding = QTimer(self) - self._timer_sync_symbols_and_folding.setSingleShot(True) - self._timer_sync_symbols_and_folding.timeout.connect( - self.sync_symbols_and_folding) - self.blockCountChanged.connect( - self.set_sync_symbols_and_folding_timeout) - - # Goto uri - self._last_hover_pattern_key = None - self._last_hover_pattern_text = None - - # 79-col edge line - self.edge_line = self.panels.register(EdgeLine(), - Panel.Position.FLOATING) - - # indent guides - self.indent_guides = self.panels.register(IndentationGuide(), - Panel.Position.FLOATING) - # Blanks enabled - self.blanks_enabled = False - - # Underline errors and warnings - self.underline_errors_enabled = False - - # Scrolling past the end of the document - self.scrollpastend_enabled = False - - self.background = QColor('white') - - # Folding - self.panels.register(FoldingPanel()) - - # Debugger panel (Breakpoints) - self.debugger = DebuggerManager(self) - self.panels.register(DebuggerPanel()) - # Update breakpoints if the number of lines in the file changes - self.blockCountChanged.connect(self.sig_breakpoints_changed) - - # Line number area management - self.linenumberarea = self.panels.register(LineNumberArea()) - - # Class and Method/Function Dropdowns - self.classfuncdropdown = self.panels.register( - ClassFunctionDropdown(), - Panel.Position.TOP, - ) - - # Colors to be defined in _apply_highlighter_color_scheme() - # Currentcell color and current line color are defined in base.py - self.occurrence_color = None - self.ctrl_click_color = None - self.sideareas_color = None - self.matched_p_color = None - self.unmatched_p_color = None - self.normal_color = None - self.comment_color = None - - # --- Syntax highlight entrypoint --- - # - # - if set, self.highlighter is responsible for - # - coloring raw text data inside editor on load - # - coloring text data when editor is cloned - # - updating document highlight on line edits - # - providing color palette (scheme) for the editor - # - providing data for Outliner - # - self.highlighter is not responsible for - # - background highlight for current line - # - background highlight for search / current line occurrences - - self.highlighter_class = sh.TextSH - self.highlighter = None - ccs = 'Spyder' - if ccs not in sh.COLOR_SCHEME_NAMES: - ccs = sh.COLOR_SCHEME_NAMES[0] - self.color_scheme = ccs - - self.highlight_current_line_enabled = False - - # Vertical scrollbar - # This is required to avoid a "RuntimeError: no access to protected - # functions or signals for objects not created from Python" in - # Linux Ubuntu. See spyder-ide/spyder#5215. - self.setVerticalScrollBar(QScrollBar()) - - # Highlights and flag colors - self.warning_color = SpyderPalette.COLOR_WARN_2 - self.error_color = SpyderPalette.COLOR_ERROR_1 - self.todo_color = SpyderPalette.GROUP_9 - self.breakpoint_color = SpyderPalette.ICON_3 - self.occurrence_color = QColor(SpyderPalette.GROUP_2).lighter(160) - self.found_results_color = QColor(SpyderPalette.COLOR_OCCURRENCE_4) - - # Scrollbar flag area - self.scrollflagarea = self.panels.register(ScrollFlagArea(), - Panel.Position.RIGHT) - self.panels.refresh() - - self.document_id = id(self) - - # Indicate occurrences of the selected word - self.cursorPositionChanged.connect(self.__cursor_position_changed) - self.__find_first_pos = None - - self.language = None - self.supported_language = False - self.supported_cell_language = False - self.comment_string = None - self._kill_ring = QtKillRing(self) - - # Block user data - self.blockCountChanged.connect(self.update_bookmarks) - - # Highlight using Pygments highlighter timer - # --------------------------------------------------------------------- - # For files that use the PygmentsSH we parse the full file inside - # the highlighter in order to generate the correct coloring. - self.timer_syntax_highlight = QTimer(self) - self.timer_syntax_highlight.setSingleShot(True) - self.timer_syntax_highlight.timeout.connect( - self.run_pygments_highlighter) - - # Mark occurrences timer - self.occurrence_highlighting = None - self.occurrence_timer = QTimer(self) - self.occurrence_timer.setSingleShot(True) - self.occurrence_timer.setInterval(1500) - self.occurrence_timer.timeout.connect(self.__mark_occurrences) - self.occurrences = [] - - # Update decorations - self.update_decorations_timer = QTimer(self) - self.update_decorations_timer.setSingleShot(True) - self.update_decorations_timer.setInterval( - self.UPDATE_DECORATIONS_TIMEOUT) - self.update_decorations_timer.timeout.connect( - self.update_decorations) - self.verticalScrollBar().valueChanged.connect( - lambda value: self.update_decorations_timer.start()) - - # LSP - self.textChanged.connect(self.schedule_document_did_change) - self._pending_server_requests = [] - self._server_requests_timer = QTimer(self) - self._server_requests_timer.setSingleShot(True) - self._server_requests_timer.setInterval(100) - self._server_requests_timer.timeout.connect( - self.process_server_requests) - - # Mark found results - self.textChanged.connect(self.__text_has_changed) - self.found_results = [] - - # Docstring - self.writer_docstring = DocstringWriterExtension(self) - - # Context menu - self.gotodef_action = None - self.setup_context_menu() - - # Tab key behavior - self.tab_indents = None - self.tab_mode = True # see CodeEditor.set_tab_mode - - # Intelligent backspace mode - self.intelligent_backspace = True - - # Automatic (on the fly) completions - self.automatic_completions = True - self.automatic_completions_after_chars = 3 - self.automatic_completions_after_ms = 300 - - # Code Folding - self.code_folding = True - self.update_folding_thread = QThread(None) - self.update_folding_thread.finished.connect(self.finish_code_folding) - - # Completions hint - self.completions_hint = True - self.completions_hint_after_ms = 500 - - self.close_parentheses_enabled = True - self.close_quotes_enabled = False - self.add_colons_enabled = True - self.auto_unindent_enabled = True - - # Autoformat on save - self.format_on_save = False - self.format_eventloop = QEventLoop(None) - self.format_timer = QTimer(self) - - # Mouse tracking - self.setMouseTracking(True) - self.__cursor_changed = False - self._mouse_left_button_pressed = False - self.ctrl_click_color = QColor(Qt.blue) - - self._bookmarks_blocks = {} - self.bookmarks = [] - - # Keyboard shortcuts - self.shortcuts = self.create_shortcuts() - - # Paint event - self.__visible_blocks = [] # Visible blocks, update with repaint - self.painted.connect(self._draw_editor_cell_divider) - - # Outline explorer - self.oe_proxy = None - - # Line stripping - self.last_change_position = None - self.last_position = None - self.last_auto_indent = None - self.skip_rstrip = False - self.strip_trailing_spaces_on_modify = True - - # Hover hints - self.hover_hints_enabled = None - - # Language Server - self.filename = None - self.completions_available = False - self.text_version = 0 - self.save_include_text = True - self.open_close_notifications = True - self.sync_mode = TextDocumentSyncKind.FULL - self.will_save_notify = False - self.will_save_until_notify = False - self.enable_hover = True - self.auto_completion_characters = [] - self.resolve_completions_enabled = False - self.signature_completion_characters = [] - self.go_to_definition_enabled = False - self.find_references_enabled = False - self.highlight_enabled = False - self.formatting_enabled = False - self.range_formatting_enabled = False - self.document_symbols_enabled = False - self.formatting_characters = [] - self.completion_args = None - self.folding_supported = False - self.is_cloned = False - self.operation_in_progress = False - self.formatting_in_progress = False - - # Diagnostics - self.update_diagnostics_thread = QThread(None) - self.update_diagnostics_thread.run = self.set_errors - self.update_diagnostics_thread.finished.connect( - self.finish_code_analysis) - self._diagnostics = [] - - # Editor Extensions - self.editor_extensions = EditorExtensionsManager(self) - self.editor_extensions.add(CloseQuotesExtension()) - self.editor_extensions.add(SnippetsExtension()) - self.editor_extensions.add(CloseBracketsExtension()) - - # Text diffs across versions - self.differ = diff_match_patch() - self.previous_text = '' - self.patch = [] - self.leading_whitespaces = {} - - # re-use parent of completion_widget (usually the main window) - completion_parent = self.completion_widget.parent() - self.kite_call_to_action = KiteCallToAction(self, completion_parent) - - # Some events should not be triggered during undo/redo - # such as line stripping - self.is_undoing = False - self.is_redoing = False - - # Timer to Avoid too many calls to rehighlight. - self._rehighlight_timer = QTimer(self) - self._rehighlight_timer.setSingleShot(True) - self._rehighlight_timer.setInterval(150) - - # --- Helper private methods - # ------------------------------------------------------------------------ - def process_server_requests(self): - """Process server requests.""" - # Check if document needs to be updated: - if self._document_server_needs_update: - self.document_did_change() - self._document_server_needs_update = False - for method, params, requires_response in self._pending_server_requests: - self.emit_request(method, params, requires_response) - self._pending_server_requests = [] - - # --- Hover/Hints - def _should_display_hover(self, point): - """Check if a hover hint should be displayed:""" - if not self._mouse_left_button_pressed: - return (self.hover_hints_enabled and point - and self.get_word_at(point)) - - def _handle_hover(self): - """Handle hover hint trigger after delay.""" - self._timer_mouse_moving.stop() - pos = self._last_point - - # These are textual characters but should not trigger a completion - # FIXME: update per language - ignore_chars = ['(', ')', '.'] - - if self._should_display_hover(pos): - key, pattern_text, cursor = self.get_pattern_at(pos) - text = self.get_word_at(pos) - if pattern_text: - ctrl_text = 'Cmd' if sys.platform == "darwin" else 'Ctrl' - if key in ['file']: - hint_text = ctrl_text + ' + ' + _('click to open file') - elif key in ['mail']: - hint_text = ctrl_text + ' + ' + _('click to send email') - elif key in ['url']: - hint_text = ctrl_text + ' + ' + _('click to open url') - else: - hint_text = ctrl_text + ' + ' + _('click to open') - - hint_text = ' {} '.format(hint_text) - - self.show_tooltip(text=hint_text, at_point=pos) - return - - cursor = self.cursorForPosition(pos) - cursor_offset = cursor.position() - line, col = cursor.blockNumber(), cursor.columnNumber() - self._last_point = pos - if text and self._last_hover_word != text: - if all(char not in text for char in ignore_chars): - self._last_hover_word = text - self.request_hover(line, col, cursor_offset) - else: - self.hide_tooltip() - elif not self.is_completion_widget_visible(): - self.hide_tooltip() - - def blockuserdata_list(self): - """Get the list of all user data in document.""" - block = self.document().firstBlock() - while block.isValid(): - data = block.userData() - if data: - yield data - block = block.next() - - def outlineexplorer_data_list(self): - """Get the list of all user data in document.""" - for data in self.blockuserdata_list(): - if data.oedata: - yield data.oedata - - # ---- Keyboard Shortcuts - - def create_cursor_callback(self, attr): - """Make a callback for cursor move event type, (e.g. "Start")""" - def cursor_move_event(): - cursor = self.textCursor() - move_type = getattr(QTextCursor, attr) - cursor.movePosition(move_type) - self.setTextCursor(cursor) - return cursor_move_event - - def create_shortcuts(self): - """Create the local shortcuts for the CodeEditor.""" - shortcut_context_name_callbacks = ( - ('editor', 'code completion', self.do_completion), - ('editor', 'duplicate line down', self.duplicate_line_down), - ('editor', 'duplicate line up', self.duplicate_line_up), - ('editor', 'delete line', self.delete_line), - ('editor', 'move line up', self.move_line_up), - ('editor', 'move line down', self.move_line_down), - ('editor', 'go to new line', self.go_to_new_line), - ('editor', 'go to definition', self.go_to_definition_from_cursor), - ('editor', 'toggle comment', self.toggle_comment), - ('editor', 'blockcomment', self.blockcomment), - ('editor', 'unblockcomment', self.unblockcomment), - ('editor', 'transform to uppercase', self.transform_to_uppercase), - ('editor', 'transform to lowercase', self.transform_to_lowercase), - ('editor', 'indent', lambda: self.indent(force=True)), - ('editor', 'unindent', lambda: self.unindent(force=True)), - ('editor', 'start of line', - self.create_cursor_callback('StartOfLine')), - ('editor', 'end of line', - self.create_cursor_callback('EndOfLine')), - ('editor', 'previous line', self.create_cursor_callback('Up')), - ('editor', 'next line', self.create_cursor_callback('Down')), - ('editor', 'previous char', self.create_cursor_callback('Left')), - ('editor', 'next char', self.create_cursor_callback('Right')), - ('editor', 'previous word', - self.create_cursor_callback('PreviousWord')), - ('editor', 'next word', self.create_cursor_callback('NextWord')), - ('editor', 'kill to line end', self.kill_line_end), - ('editor', 'kill to line start', self.kill_line_start), - ('editor', 'yank', self._kill_ring.yank), - ('editor', 'rotate kill ring', self._kill_ring.rotate), - ('editor', 'kill previous word', self.kill_prev_word), - ('editor', 'kill next word', self.kill_next_word), - ('editor', 'start of document', - self.create_cursor_callback('Start')), - ('editor', 'end of document', - self.create_cursor_callback('End')), - ('editor', 'undo', self.undo), - ('editor', 'redo', self.redo), - ('editor', 'cut', self.cut), - ('editor', 'copy', self.copy), - ('editor', 'paste', self.paste), - ('editor', 'delete', self.delete), - ('editor', 'select all', self.selectAll), - ('editor', 'docstring', - self.writer_docstring.write_docstring_for_shortcut), - ('editor', 'autoformatting', self.format_document_or_range), - ('array_builder', 'enter array inline', self.enter_array_inline), - ('array_builder', 'enter array table', self.enter_array_table) - ) - - shortcuts = [] - for context, name, callback in shortcut_context_name_callbacks: - shortcuts.append( - CONF.config_shortcut( - callback, context=context, name=name, parent=self)) - return shortcuts - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - def closeEvent(self, event): - if isinstance(self.highlighter, sh.PygmentsSH): - self.highlighter.stop() - self.update_folding_thread.quit() - self.update_folding_thread.wait() - self.update_diagnostics_thread.quit() - self.update_diagnostics_thread.wait() - TextEditBaseWidget.closeEvent(self, event) - - def get_document_id(self): - return self.document_id - - def set_as_clone(self, editor): - """Set as clone editor""" - self.setDocument(editor.document()) - self.document_id = editor.get_document_id() - self.highlighter = editor.highlighter - self._rehighlight_timer.timeout.connect( - self.highlighter.rehighlight) - self.eol_chars = editor.eol_chars - self._apply_highlighter_color_scheme() - self.highlighter.sig_font_changed.connect(self.sync_font) - - # ---- Widget setup and options - def toggle_wrap_mode(self, enable): - """Enable/disable wrap mode""" - self.set_wrap_mode('word' if enable else None) - - def toggle_line_numbers(self, linenumbers=True, markers=False): - """Enable/disable line numbers.""" - self.linenumberarea.setup_margins(linenumbers, markers) - - @property - def panels(self): - """ - Returns a reference to the - :class:`spyder.widgets.panels.managers.PanelsManager` - used to manage the collection of installed panels - """ - return self._panels - - def setup_editor(self, - linenumbers=True, - language=None, - markers=False, - font=None, - color_scheme=None, - wrap=False, - tab_mode=True, - strip_mode=False, - intelligent_backspace=True, - automatic_completions=True, - automatic_completions_after_chars=3, - automatic_completions_after_ms=300, - completions_hint=True, - completions_hint_after_ms=500, - hover_hints=True, - code_snippets=True, - highlight_current_line=True, - highlight_current_cell=True, - occurrence_highlighting=True, - scrollflagarea=True, - edge_line=True, - edge_line_columns=(79,), - show_blanks=False, - underline_errors=False, - close_parentheses=True, - close_quotes=False, - add_colons=True, - auto_unindent=True, - indent_chars=" "*4, - tab_stop_width_spaces=4, - cloned_from=None, - filename=None, - occurrence_timeout=1500, - show_class_func_dropdown=False, - indent_guides=False, - scroll_past_end=False, - show_debug_panel=True, - folding=True, - remove_trailing_spaces=False, - remove_trailing_newlines=False, - add_newline=False, - format_on_save=False): - """ - Set-up configuration for the CodeEditor instance. - - Usually the parameters here are related with a configurable preference - in the Preference Dialog and Editor configurations: - - linenumbers: Enable/Disable line number panel. Default True. - language: Set editor language for example python. Default None. - markers: Enable/Disable markers panel. Used to show elements like - Code Analysis. Default False. - font: Base font for the Editor to use. Default None. - color_scheme: Initial color scheme for the Editor to use. Default None. - wrap: Enable/Disable line wrap. Default False. - tab_mode: Enable/Disable using Tab as delimiter between word, - Default True. - strip_mode: strip_mode: Enable/Disable striping trailing spaces when - modifying the file. Default False. - intelligent_backspace: Enable/Disable automatically unindenting - inserted text (unindenting happens if the leading text length of - the line isn't module of the length of indentation chars being use) - Default True. - automatic_completions: Enable/Disable automatic completions. - The behavior of the trigger of this the completions can be - established with the two following kwargs. Default True. - automatic_completions_after_chars: Number of charts to type to trigger - an automatic completion. Default 3. - automatic_completions_after_ms: Number of milliseconds to pass before - an autocompletion is triggered. Default 300. - completions_hint: Enable/Disable documentation hints for completions. - Default True. - completions_hint_after_ms: Number of milliseconds over a completion - item to show the documentation hint. Default 500. - hover_hints: Enable/Disable documentation hover hints. Default True. - code_snippets: Enable/Disable code snippets completions. Default True. - highlight_current_line: Enable/Disable current line highlighting. - Default True. - highlight_current_cell: Enable/Disable current cell highlighting. - Default True. - occurrence_highlighting: Enable/Disable highlighting of current word - occurrence in the file. Default True. - scrollflagarea : Enable/Disable flag area that shows at the left of - the scroll bar. Default True. - edge_line: Enable/Disable vertical line to show max number of - characters per line. Customizable number of columns in the - following kwarg. Default True. - edge_line_columns: Number of columns/characters where the editor - horizontal edge line will show. Default (79,). - show_blanks: Enable/Disable blanks highlighting. Default False. - underline_errors: Enable/Disable showing and underline to highlight - errors. Default False. - close_parentheses: Enable/Disable automatic parentheses closing - insertion. Default True. - close_quotes: Enable/Disable automatic closing of quotes. - Default False. - add_colons: Enable/Disable automatic addition of colons. Default True. - auto_unindent: Enable/Disable automatically unindentation before else, - elif, finally or except statements. Default True. - indent_chars: Characters to use for indentation. Default " "*4. - tab_stop_width_spaces: Enable/Disable using tabs for indentation. - Default 4. - cloned_from: Editor instance used as template to instantiate this - CodeEditor instance. Default None. - filename: Initial filename to show. Default None. - occurrence_timeout : Timeout in milliseconds to start highlighting - matches/occurrences for the current word under the cursor. - Default 1500 ms. - show_class_func_dropdown: Enable/Disable a Matlab like widget to show - classes and functions available in the current file. Default False. - indent_guides: Enable/Disable highlighting of code indentation. - Default False. - scroll_past_end: Enable/Disable possibility to scroll file passed - its end. Default False. - show_debug_panel: Enable/Disable debug panel. Default True. - folding: Enable/Disable code folding. Default True. - remove_trailing_spaces: Remove trailing whitespaces on lines. - Default False. - remove_trailing_newlines: Remove extra lines at the end of the file. - Default False. - add_newline: Add a newline at the end of the file if there is not one. - Default False. - format_on_save: Autoformat file automatically when saving. - Default False. - """ - - self.set_close_parentheses_enabled(close_parentheses) - self.set_close_quotes_enabled(close_quotes) - self.set_add_colons_enabled(add_colons) - self.set_auto_unindent_enabled(auto_unindent) - self.set_indent_chars(indent_chars) - - # Show/hide the debug panel depending on the language and parameter - self.set_debug_panel(show_debug_panel, language) - - # Show/hide folding panel depending on parameter - self.toggle_code_folding(folding) - - # Scrollbar flag area - self.scrollflagarea.set_enabled(scrollflagarea) - - # Debugging - self.debugger.set_filename(filename) - - # Edge line - self.edge_line.set_enabled(edge_line) - self.edge_line.set_columns(edge_line_columns) - - # Indent guides - self.toggle_identation_guides(indent_guides) - if self.indent_chars == '\t': - self.indent_guides.set_indentation_width( - tab_stop_width_spaces) - else: - self.indent_guides.set_indentation_width(len(self.indent_chars)) - - # Blanks - self.set_blanks_enabled(show_blanks) - - # Remove trailing whitespaces - self.set_remove_trailing_spaces(remove_trailing_spaces) - - # Remove trailing newlines - self.set_remove_trailing_newlines(remove_trailing_newlines) - - # Add newline at the end - self.set_add_newline(add_newline) - - # Scrolling past the end - self.set_scrollpastend_enabled(scroll_past_end) - - # Line number area and indent guides - if cloned_from: - self.setFont(font) # this is required for line numbers area - # Needed to show indent guides for splited editor panels - # See spyder-ide/spyder#10900 - self.patch = cloned_from.patch - self.is_cloned = True - self.toggle_line_numbers(linenumbers, markers) - - # Lexer - self.filename = filename - self.set_language(language, filename) - - # Underline errors and warnings - self.set_underline_errors_enabled(underline_errors) - - # Highlight current cell - self.set_highlight_current_cell(highlight_current_cell) - - # Highlight current line - self.set_highlight_current_line(highlight_current_line) - - # Occurrence highlighting - self.set_occurrence_highlighting(occurrence_highlighting) - self.set_occurrence_timeout(occurrence_timeout) - - # Tab always indents (even when cursor is not at the begin of line) - self.set_tab_mode(tab_mode) - - # Intelligent backspace - self.toggle_intelligent_backspace(intelligent_backspace) - - # Automatic completions - self.toggle_automatic_completions(automatic_completions) - self.set_automatic_completions_after_chars( - automatic_completions_after_chars) - self.set_automatic_completions_after_ms(automatic_completions_after_ms) - - # Completions hint - self.toggle_completions_hint(completions_hint) - self.set_completions_hint_after_ms(completions_hint_after_ms) - - # Hover hints - self.toggle_hover_hints(hover_hints) - - # Code snippets - self.toggle_code_snippets(code_snippets) - - # Autoformat on save - self.toggle_format_on_save(format_on_save) - - if cloned_from is not None: - self.set_as_clone(cloned_from) - self.panels.refresh() - elif font is not None: - self.set_font(font, color_scheme) - elif color_scheme is not None: - self.set_color_scheme(color_scheme) - - # Set tab spacing after font is set - self.set_tab_stop_width_spaces(tab_stop_width_spaces) - - self.toggle_wrap_mode(wrap) - - # Class/Function dropdown will be disabled if we're not in a Python - # file. - self.classfuncdropdown.setVisible(show_class_func_dropdown - and self.is_python_like()) - - self.set_strip_mode(strip_mode) - - # --- Language Server Protocol methods ----------------------------------- - # ------------------------------------------------------------------------ - @Slot(str, dict) - def handle_response(self, method, params): - if method in self.handler_registry: - handler_name = self.handler_registry[method] - handler = getattr(self, handler_name) - handler(params) - # This signal is only used on tests. - # It could be used to track and profile LSP diagnostics. - self.completions_response_signal.emit(method, params) - - def emit_request(self, method, params, requires_response): - """Send request to LSP manager.""" - params['requires_response'] = requires_response - params['response_instance'] = self - self.sig_perform_completion_request.emit( - self.language.lower(), method, params) - - def log_lsp_handle_errors(self, message): - """ - Log errors when handling LSP responses. - - This works when debugging is on or off. - """ - if get_debug_level() > 0: - # We log the error normally when running on debug mode. - logger.error(message, exc_info=True) - else: - # We need this because logger.error activates our error - # report dialog but it doesn't show the entire traceback - # there. So we intentionally leave an error in this call - # to get the entire stack info generated by it, which - # gives the info we need from users. - if PY2: - logger.error(message, exc_info=True) - print(message, file=sys.stderr) - else: - logger.error('%', 1, stack_info=True) - - # ------------- LSP: Configuration and protocol start/end ---------------- - def start_completion_services(self): - """Start completion services for this instance.""" - self.completions_available = True - - if self.is_cloned: - additional_msg = " cloned editor" - else: - additional_msg = "" - self.document_did_open() - - logger.debug(u"Completion services available for {0}: {1}".format( - additional_msg, self.filename)) - - def register_completion_capabilities(self, capabilities): - """ - Register completion server capabilities. - - Parameters - ---------- - capabilities: dict - Capabilities supported by a language server. - """ - sync_options = capabilities['textDocumentSync'] - completion_options = capabilities['completionProvider'] - signature_options = capabilities['signatureHelpProvider'] - range_formatting_options = ( - capabilities['documentOnTypeFormattingProvider']) - self.open_close_notifications = sync_options.get('openClose', False) - self.sync_mode = sync_options.get('change', TextDocumentSyncKind.NONE) - self.will_save_notify = sync_options.get('willSave', False) - self.will_save_until_notify = sync_options.get('willSaveWaitUntil', - False) - self.save_include_text = sync_options['save']['includeText'] - self.enable_hover = capabilities['hoverProvider'] - self.folding_supported = capabilities.get( - 'foldingRangeProvider', False) - self.auto_completion_characters = ( - completion_options['triggerCharacters']) - self.resolve_completions_enabled = ( - completion_options.get('resolveProvider', False)) - self.signature_completion_characters = ( - signature_options['triggerCharacters'] + ['=']) # FIXME: - self.go_to_definition_enabled = capabilities['definitionProvider'] - self.find_references_enabled = capabilities['referencesProvider'] - self.highlight_enabled = capabilities['documentHighlightProvider'] - self.formatting_enabled = capabilities['documentFormattingProvider'] - self.range_formatting_enabled = ( - capabilities['documentRangeFormattingProvider']) - self.document_symbols_enabled = ( - capabilities['documentSymbolProvider'] - ) - self.formatting_characters.append( - range_formatting_options['firstTriggerCharacter']) - self.formatting_characters += ( - range_formatting_options.get('moreTriggerCharacter', [])) - - if self.formatting_enabled: - self.format_action.setEnabled(True) - self.sig_refresh_formatting.emit(True) - - self.completions_available = True - - def stop_completion_services(self): - logger.debug('Stopping completion services for %s' % self.filename) - self.completions_available = False - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_OPEN, - requires_response=False) - def document_did_open(self): - """Send textDocument/didOpen request to the server.""" - cursor = self.textCursor() - text = self.get_text_with_eol() - if self.is_ipython(): - # Send valid python text to LSP as it doesn't support IPython - text = self.ipython_to_python(text) - params = { - 'file': self.filename, - 'language': self.language, - 'version': self.text_version, - 'text': text, - 'codeeditor': self, - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - } - return params - - # ------------- LSP: Symbols --------------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_SYMBOL) - def request_symbols(self): - """Request document symbols.""" - if not self.document_symbols_enabled: - return - if self.oe_proxy is not None: - self.oe_proxy.emit_request_in_progress() - params = {'file': self.filename} - return params - - @handles(CompletionRequestTypes.DOCUMENT_SYMBOL) - def process_symbols(self, params): - """Handle symbols response.""" - try: - symbols = params['params'] - symbols = [] if symbols is None else symbols - self.classfuncdropdown.update_data(symbols) - if self.oe_proxy is not None: - self.oe_proxy.update_outline_info(symbols) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing symbols") - - # ------------- LSP: Linting --------------------------------------- - def schedule_document_did_change(self): - """Schedule a document update.""" - self._document_server_needs_update = True - self._server_requests_timer.start() - - @request( - method=CompletionRequestTypes.DOCUMENT_DID_CHANGE, - requires_response=False) - def document_did_change(self): - """Send textDocument/didChange request to the server.""" - # Cancel formatting - self.formatting_in_progress = False - text = self.get_text_with_eol() - if self.is_ipython(): - # Send valid python text to LSP - text = self.ipython_to_python(text) - - self.text_version += 1 - - self.patch = self.differ.patch_make(self.previous_text, text) - self.previous_text = text - cursor = self.textCursor() - params = { - 'file': self.filename, - 'version': self.text_version, - 'text': text, - 'diff': self.patch, - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - } - return params - - @handles(CompletionRequestTypes.DOCUMENT_PUBLISH_DIAGNOSTICS) - def process_diagnostics(self, params): - """Handle linting response.""" - # The LSP spec doesn't require that folding and symbols - # are treated in the same way as linting, i.e. to be - # recomputed on didChange, didOpen and didSave. However, - # we think that's necessary to maintain accurate folding - # and symbols all the time. Therefore, we decided to call - # those requests here, but after a certain timeout to - # avoid performance issues. - self._timer_sync_symbols_and_folding.start() - - # Process results (runs in a thread) - self.process_code_analysis(params['params']) - - def set_sync_symbols_and_folding_timeout(self): - """ - Set timeout to sync symbols and folding according to the file - size. - """ - current_lines = self.get_line_count() - timeout = None - - for lines in self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.keys(): - if (current_lines // lines) == 0: - timeout = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS[lines] - break - - if not timeout: - timeouts = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.values() - timeout = list(timeouts)[-1] - - self._timer_sync_symbols_and_folding.setInterval(timeout) - - def sync_symbols_and_folding(self): - """ - Synchronize symbols and folding after linting results arrive. - """ - self.request_folding() - self.request_symbols() - - def process_code_analysis(self, diagnostics): - """Process code analysis results in a thread.""" - self.cleanup_code_analysis() - self._diagnostics = diagnostics - - # Process diagnostics in a thread to improve performance. - self.update_diagnostics_thread.start() - - def cleanup_code_analysis(self): - """Remove all code analysis markers""" - self.setUpdatesEnabled(False) - self.clear_extra_selections('code_analysis_highlight') - self.clear_extra_selections('code_analysis_underline') - for data in self.blockuserdata_list(): - data.code_analysis = [] - - self.setUpdatesEnabled(True) - # When the new code analysis results are empty, it is necessary - # to update manually the scrollflag and linenumber areas (otherwise, - # the old flags will still be displayed): - self.sig_flags_changed.emit() - self.linenumberarea.update() - - def set_errors(self): - """Set errors and warnings in the line number area.""" - try: - self._process_code_analysis(underline=False) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing linting") - - def underline_errors(self): - """Underline errors and warnings.""" - try: - # Clear current selections before painting the new ones. - # This prevents accumulating them when moving around in or editing - # the file, which generated a memory leakage and sluggishness - # after some time. - self.clear_extra_selections('code_analysis_underline') - self._process_code_analysis(underline=True) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing linting") - - def finish_code_analysis(self): - """Finish processing code analysis results.""" - self.linenumberarea.update() - if self.underline_errors_enabled: - self.underline_errors() - self.sig_process_code_analysis.emit() - self.sig_flags_changed.emit() - - def errors_present(self): - """ - Return True if there are errors or warnings present in the file. - """ - return bool(len(self._diagnostics)) - - def _process_code_analysis(self, underline): - """ - Process all code analysis results. - - Parameters - ---------- - underline: bool - Determines if errors and warnings are going to be set in - the line number area or underlined. It's better to separate - these two processes for perfomance reasons. That's because - setting errors can be done in a thread whereas underlining - them can't. - """ - document = self.document() - if underline: - first_block, last_block = self.get_buffer_block_numbers() - - for diagnostic in self._diagnostics: - if self.is_ipython() and ( - diagnostic["message"] == "undefined name 'get_ipython'"): - # get_ipython is defined in IPython files - continue - source = diagnostic.get('source', '') - msg_range = diagnostic['range'] - start = msg_range['start'] - end = msg_range['end'] - code = diagnostic.get('code', 'E') - message = diagnostic['message'] - severity = diagnostic.get( - 'severity', DiagnosticSeverity.ERROR) - - block = document.findBlockByNumber(start['line']) - text = block.text() - - # Skip messages according to certain criteria. - # This one works for any programming language - if 'analysis:ignore' in text: - continue - - # This only works for Python. - if self.language == 'Python': - if NOQA_INLINE_REGEXP.search(text) is not None: - continue - - data = block.userData() - if not data: - data = BlockUserData(self) - - if underline: - block_nb = block.blockNumber() - if first_block <= block_nb <= last_block: - error = severity == DiagnosticSeverity.ERROR - color = self.error_color if error else self.warning_color - color = QColor(color) - color.setAlpha(255) - block.color = color - - data.selection_start = start - data.selection_end = end - - self.highlight_selection('code_analysis_underline', - data._selection(), - underline_color=block.color) - else: - # Don't append messages to data for cloned editors to avoid - # showing them twice or more times on hover. - # Fixes spyder-ide/spyder#15618 - if not self.is_cloned: - data.code_analysis.append( - (source, code, severity, message) - ) - block.setUserData(data) - - # ------------- LSP: Completion --------------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_COMPLETION) - def do_completion(self, automatic=False): - """Trigger completion.""" - cursor = self.textCursor() - current_word = self.get_current_word( - completion=True, - valid_python_variable=False - ) - - params = { - 'file': self.filename, - 'line': cursor.blockNumber(), - 'column': cursor.columnNumber(), - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - 'current_word': current_word - } - self.completion_args = (self.textCursor().position(), automatic) - return params - - @handles(CompletionRequestTypes.DOCUMENT_COMPLETION) - def process_completion(self, params): - """Handle completion response.""" - args = self.completion_args - if args is None: - # This should not happen - return - self.completion_args = None - position, automatic = args - - start_cursor = self.textCursor() - start_cursor.movePosition(QTextCursor.StartOfBlock) - line_text = self.get_text(start_cursor.position(), 'eol') - leading_whitespace = self.compute_whitespace(line_text) - indentation_whitespace = ' ' * leading_whitespace - eol_char = self.get_line_separator() - - try: - completions = params['params'] - completions = ([] if completions is None else - [completion for completion in completions - if completion.get('insertText') - or completion.get('textEdit', {}).get('newText')]) - prefix = self.get_current_word(completion=True, - valid_python_variable=False) - if (len(completions) == 1 - and completions[0].get('insertText') == prefix - and not completions[0].get('textEdit', {}).get('newText')): - completions.pop() - - replace_end = self.textCursor().position() - under_cursor = self.get_current_word_and_position(completion=True) - if under_cursor: - word, replace_start = under_cursor - else: - word = '' - replace_start = replace_end - first_letter = '' - if len(word) > 0: - first_letter = word[0] - - def sort_key(completion): - if 'textEdit' in completion: - text_insertion = completion['textEdit']['newText'] - else: - text_insertion = completion['insertText'] - first_insert_letter = text_insertion[0] - case_mismatch = ( - (first_letter.isupper() and first_insert_letter.islower()) - or - (first_letter.islower() and first_insert_letter.isupper()) - ) - # False < True, so case matches go first - return (case_mismatch, completion['sortText']) - - completion_list = sorted(completions, key=sort_key) - - # Allow for textEdit completions to be filtered by Spyder - # if on-the-fly completions are disabled, only if the - # textEdit range matches the word under the cursor. - for completion in completion_list: - if 'textEdit' in completion: - c_replace_start = completion['textEdit']['range']['start'] - c_replace_end = completion['textEdit']['range']['end'] - if (c_replace_start == replace_start - and c_replace_end == replace_end): - insert_text = completion['textEdit']['newText'] - completion['filterText'] = insert_text - completion['insertText'] = insert_text - del completion['textEdit'] - - if 'insertText' in completion: - insert_text = completion['insertText'] - insert_text_lines = insert_text.splitlines() - reindented_text = [insert_text_lines[0]] - for insert_line in insert_text_lines[1:]: - insert_line = indentation_whitespace + insert_line - reindented_text.append(insert_line) - reindented_text = eol_char.join(reindented_text) - completion['insertText'] = reindented_text - - self.completion_widget.show_list( - completion_list, position, automatic) - - self.kite_call_to_action.handle_processed_completions(completions) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - self.kite_call_to_action.hide_coverage_cta() - return - except Exception: - self.log_lsp_handle_errors('Error when processing completions') - - @schedule_request(method=CompletionRequestTypes.COMPLETION_RESOLVE) - def resolve_completion_item(self, item): - return { - 'file': self.filename, - 'completion_item': item - } - - @handles(CompletionRequestTypes.COMPLETION_RESOLVE) - def handle_completion_item_resolution(self, response): - try: - response = response['params'] - - if not response: - return - - self.completion_widget.augment_completion_info(response) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors( - "Error when handling completion item resolution") - - # ------------- LSP: Signature Hints ------------------------------------ - @schedule_request(method=CompletionRequestTypes.DOCUMENT_SIGNATURE) - def request_signature(self): - """Ask for signature.""" - line, column = self.get_cursor_line_column() - offset = self.get_position('cursor') - params = { - 'file': self.filename, - 'line': line, - 'column': column, - 'offset': offset - } - return params - - @handles(CompletionRequestTypes.DOCUMENT_SIGNATURE) - def process_signatures(self, params): - """Handle signature response.""" - try: - signature_params = params['params'] - - if (signature_params is not None and - 'activeParameter' in signature_params): - self.sig_signature_invoked.emit(signature_params) - signature_data = signature_params['signatures'] - documentation = signature_data['documentation'] - - if isinstance(documentation, dict): - documentation = documentation['value'] - - # The language server returns encoded text with - # spaces defined as `\xa0` - documentation = documentation.replace(u'\xa0', ' ') - - parameter_idx = signature_params['activeParameter'] - parameters = signature_data['parameters'] - parameter = None - if len(parameters) > 0 and parameter_idx < len(parameters): - parameter_data = parameters[parameter_idx] - parameter = parameter_data['label'] - - signature = signature_data['label'] - - # This method is part of spyder/widgets/mixins - self.show_calltip( - signature=signature, - parameter=parameter, - language=self.language, - documentation=documentation, - ) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing signature") - - # ------------- LSP: Hover/Mouse --------------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_CURSOR_EVENT) - def request_cursor_event(self): - text = self.get_text_with_eol() - cursor = self.textCursor() - params = { - 'file': self.filename, - 'version': self.text_version, - 'text': text, - 'offset': cursor.position(), - 'selection_start': cursor.selectionStart(), - 'selection_end': cursor.selectionEnd(), - } - return params - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_HOVER) - def request_hover(self, line, col, offset, show_hint=True, clicked=True): - """Request hover information.""" - params = { - 'file': self.filename, - 'line': line, - 'column': col, - 'offset': offset - } - self._show_hint = show_hint - self._request_hover_clicked = clicked - return params - - @handles(CompletionRequestTypes.DOCUMENT_HOVER) - def handle_hover_response(self, contents): - """Handle hover response.""" - if running_under_pytest(): - from unittest.mock import Mock - - # On some tests this is returning a Mock - if isinstance(contents, Mock): - return - - try: - content = contents['params'] - - # - Don't display hover if there's no content to display. - # - Prevent spurious errors when a client returns a list. - if not content or isinstance(content, list): - return - - self.sig_display_object_info.emit( - content, - self._request_hover_clicked - ) - if content is not None and self._show_hint and self._last_point: - # This is located in spyder/widgets/mixins.py - word = self._last_hover_word - content = content.replace(u'\xa0', ' ') - self.show_hint(content, inspect_word=word, - at_point=self._last_point) - self._last_point = None - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing hover") - - # ------------- LSP: Go To Definition ---------------------------- - @Slot() - @schedule_request(method=CompletionRequestTypes.DOCUMENT_DEFINITION) - def go_to_definition_from_cursor(self, cursor=None): - """Go to definition from cursor instance (QTextCursor).""" - if (not self.go_to_definition_enabled or - self.in_comment_or_string()): - return - - if cursor is None: - cursor = self.textCursor() - - text = to_text_string(cursor.selectedText()) - - if len(text) == 0: - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - if text is not None: - line, column = self.get_cursor_line_column() - params = { - 'file': self.filename, - 'line': line, - 'column': column - } - return params - - @handles(CompletionRequestTypes.DOCUMENT_DEFINITION) - def handle_go_to_definition(self, position): - """Handle go to definition response.""" - try: - position = position['params'] - if position is not None: - def_range = position['range'] - start = def_range['start'] - if self.filename == position['file']: - self.go_to_line(start['line'] + 1, - start['character'], - None, - word=None) - else: - self.go_to_definition.emit(position['file'], - start['line'] + 1, - start['character']) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors( - "Error when processing go to definition") - - # ------------- LSP: Document/Selection formatting -------------------- - def format_document_or_range(self): - if self.has_selected_text() and self.range_formatting_enabled: - self.format_document_range() - else: - self.format_document() - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_FORMATTING) - def format_document(self): - if not self.formatting_enabled: - return - if self.formatting_in_progress: - # Already waiting for a formatting - return - - using_spaces = self.indent_chars != '\t' - tab_size = (len(self.indent_chars) if using_spaces else - self.tab_stop_width_spaces) - params = { - 'file': self.filename, - 'options': { - 'tab_size': tab_size, - 'insert_spaces': using_spaces, - 'trim_trailing_whitespace': self.remove_trailing_spaces, - 'insert_final_new_line': self.add_newline, - 'trim_final_new_lines': self.remove_trailing_newlines - } - } - - # Sets the document into read-only and updates its corresponding - # tab name to display the filename into parenthesis - self.setReadOnly(True) - self.document().setModified(True) - self.sig_start_operation_in_progress.emit() - self.operation_in_progress = True - self.formatting_in_progress = True - - return params - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) - def format_document_range(self): - if not self.range_formatting_enabled or not self.has_selected_text(): - return - if self.formatting_in_progress: - # Already waiting for a formatting - return - - start, end = self.get_selection_start_end() - start_line, start_col = start - end_line, end_col = end - using_spaces = self.indent_chars != '\t' - tab_size = (len(self.indent_chars) if using_spaces else - self.tab_stop_width_spaces) - - fmt_range = { - 'start': { - 'line': start_line, - 'character': start_col - }, - 'end': { - 'line': end_line, - 'character': end_col - } - } - params = { - 'file': self.filename, - 'range': fmt_range, - 'options': { - 'tab_size': tab_size, - 'insert_spaces': using_spaces, - 'trim_trailing_whitespace': self.remove_trailing_spaces, - 'insert_final_new_line': self.add_newline, - 'trim_final_new_lines': self.remove_trailing_newlines - } - } - - # Sets the document into read-only and updates its corresponding - # tab name to display the filename into parenthesis - self.setReadOnly(True) - self.document().setModified(True) - self.sig_start_operation_in_progress.emit() - self.operation_in_progress = True - self.formatting_in_progress = True - - return params - - @handles(CompletionRequestTypes.DOCUMENT_FORMATTING) - def handle_document_formatting(self, edits): - try: - if self.formatting_in_progress: - self._apply_document_edits(edits) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing document " - "formatting") - finally: - # Remove read-only parenthesis and highlight document modification - self.setReadOnly(False) - self.document().setModified(False) - self.document().setModified(True) - self.sig_stop_operation_in_progress.emit() - self.operation_in_progress = False - self.formatting_in_progress = False - - @handles(CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) - def handle_document_range_formatting(self, edits): - try: - if self.formatting_in_progress: - self._apply_document_edits(edits) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing document " - "selection formatting") - finally: - # Remove read-only parenthesis and highlight document modification - self.setReadOnly(False) - self.document().setModified(False) - self.document().setModified(True) - self.sig_stop_operation_in_progress.emit() - self.operation_in_progress = False - self.formatting_in_progress = False - - def _apply_document_edits(self, edits): - """Apply a set of atomic document edits to the current editor text.""" - edits = edits['params'] - if edits is None: - return - - # We need to use here toPlainText (which returns text with '\n' - # for eols) and not get_text_with_eol, so that applying the - # text edits that come from the LSP in the way implemented below - # works as expected. That's because we assume eol chars of length - # one in our algorithm. - # Fixes spyder-ide/spyder#16180 - text = self.toPlainText() - - text_tokens = list(text) - merged_text = None - for edit in edits: - edit_range = edit['range'] - repl_text = edit['newText'] - start, end = edit_range['start'], edit_range['end'] - start_line, start_col = start['line'], start['character'] - end_line, end_col = end['line'], end['character'] - - start_pos = self.get_position_line_number(start_line, start_col) - end_pos = self.get_position_line_number(end_line, end_col) - - # Replace repl_text eols for '\n' to match the ones used in - # `text`. - repl_eol = sourcecode.get_eol_chars(repl_text) - if repl_eol is not None and repl_eol != '\n': - repl_text = repl_text.replace(repl_eol, '\n') - - text_tokens = list(text_tokens) - this_edit = list(repl_text) - - if end_line == self.document().blockCount(): - end_pos = self.get_position('eof') - end_pos += 1 - - if (end_pos == len(text_tokens) and - text_tokens[end_pos - 1] == '\n'): - end_pos += 1 - - this_edition = (text_tokens[:max(start_pos - 1, 0)] + - this_edit + - text_tokens[end_pos - 1:]) - - text_edit = ''.join(this_edition) - if merged_text is None: - merged_text = text_edit - else: - merged_text = merge(text_edit, merged_text, text) - - if merged_text is not None: - # Restore eol chars after applying edits. - merged_text = merged_text.replace('\n', self.get_line_separator()) - - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.Start) - cursor.movePosition(QTextCursor.End, - QTextCursor.KeepAnchor) - cursor.insertText(merged_text) - cursor.endEditBlock() - - # ------------- LSP: Code folding ranges ------------------------------- - def compute_whitespace(self, line): - tab_size = self.tab_stop_width_spaces - whitespace_regex = re.compile(r'(\s+).*') - whitespace_match = whitespace_regex.match(line) - total_whitespace = 0 - if whitespace_match is not None: - whitespace_chars = whitespace_match.group(1) - whitespace_chars = whitespace_chars.replace( - '\t', tab_size * ' ') - total_whitespace = len(whitespace_chars) - return total_whitespace - - def update_whitespace_count(self, line, column): - self.leading_whitespaces = {} - lines = to_text_string(self.toPlainText()).splitlines() - for i, text in enumerate(lines): - total_whitespace = self.compute_whitespace(text) - self.leading_whitespaces[i] = total_whitespace - - def cleanup_folding(self): - """Cleanup folding pane.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.folding_regions = {} - - @schedule_request(method=CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) - def request_folding(self): - """Request folding.""" - if not self.folding_supported or not self.code_folding: - return - params = {'file': self.filename} - return params - - @handles(CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) - def handle_folding_range(self, response): - """Handle folding response.""" - ranges = response['params'] - if ranges is None: - return - - # Compute extended_ranges here because get_text_region ends up - # calling paintEvent and that method can't be called in a - # thread due to Qt restrictions. - try: - extended_ranges = [] - for start, end in ranges: - text_region = self.get_text_region(start, end) - extended_ranges.append((start, end, text_region)) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing folding") - - # Update folding in a thread - self.update_folding_thread.run = functools.partial( - self.update_and_merge_folding, extended_ranges) - self.update_folding_thread.start() - - def update_and_merge_folding(self, extended_ranges): - """Update and merge new folding information.""" - try: - folding_panel = self.panels.get(FoldingPanel) - - current_tree, root = merge_folding( - extended_ranges, folding_panel.current_tree, - folding_panel.root) - - folding_info = collect_folding_regions(root) - self._folding_info = (current_tree, root, *folding_info) - except RuntimeError: - # This is triggered when a codeeditor instance was removed - # before the response can be processed. - return - except Exception: - self.log_lsp_handle_errors("Error when processing folding") - - def finish_code_folding(self): - """Finish processing code folding.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.update_folding(self._folding_info) - - # Update indent guides, which depend on folding - if self.indent_guides._enabled and len(self.patch) > 0: - line, column = self.get_cursor_line_column() - self.update_whitespace_count(line, column) - - # ------------- LSP: Save/close file ----------------------------------- - @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_SAVE, - requires_response=False) - def notify_save(self): - """Send save request.""" - params = {'file': self.filename} - if self.save_include_text: - params['text'] = self.get_text_with_eol() - return params - - @request(method=CompletionRequestTypes.DOCUMENT_DID_CLOSE, - requires_response=False) - def notify_close(self): - """Send close request.""" - self._pending_server_requests = [] - self._server_requests_timer.stop() - if self.completions_available: - # This is necessary to prevent an error in our tests. - try: - # Servers can send an empty publishDiagnostics reply to clear - # diagnostics after they receive a didClose request. Since - # we also ask for symbols and folding when processing - # diagnostics, we need to prevent it from happening - # before sending that request here. - self._timer_sync_symbols_and_folding.timeout.disconnect( - self.sync_symbols_and_folding) - except (TypeError, RuntimeError): - pass - - params = { - 'file': self.filename, - 'codeeditor': self - } - return params - - # ------------------------------------------------------------------------- - def set_debug_panel(self, show_debug_panel, language): - """Enable/disable debug panel.""" - debugger_panel = self.panels.get(DebuggerPanel) - if (is_text_string(language) and - language.lower() in ALL_LANGUAGES['Python'] and - show_debug_panel): - debugger_panel.setVisible(True) - else: - debugger_panel.setVisible(False) - - def update_debugger_panel_state(self, state, last_step, force=False): - """Update debugger panel state.""" - debugger_panel = self.panels.get(DebuggerPanel) - if force: - debugger_panel.start_clean() - return - elif state and 'fname' in last_step: - fname = last_step['fname'] - if (fname and self.filename - and osp.normcase(fname) == osp.normcase(self.filename)): - debugger_panel.start_clean() - return - debugger_panel.stop_clean() - - def set_folding_panel(self, folding): - """Enable/disable folding panel.""" - folding_panel = self.panels.get(FoldingPanel) - folding_panel.setVisible(folding) - - def set_tab_mode(self, enable): - """ - enabled = tab always indent - (otherwise tab indents only when cursor is at the beginning of a line) - """ - self.tab_mode = enable - - def set_strip_mode(self, enable): - """ - Strip all trailing spaces if enabled. - """ - self.strip_trailing_spaces_on_modify = enable - - def toggle_intelligent_backspace(self, state): - self.intelligent_backspace = state - - def toggle_automatic_completions(self, state): - self.automatic_completions = state - - def toggle_hover_hints(self, state): - self.hover_hints_enabled = state - - def toggle_code_snippets(self, state): - self.code_snippets = state - - def toggle_format_on_save(self, state): - self.format_on_save = state - - def toggle_code_folding(self, state): - self.code_folding = state - self.set_folding_panel(state) - if not state and self.indent_guides._enabled: - self.code_folding = True - - def toggle_identation_guides(self, state): - if state and not self.code_folding: - self.code_folding = True - self.indent_guides.set_enabled(state) - - def toggle_completions_hint(self, state): - """Enable/disable completion hint.""" - self.completions_hint = state - - def set_automatic_completions_after_chars(self, number): - """ - Set the number of characters after which auto completion is fired. - """ - self.automatic_completions_after_chars = number - - def set_automatic_completions_after_ms(self, ms): - """ - Set the amount of time in ms after which auto completion is fired. - """ - self.automatic_completions_after_ms = ms - - def set_completions_hint_after_ms(self, ms): - """ - Set the amount of time in ms after which the completions hint is shown. - """ - self.completions_hint_after_ms = ms - - def set_close_parentheses_enabled(self, enable): - """Enable/disable automatic parentheses insertion feature""" - self.close_parentheses_enabled = enable - bracket_extension = self.editor_extensions.get(CloseBracketsExtension) - if bracket_extension is not None: - bracket_extension.enabled = enable - - def set_close_quotes_enabled(self, enable): - """Enable/disable automatic quote insertion feature""" - self.close_quotes_enabled = enable - quote_extension = self.editor_extensions.get(CloseQuotesExtension) - if quote_extension is not None: - quote_extension.enabled = enable - - def set_add_colons_enabled(self, enable): - """Enable/disable automatic colons insertion feature""" - self.add_colons_enabled = enable - - def set_auto_unindent_enabled(self, enable): - """Enable/disable automatic unindent after else/elif/finally/except""" - self.auto_unindent_enabled = enable - - def set_occurrence_highlighting(self, enable): - """Enable/disable occurrence highlighting""" - self.occurrence_highlighting = enable - if not enable: - self.__clear_occurrences() - - def set_occurrence_timeout(self, timeout): - """Set occurrence highlighting timeout (ms)""" - self.occurrence_timer.setInterval(timeout) - - def set_underline_errors_enabled(self, state): - """Toggle the underlining of errors and warnings.""" - self.underline_errors_enabled = state - if not state: - self.clear_extra_selections('code_analysis_underline') - - def set_highlight_current_line(self, enable): - """Enable/disable current line highlighting""" - self.highlight_current_line_enabled = enable - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - - def set_highlight_current_cell(self, enable): - """Enable/disable current line highlighting""" - hl_cell_enable = enable and self.supported_cell_language - self.highlight_current_cell_enabled = hl_cell_enable - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - - def set_language(self, language, filename=None): - extra_supported_languages = {'stil': 'STIL'} - self.tab_indents = language in self.TAB_ALWAYS_INDENTS - self.comment_string = '' - self.language = 'Text' - self.supported_language = False - sh_class = sh.TextSH - language = 'None' if language is None else language - if language is not None: - for (key, value) in ALL_LANGUAGES.items(): - if language.lower() in value: - self.supported_language = True - sh_class, comment_string = self.LANGUAGES[key] - if key == 'IPython': - self.language = 'Python' - else: - self.language = key - self.comment_string = comment_string - if key in CELL_LANGUAGES: - self.supported_cell_language = True - self.has_cell_separators = True - break - - if filename is not None and not self.supported_language: - sh_class = sh.guess_pygments_highlighter(filename) - self.support_language = sh_class is not sh.TextSH - if self.support_language: - # Pygments report S for the lexer name of R files - if sh_class._lexer.name == 'S': - self.language = 'R' - else: - self.language = sh_class._lexer.name - else: - _, ext = osp.splitext(filename) - ext = ext.lower() - if ext in extra_supported_languages: - self.language = extra_supported_languages[ext] - - self._set_highlighter(sh_class) - self.completion_widget.set_language(self.language) - - def _set_highlighter(self, sh_class): - self.highlighter_class = sh_class - if self.highlighter is not None: - # Removing old highlighter - # TODO: test if leaving parent/document as is eats memory - self.highlighter.setParent(None) - self.highlighter.setDocument(None) - self.highlighter = self.highlighter_class(self.document(), - self.font(), - self.color_scheme) - self._apply_highlighter_color_scheme() - - self.highlighter.editor = self - self.highlighter.sig_font_changed.connect(self.sync_font) - self._rehighlight_timer.timeout.connect( - self.highlighter.rehighlight) - - def sync_font(self): - """Highlighter changed font, update.""" - self.setFont(self.highlighter.font) - self.sig_font_changed.emit() - - def get_cell_list(self): - """Get all cells.""" - if self.highlighter is None: - return [] - - # Filter out old cells - def good(oedata): - return oedata.is_valid() and oedata.def_type == oedata.CELL - - self.highlighter._cell_list = [ - oedata for oedata in self.highlighter._cell_list if good(oedata)] - - return sorted( - {oedata.block.blockNumber(): oedata - for oedata in self.highlighter._cell_list}.items()) - - def is_json(self): - return (isinstance(self.highlighter, sh.PygmentsSH) and - self.highlighter._lexer.name == 'JSON') - - def is_python(self): - return self.highlighter_class is sh.PythonSH - - def is_ipython(self): - return self.highlighter_class is sh.IPythonSH - - def is_python_or_ipython(self): - return self.is_python() or self.is_ipython() - - def is_cython(self): - return self.highlighter_class is sh.CythonSH - - def is_enaml(self): - return self.highlighter_class is sh.EnamlSH - - def is_python_like(self): - return (self.is_python() or self.is_ipython() - or self.is_cython() or self.is_enaml()) - - def intelligent_tab(self): - """Provide intelligent behavior for Tab key press.""" - leading_text = self.get_text('sol', 'cursor') - if not leading_text.strip() or leading_text.endswith('#'): - # blank line or start of comment - self.indent_or_replace() - elif self.in_comment_or_string() and not leading_text.endswith(' '): - # in a word in a comment - self.do_completion() - elif leading_text.endswith('import ') or leading_text[-1] == '.': - # blank import or dot completion - self.do_completion() - elif (leading_text.split()[0] in ['from', 'import'] and - ';' not in leading_text): - # import line with a single statement - # (prevents lines like: `import pdb; pdb.set_trace()`) - self.do_completion() - elif leading_text[-1] in '(,' or leading_text.endswith(', '): - self.indent_or_replace() - elif leading_text.endswith(' '): - # if the line ends with a space, indent - self.indent_or_replace() - elif re.search(r"[^\d\W]\w*\Z", leading_text, re.UNICODE): - # if the line ends with a non-whitespace character - self.do_completion() - else: - self.indent_or_replace() - - def intelligent_backtab(self): - """Provide intelligent behavior for Shift+Tab key press""" - leading_text = self.get_text('sol', 'cursor') - if not leading_text.strip(): - # blank line - self.unindent() - elif self.in_comment_or_string(): - self.unindent() - elif leading_text[-1] in '(,' or leading_text.endswith(', '): - position = self.get_position('cursor') - self.show_object_info(position) - else: - # if the line ends with any other character but comma - self.unindent() - - def rehighlight(self): - """Rehighlight the whole document.""" - if self.highlighter is not None: - self.highlighter.rehighlight() - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - - def trim_trailing_spaces(self): - """Remove trailing spaces""" - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.Start) - while True: - cursor.movePosition(QTextCursor.EndOfBlock) - text = to_text_string(cursor.block().text()) - length = len(text)-len(text.rstrip()) - if length > 0: - cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, - length) - cursor.removeSelectedText() - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock) - cursor.endEditBlock() - - def trim_trailing_newlines(self): - """Remove extra newlines at the end of the document.""" - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.End) - line = cursor.blockNumber() - this_line = self.get_text_line(line) - previous_line = self.get_text_line(line - 1) - - # Don't try to trim new lines for a file with a single line. - # Fixes spyder-ide/spyder#16401 - if self.get_line_count() > 1: - while this_line == '': - cursor.movePosition(QTextCursor.PreviousBlock, - QTextCursor.KeepAnchor) - - if self.add_newline: - if this_line == '' and previous_line != '': - cursor.movePosition(QTextCursor.NextBlock, - QTextCursor.KeepAnchor) - - line -= 1 - if line == 0: - break - - this_line = self.get_text_line(line) - previous_line = self.get_text_line(line - 1) - - if not self.add_newline: - cursor.movePosition(QTextCursor.EndOfBlock, - QTextCursor.KeepAnchor) - - cursor.removeSelectedText() - cursor.endEditBlock() - - def add_newline_to_file(self): - """Add a newline to the end of the file if it does not exist.""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - line = cursor.blockNumber() - this_line = self.get_text_line(line) - if this_line != '': - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.insertText(self.get_line_separator()) - cursor.endEditBlock() - - def fix_indentation(self): - """Replace tabs by spaces.""" - text_before = to_text_string(self.toPlainText()) - text_after = sourcecode.fix_indentation(text_before, self.indent_chars) - if text_before != text_after: - # We do the following rather than using self.setPlainText - # to benefit from QTextEdit's undo/redo feature. - self.selectAll() - self.skip_rstrip = True - self.insertPlainText(text_after) - self.skip_rstrip = False - - def get_current_object(self): - """Return current object (string) """ - source_code = to_text_string(self.toPlainText()) - offset = self.get_position('cursor') - return sourcecode.get_primary_at(source_code, offset) - - def next_cursor_position(self, position=None, - mode=QTextLayout.SkipCharacters): - """ - Get next valid cursor position. - - Adapted from: - https://github.com/qt/qtbase/blob/5.15.2/src/gui/text/qtextdocument_p.cpp#L1361 - """ - cursor = self.textCursor() - if cursor.atEnd(): - return position - if position is None: - position = cursor.position() - else: - cursor.setPosition(position) - it = cursor.block() - start = it.position() - end = start + it.length() - 1 - if (position == end): - return end + 1 - return it.layout().nextCursorPosition(position - start, mode) + start - - @Slot() - def delete(self): - """Remove selected text or next character.""" - if not self.has_selected_text(): - cursor = self.textCursor() - if not cursor.atEnd(): - cursor.setPosition( - self.next_cursor_position(), QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - self.remove_selected_text() - - #------Find occurrences - def __find_first(self, text): - """Find first occurrence: scan whole document""" - flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords - cursor = self.textCursor() - # Scanning whole document - cursor.movePosition(QTextCursor.Start) - regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) - cursor = self.document().find(regexp, cursor, flags) - self.__find_first_pos = cursor.position() - return cursor - - def __find_next(self, text, cursor): - """Find next occurrence""" - flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords - regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) - cursor = self.document().find(regexp, cursor, flags) - if cursor.position() != self.__find_first_pos: - return cursor - - def __cursor_position_changed(self): - """Cursor position has changed""" - line, column = self.get_cursor_line_column() - self.sig_cursor_position_changed.emit(line, column) - - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - if self.occurrence_highlighting: - self.occurrence_timer.start() - - # Strip if needed - self.strip_trailing_spaces() - - def __clear_occurrences(self): - """Clear occurrence markers""" - self.occurrences = [] - self.clear_extra_selections('occurrences') - self.sig_flags_changed.emit() - - def get_selection(self, cursor, foreground_color=None, - background_color=None, underline_color=None, - outline_color=None, - underline_style=QTextCharFormat.SingleUnderline): - """Get selection.""" - if cursor is None: - return - - selection = TextDecoration(cursor) - if foreground_color is not None: - selection.format.setForeground(foreground_color) - if background_color is not None: - selection.format.setBackground(background_color) - if underline_color is not None: - selection.format.setProperty(QTextFormat.TextUnderlineStyle, - to_qvariant(underline_style)) - selection.format.setProperty(QTextFormat.TextUnderlineColor, - to_qvariant(underline_color)) - if outline_color is not None: - selection.set_outline(outline_color) - return selection - - def highlight_selection(self, key, cursor, foreground_color=None, - background_color=None, underline_color=None, - outline_color=None, - underline_style=QTextCharFormat.SingleUnderline): - - selection = self.get_selection( - cursor, foreground_color, background_color, underline_color, - outline_color, underline_style) - if selection is None: - return - extra_selections = self.get_extra_selections(key) - extra_selections.append(selection) - self.set_extra_selections(key, extra_selections) - - def __mark_occurrences(self): - """Marking occurrences of the currently selected word""" - self.__clear_occurrences() - - if not self.supported_language: - return - - text = self.get_selected_text().strip() - if not text: - text = self.get_current_word() - if text is None: - return - if (self.has_selected_text() and - self.get_selected_text().strip() != text): - return - - if (self.is_python_like() and - (sourcecode.is_keyword(to_text_string(text)) or - to_text_string(text) == 'self')): - return - - # Highlighting all occurrences of word *text* - cursor = self.__find_first(text) - self.occurrences = [] - extra_selections = self.get_extra_selections('occurrences') - first_occurrence = None - while cursor: - block = cursor.block() - if not block.userData(): - # Add user data to check block validity - block.setUserData(BlockUserData(self)) - self.occurrences.append(block) - - selection = self.get_selection(cursor) - if len(selection.cursor.selectedText()) > 0: - extra_selections.append(selection) - if len(extra_selections) == 1: - first_occurrence = selection - else: - selection.format.setBackground(self.occurrence_color) - first_occurrence.format.setBackground( - self.occurrence_color) - cursor = self.__find_next(text, cursor) - self.set_extra_selections('occurrences', extra_selections) - - if len(self.occurrences) > 1 and self.occurrences[-1] == 0: - # XXX: this is never happening with PySide but it's necessary - # for PyQt4... this must be related to a different behavior for - # the QTextDocument.find function between those two libraries - self.occurrences.pop(-1) - self.sig_flags_changed.emit() - - #-----highlight found results (find/replace widget) - def highlight_found_results(self, pattern, word=False, regexp=False, - case=False): - """Highlight all found patterns""" - pattern = to_text_string(pattern) - if not pattern: - return - if not regexp: - pattern = re.escape(to_text_string(pattern)) - pattern = r"\b%s\b" % pattern if word else pattern - text = to_text_string(self.toPlainText()) - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - try: - regobj = re.compile(pattern, flags=re_flags) - except sre_constants.error: - return - extra_selections = [] - self.found_results = [] - has_unicode = len(text) != qstring_length(text) - for match in regobj.finditer(text): - if has_unicode: - pos1, pos2 = sh.get_span(match) - else: - pos1, pos2 = match.span() - selection = TextDecoration(self.textCursor()) - selection.format.setBackground(self.found_results_color) - selection.cursor.setPosition(pos1) - - block = selection.cursor.block() - if not block.userData(): - # Add user data to check block validity - block.setUserData(BlockUserData(self)) - self.found_results.append(block) - - selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor) - extra_selections.append(selection) - self.set_extra_selections('find', extra_selections) - - def clear_found_results(self): - """Clear found results highlighting""" - self.found_results = [] - self.clear_extra_selections('find') - self.sig_flags_changed.emit() - - def __text_has_changed(self): - """Text has changed, eventually clear found results highlighting""" - self.last_change_position = self.textCursor().position() - if self.found_results: - self.clear_found_results() - - def get_linenumberarea_width(self): - """ - Return current line number area width. - - This method is left for backward compatibility (BaseEditMixin - define it), any changes should be in LineNumberArea class. - """ - return self.linenumberarea.get_width() - - def calculate_real_position(self, point): - """Add offset to a point, to take into account the panels.""" - point.setX(point.x() + self.panels.margin_size(Panel.Position.LEFT)) - point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) - return point - - def calculate_real_position_from_global(self, point): - """Add offset to a point, to take into account the panels.""" - point.setX(point.x() - self.panels.margin_size(Panel.Position.LEFT)) - point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) - return point - - def get_linenumber_from_mouse_event(self, event): - """Return line number from mouse event""" - block = self.firstVisibleBlock() - line_number = block.blockNumber() - top = self.blockBoundingGeometry(block).translated( - self.contentOffset()).top() - bottom = top + self.blockBoundingRect(block).height() - while block.isValid() and top < event.pos().y(): - block = block.next() - if block.isVisible(): # skip collapsed blocks - top = bottom - bottom = top + self.blockBoundingRect(block).height() - line_number += 1 - return line_number - - def select_lines(self, linenumber_pressed, linenumber_released): - """Select line(s) after a mouse press/mouse press drag event""" - find_block_by_number = self.document().findBlockByNumber - move_n_blocks = (linenumber_released - linenumber_pressed) - start_line = linenumber_pressed - start_block = find_block_by_number(start_line - 1) - - cursor = self.textCursor() - cursor.setPosition(start_block.position()) - - # Select/drag downwards - if move_n_blocks > 0: - for n in range(abs(move_n_blocks) + 1): - cursor.movePosition(cursor.NextBlock, cursor.KeepAnchor) - # Select/drag upwards or select single line - else: - cursor.movePosition(cursor.NextBlock) - for n in range(abs(move_n_blocks) + 1): - cursor.movePosition(cursor.PreviousBlock, cursor.KeepAnchor) - - # Account for last line case - if linenumber_released == self.blockCount(): - cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor) - else: - cursor.movePosition(cursor.StartOfBlock, cursor.KeepAnchor) - - self.setTextCursor(cursor) - - # ----- Code bookmarks - def add_bookmark(self, slot_num, line=None, column=None): - """Add bookmark to current block's userData.""" - if line is None: - # Triggered by shortcut, else by spyder start - line, column = self.get_cursor_line_column() - block = self.document().findBlockByNumber(line) - data = block.userData() - if not data: - data = BlockUserData(self) - if slot_num not in data.bookmarks: - data.bookmarks.append((slot_num, column)) - block.setUserData(data) - self._bookmarks_blocks[id(block)] = block - self.sig_bookmarks_changed.emit() - - def get_bookmarks(self): - """Get bookmarks by going over all blocks.""" - bookmarks = {} - pruned_bookmarks_blocks = {} - for block_id in self._bookmarks_blocks: - block = self._bookmarks_blocks[block_id] - if block.isValid(): - data = block.userData() - if data and data.bookmarks: - pruned_bookmarks_blocks[block_id] = block - line_number = block.blockNumber() - for slot_num, column in data.bookmarks: - bookmarks[slot_num] = [line_number, column] - self._bookmarks_blocks = pruned_bookmarks_blocks - return bookmarks - - def clear_bookmarks(self): - """Clear bookmarks for all blocks.""" - self.bookmarks = {} - for data in self.blockuserdata_list(): - data.bookmarks = [] - self._bookmarks_blocks = {} - - def set_bookmarks(self, bookmarks): - """Set bookmarks when opening file.""" - self.clear_bookmarks() - for slot_num, bookmark in bookmarks.items(): - self.add_bookmark(slot_num, bookmark[1], bookmark[2]) - - def update_bookmarks(self): - """Emit signal to update bookmarks.""" - self.sig_bookmarks_changed.emit() - - # -----Code introspection - def show_completion_object_info(self, name, signature): - """Trigger show completion info in Help Pane.""" - force = True - self.sig_show_completion_object_info.emit(name, signature, force) - - def show_object_info(self, position): - """Trigger a calltip""" - self.sig_show_object_info.emit(position) - - # -----blank spaces - def set_blanks_enabled(self, state): - """Toggle blanks visibility""" - self.blanks_enabled = state - option = self.document().defaultTextOption() - option.setFlags(option.flags() | \ - QTextOption.AddSpaceForLineAndParagraphSeparators) - if self.blanks_enabled: - option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) - else: - option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) - self.document().setDefaultTextOption(option) - # Rehighlight to make the spaces less apparent. - self.rehighlight() - - def set_scrollpastend_enabled(self, state): - """ - Allow user to scroll past the end of the document to have the last - line on top of the screen - """ - self.scrollpastend_enabled = state - self.setCenterOnScroll(state) - self.setDocument(self.document()) - - def resizeEvent(self, event): - """Reimplemented Qt method to handle p resizing""" - TextEditBaseWidget.resizeEvent(self, event) - self.panels.resize() - - def showEvent(self, event): - """Overrides showEvent to update the viewport margins.""" - super(CodeEditor, self).showEvent(event) - self.panels.refresh() - - #-----Misc. - def _apply_highlighter_color_scheme(self): - """Apply color scheme from syntax highlighter to the editor""" - hl = self.highlighter - if hl is not None: - self.set_palette(background=hl.get_background_color(), - foreground=hl.get_foreground_color()) - self.currentline_color = hl.get_currentline_color() - self.currentcell_color = hl.get_currentcell_color() - self.occurrence_color = hl.get_occurrence_color() - self.ctrl_click_color = hl.get_ctrlclick_color() - self.sideareas_color = hl.get_sideareas_color() - self.comment_color = hl.get_comment_color() - self.normal_color = hl.get_foreground_color() - self.matched_p_color = hl.get_matched_p_color() - self.unmatched_p_color = hl.get_unmatched_p_color() - - self.edge_line.update_color() - self.indent_guides.update_color() - - self.sig_theme_colors_changed.emit( - {'occurrence': self.occurrence_color}) - - def apply_highlighter_settings(self, color_scheme=None): - """Apply syntax highlighter settings""" - if self.highlighter is not None: - # Updating highlighter settings (font and color scheme) - self.highlighter.setup_formats(self.font()) - if color_scheme is not None: - self.set_color_scheme(color_scheme) - else: - self._rehighlight_timer.start() - - def set_font(self, font, color_scheme=None): - """Set font""" - # Note: why using this method to set color scheme instead of - # 'set_color_scheme'? To avoid rehighlighting the document twice - # at startup. - if color_scheme is not None: - self.color_scheme = color_scheme - self.setFont(font) - self.panels.refresh() - self.apply_highlighter_settings(color_scheme) - - def set_color_scheme(self, color_scheme): - """Set color scheme for syntax highlighting""" - self.color_scheme = color_scheme - if self.highlighter is not None: - # this calls self.highlighter.rehighlight() - self.highlighter.set_color_scheme(color_scheme) - self._apply_highlighter_color_scheme() - if self.highlight_current_cell_enabled: - self.highlight_current_cell() - else: - self.unhighlight_current_cell() - if self.highlight_current_line_enabled: - self.highlight_current_line() - else: - self.unhighlight_current_line() - - def set_text(self, text): - """Set the text of the editor""" - self.setPlainText(text) - self.set_eol_chars(text=text) - - if (isinstance(self.highlighter, sh.PygmentsSH) - and not running_under_pytest()): - self.highlighter.make_charlist() - - def set_text_from_file(self, filename, language=None): - """Set the text of the editor from file *fname*""" - self.filename = filename - text, _enc = encoding.read(filename) - if language is None: - language = get_file_language(filename, text) - self.set_language(language, filename) - self.set_text(text) - - def append(self, text): - """Append text to the end of the text widget""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - cursor.insertText(text) - - def adjust_indentation(self, line, indent_adjustment): - """Adjust indentation.""" - if indent_adjustment == 0 or line == "": - return line - using_spaces = self.indent_chars != '\t' - - if indent_adjustment > 0: - if using_spaces: - return ' ' * indent_adjustment + line - else: - return ( - self.indent_chars - * (indent_adjustment // self.tab_stop_width_spaces) - + line) - - max_indent = self.get_line_indentation(line) - indent_adjustment = min(max_indent, -indent_adjustment) - - indent_adjustment = (indent_adjustment if using_spaces else - indent_adjustment // self.tab_stop_width_spaces) - - return line[indent_adjustment:] - - @Slot() - def paste(self): - """ - Insert text or file/folder path copied from clipboard. - - Reimplement QPlainTextEdit's method to fix the following issue: - on Windows, pasted text has only 'LF' EOL chars even if the original - text has 'CRLF' EOL chars. - The function also changes the clipboard data if they are copied as - files/folders but does not change normal text data except if they are - multiple lines. Since we are changing clipboard data we cannot use - paste, which directly pastes from clipboard instead we use - insertPlainText and pass the formatted/changed text without modifying - clipboard content. - """ - clipboard = QApplication.clipboard() - text = to_text_string(clipboard.text()) - - if clipboard.mimeData().hasUrls(): - # Have copied file and folder urls pasted as text paths. - # See spyder-ide/spyder#8644 for details. - urls = clipboard.mimeData().urls() - if all([url.isLocalFile() for url in urls]): - if len(urls) > 1: - sep_chars = ',' + self.get_line_separator() - text = sep_chars.join('"' + url.toLocalFile(). - replace(osp.os.sep, '/') - + '"' for url in urls) - else: - # The `urls` list can be empty, so we need to check that - # before proceeding. - # Fixes spyder-ide/spyder#17521 - if urls: - text = urls[0].toLocalFile().replace(osp.os.sep, '/') - - eol_chars = self.get_line_separator() - if len(text.splitlines()) > 1: - text = eol_chars.join((text + eol_chars).splitlines()) - - # Align multiline text based on first line - cursor = self.textCursor() - cursor.beginEditBlock() - cursor.removeSelectedText() - cursor.setPosition(cursor.selectionStart()) - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) - preceding_text = cursor.selectedText() - first_line_selected, *remaining_lines = (text + eol_chars).splitlines() - first_line = preceding_text + first_line_selected - - first_line_adjustment = 0 - - # Dedent if automatic indentation makes code invalid - # Minimum indentation = max of current and paster indentation - if (self.is_python_like() and len(preceding_text.strip()) == 0 - and len(first_line.strip()) > 0): - # Correct indentation - desired_indent = self.find_indentation() - if desired_indent: - # minimum indentation is either the current indentation - # or the indentation of the paster text - desired_indent = max( - desired_indent, - self.get_line_indentation(first_line_selected), - self.get_line_indentation(preceding_text)) - first_line_adjustment = ( - desired_indent - self.get_line_indentation(first_line)) - # Only dedent, don't indent - first_line_adjustment = min(first_line_adjustment, 0) - # Only dedent, don't indent - first_line = self.adjust_indentation( - first_line, first_line_adjustment) - - # Fix indentation of multiline text based on first line - if len(remaining_lines) > 0 and len(first_line.strip()) > 0: - lines_adjustment = first_line_adjustment - lines_adjustment += CLIPBOARD_HELPER.remaining_lines_adjustment( - preceding_text) - - # Make sure the code is not flattened - indentations = [ - self.get_line_indentation(line) - for line in remaining_lines if line.strip() != ""] - if indentations: - max_dedent = min(indentations) - lines_adjustment = max(lines_adjustment, -max_dedent) - # Get new text - remaining_lines = [ - self.adjust_indentation(line, lines_adjustment) - for line in remaining_lines] - - text = eol_chars.join([first_line, *remaining_lines]) - - self.skip_rstrip = True - self.sig_will_paste_text.emit(text) - cursor.removeSelectedText() - cursor.insertText(text) - cursor.endEditBlock() - self.sig_text_was_inserted.emit() - - self.skip_rstrip = False - - def _save_clipboard_indentation(self): - """ - Save the indentation corresponding to the clipboard data. - - Must be called right after copying. - """ - cursor = self.textCursor() - cursor.setPosition(cursor.selectionStart()) - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) - preceding_text = cursor.selectedText() - CLIPBOARD_HELPER.save_indentation( - preceding_text, self.tab_stop_width_spaces) - - @Slot() - def cut(self): - """Reimplement cut to signal listeners about changes on the text.""" - has_selected_text = self.has_selected_text() - if not has_selected_text: - return - start, end = self.get_selection_start_end() - self.sig_will_remove_selection.emit(start, end) - TextEditBaseWidget.cut(self) - self._save_clipboard_indentation() - self.sig_text_was_inserted.emit() - - @Slot() - def copy(self): - """Reimplement copy to save indentation.""" - TextEditBaseWidget.copy(self) - self._save_clipboard_indentation() - - @Slot() - def undo(self): - """Reimplement undo to decrease text version number.""" - if self.document().isUndoAvailable(): - self.text_version -= 1 - self.skip_rstrip = True - self.is_undoing = True - TextEditBaseWidget.undo(self) - self.sig_undo.emit() - self.sig_text_was_inserted.emit() - self.is_undoing = False - self.skip_rstrip = False - - @Slot() - def redo(self): - """Reimplement redo to increase text version number.""" - if self.document().isRedoAvailable(): - self.text_version += 1 - self.skip_rstrip = True - self.is_redoing = True - TextEditBaseWidget.redo(self) - self.sig_redo.emit() - self.sig_text_was_inserted.emit() - self.is_redoing = False - self.skip_rstrip = False - - # ========================================================================= - # High-level editor features - # ========================================================================= - @Slot() - def center_cursor_on_next_focus(self): - """QPlainTextEdit's "centerCursor" requires the widget to be visible""" - self.centerCursor() - self.focus_in.disconnect(self.center_cursor_on_next_focus) - - def go_to_line(self, line, start_column=0, end_column=0, word=''): - """Go to line number *line* and eventually highlight it""" - self.text_helper.goto_line(line, column=start_column, - end_column=end_column, move=True, - word=word) - - def exec_gotolinedialog(self): - """Execute the GoToLineDialog dialog box""" - dlg = GoToLineDialog(self) - if dlg.exec_(): - self.go_to_line(dlg.get_line_number()) - - def hide_tooltip(self): - """ - Hide the tooltip widget. - - The tooltip widget is a special QLabel that looks like a tooltip, - this method is here so it can be hidden as necessary. For example, - when the user leaves the Linenumber area when hovering over lint - warnings and errors. - """ - self._timer_mouse_moving.stop() - self._last_hover_word = None - self.clear_extra_selections('code_analysis_highlight') - if self.tooltip_widget.isVisible(): - self.tooltip_widget.hide() - - def _set_completions_hint_idle(self): - self._completions_hint_idle = True - self.completion_widget.trigger_completion_hint() - - # --- Hint for completions - def show_hint_for_completion(self, word, documentation, at_point): - """Show hint for completion element.""" - if self.completions_hint and self._completions_hint_idle: - documentation = documentation.replace(u'\xa0', ' ') - completion_doc = {'name': word, - 'signature': documentation} - - if documentation and len(documentation) > 0: - self.show_hint( - documentation, - inspect_word=word, - at_point=at_point, - completion_doc=completion_doc, - max_lines=self._DEFAULT_MAX_LINES, - max_width=self._DEFAULT_COMPLETION_HINT_MAX_WIDTH) - self.tooltip_widget.move(at_point) - return - self.hide_tooltip() - - def update_decorations(self): - """Update decorations on the visible portion of the screen.""" - if self.underline_errors_enabled: - self.underline_errors() - - # This is required to update decorations whether there are or not - # underline errors in the visible portion of the screen. - # See spyder-ide/spyder#14268. - self.decorations.update() - - def show_code_analysis_results(self, line_number, block_data): - """Show warning/error messages.""" - # Diagnostic severity - icons = { - DiagnosticSeverity.ERROR: 'error', - DiagnosticSeverity.WARNING: 'warning', - DiagnosticSeverity.INFORMATION: 'information', - DiagnosticSeverity.HINT: 'hint', - } - - code_analysis = block_data.code_analysis - - # Size must be adapted from font - fm = self.fontMetrics() - size = fm.height() - template = ( - ' ' - '{} ({} {})' - ) - - msglist = [] - max_lines_msglist = 25 - sorted_code_analysis = sorted(code_analysis, key=lambda i: i[2]) - for src, code, sev, msg in sorted_code_analysis: - if src == 'pylint' and '[' in msg and ']' in msg: - # Remove extra redundant info from pylint messages - msg = msg.split(']')[-1] - - msg = msg.strip() - # Avoid messing TODO, FIXME - # Prevent error if msg only has one element - if len(msg) > 1: - msg = msg[0].upper() + msg[1:] - - # Get individual lines following paragraph format and handle - # symbols like '<' and '>' to not mess with br tags - msg = msg.replace('<', '<').replace('>', '>') - paragraphs = msg.splitlines() - new_paragraphs = [] - long_paragraphs = 0 - lines_per_message = 6 - for paragraph in paragraphs: - new_paragraph = textwrap.wrap( - paragraph, - width=self._DEFAULT_MAX_HINT_WIDTH) - if lines_per_message > 2: - if len(new_paragraph) > 1: - new_paragraph = '
'.join(new_paragraph[:2]) + '...' - long_paragraphs += 1 - lines_per_message -= 2 - else: - new_paragraph = '
'.join(new_paragraph) - lines_per_message -= 1 - new_paragraphs.append(new_paragraph) - - if len(new_paragraphs) > 1: - # Define max lines taking into account that in the same - # tooltip you can find multiple warnings and messages - # and each one can have multiple lines - if long_paragraphs != 0: - max_lines = 3 - max_lines_msglist -= max_lines * 2 - else: - max_lines = 5 - max_lines_msglist -= max_lines - msg = '
'.join(new_paragraphs[:max_lines]) + '
' - else: - msg = '
'.join(new_paragraphs) - - base_64 = ima.base64_from_icon(icons[sev], size, size) - if max_lines_msglist >= 0: - msglist.append(template.format(base_64, msg, src, - code, size=size)) - - if msglist: - self.show_tooltip( - title=_("Code analysis"), - text='\n'.join(msglist), - title_color=QStylePalette.COLOR_ACCENT_4, - at_line=line_number, - with_html_format=True - ) - self.highlight_line_warning(block_data) - - def highlight_line_warning(self, block_data): - """Highlight errors and warnings in this editor.""" - self.clear_extra_selections('code_analysis_highlight') - self.highlight_selection('code_analysis_highlight', - block_data._selection(), - background_color=block_data.color) - self.linenumberarea.update() - - def get_current_warnings(self): - """ - Get all warnings for the current editor and return - a list with the message and line number. - """ - block = self.document().firstBlock() - line_count = self.document().blockCount() - warnings = [] - while True: - data = block.userData() - if data and data.code_analysis: - for warning in data.code_analysis: - warnings.append([warning[-1], block.blockNumber() + 1]) - # See spyder-ide/spyder#9924 - if block.blockNumber() + 1 == line_count: - break - block = block.next() - return warnings - - def go_to_next_warning(self): - """ - Go to next code warning message and return new cursor position. - """ - block = self.textCursor().block() - line_count = self.document().blockCount() - for __ in range(line_count): - line_number = block.blockNumber() + 1 - if line_number < line_count: - block = block.next() - else: - block = self.document().firstBlock() - - data = block.userData() - if data and data.code_analysis: - line_number = block.blockNumber() + 1 - self.go_to_line(line_number) - self.show_code_analysis_results(line_number, data) - return self.get_position('cursor') - - def go_to_previous_warning(self): - """ - Go to previous code warning message and return new cursor position. - """ - block = self.textCursor().block() - line_count = self.document().blockCount() - for __ in range(line_count): - line_number = block.blockNumber() + 1 - if line_number > 1: - block = block.previous() - else: - block = self.document().lastBlock() - - data = block.userData() - if data and data.code_analysis: - line_number = block.blockNumber() + 1 - self.go_to_line(line_number) - self.show_code_analysis_results(line_number, data) - return self.get_position('cursor') - - def cell_list(self): - """Get the outline explorer data for all cells.""" - for oedata in self.outlineexplorer_data_list(): - if oedata.def_type == OED.CELL: - yield oedata - - def get_cell_code(self, cell): - """ - Get cell code for a given cell. - - If the cell doesn't exist, raises an exception - """ - selected_block = None - if is_string(cell): - for oedata in self.cell_list(): - if oedata.def_name == cell: - selected_block = oedata.block - break - else: - if cell == 0: - selected_block = self.document().firstBlock() - else: - cell_list = list(self.cell_list()) - if cell <= len(cell_list): - selected_block = cell_list[cell - 1].block - - if not selected_block: - raise RuntimeError("Cell {} not found.".format(repr(cell))) - - cursor = QTextCursor(selected_block) - cell_code, _ = self.get_cell_as_executable_code(cursor) - return cell_code - - def get_cell_count(self): - """Get number of cells in document.""" - return 1 + len(list(self.cell_list())) - - #------Tasks management - def go_to_next_todo(self): - """Go to next todo and return new cursor position""" - block = self.textCursor().block() - line_count = self.document().blockCount() - while True: - if block.blockNumber()+1 < line_count: - block = block.next() - else: - block = self.document().firstBlock() - data = block.userData() - if data and data.todo: - break - line_number = block.blockNumber()+1 - self.go_to_line(line_number) - self.show_tooltip( - title=_("To do"), - text=data.todo, - title_color=QStylePalette.COLOR_ACCENT_4, - at_line=line_number, - ) - - return self.get_position('cursor') - - def process_todo(self, todo_results): - """Process todo finder results""" - for data in self.blockuserdata_list(): - data.todo = '' - - for message, line_number in todo_results: - block = self.document().findBlockByNumber(line_number - 1) - data = block.userData() - if not data: - data = BlockUserData(self) - data.todo = message - block.setUserData(data) - self.sig_flags_changed.emit() - - #------Comments/Indentation - def add_prefix(self, prefix): - """Add prefix to current line or selected line(s)""" - cursor = self.textCursor() - if self.has_selected_text(): - # Add prefix to selected line(s) - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - - # Let's see if selection begins at a block start - first_pos = min([start_pos, end_pos]) - first_cursor = self.textCursor() - first_cursor.setPosition(first_pos) - - cursor.beginEditBlock() - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock) - if cursor.position() < start_pos: - cursor.setPosition(start_pos) - move_number = self.__spaces_for_prefix() - - while cursor.position() >= start_pos: - cursor.movePosition(QTextCursor.StartOfBlock) - line_text = to_text_string(cursor.block().text()) - if (self.get_character(cursor.position()) == ' ' - and '#' in prefix and not line_text.isspace() - or (not line_text.startswith(' ') - and line_text != '')): - cursor.movePosition(QTextCursor.Right, - QTextCursor.MoveAnchor, - move_number) - cursor.insertText(prefix) - elif '#' not in prefix: - cursor.insertText(prefix) - if cursor.blockNumber() == 0: - # Avoid infinite loop when indenting the very first line - break - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.endEditBlock() - else: - # Add prefix to current line - cursor.beginEditBlock() - cursor.movePosition(QTextCursor.StartOfBlock) - if self.get_character(cursor.position()) == ' ' and '#' in prefix: - cursor.movePosition(QTextCursor.NextWord) - cursor.insertText(prefix) - cursor.endEditBlock() - - def __spaces_for_prefix(self): - """Find the less indented level of text.""" - cursor = self.textCursor() - if self.has_selected_text(): - # Add prefix to selected line(s) - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - - # Let's see if selection begins at a block start - first_pos = min([start_pos, end_pos]) - first_cursor = self.textCursor() - first_cursor.setPosition(first_pos) - - cursor.beginEditBlock() - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock) - if cursor.position() < start_pos: - cursor.setPosition(start_pos) - - number_spaces = -1 - while cursor.position() >= start_pos: - cursor.movePosition(QTextCursor.StartOfBlock) - line_text = to_text_string(cursor.block().text()) - start_with_space = line_text.startswith(' ') - left_number_spaces = self.__number_of_spaces(line_text) - if not start_with_space: - left_number_spaces = 0 - if ((number_spaces == -1 - or number_spaces > left_number_spaces) - and not line_text.isspace() and line_text != ''): - number_spaces = left_number_spaces - if cursor.blockNumber() == 0: - # Avoid infinite loop when indenting the very first line - break - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.movePosition(QTextCursor.EndOfBlock) - cursor.endEditBlock() - return number_spaces - - def remove_suffix(self, suffix): - """ - Remove suffix from current line (there should not be any selection) - """ - cursor = self.textCursor() - cursor.setPosition(cursor.position() - qstring_length(suffix), - QTextCursor.KeepAnchor) - if to_text_string(cursor.selectedText()) == suffix: - cursor.removeSelectedText() - - def remove_prefix(self, prefix): - """Remove prefix from current line or selected line(s)""" - cursor = self.textCursor() - if self.has_selected_text(): - # Remove prefix from selected line(s) - start_pos, end_pos = sorted([cursor.selectionStart(), - cursor.selectionEnd()]) - cursor.setPosition(start_pos) - if not cursor.atBlockStart(): - cursor.movePosition(QTextCursor.StartOfBlock) - start_pos = cursor.position() - cursor.beginEditBlock() - cursor.setPosition(end_pos) - # Check if end_pos is at the start of a block: if so, starting - # changes from the previous block - if cursor.atBlockStart(): - cursor.movePosition(QTextCursor.PreviousBlock) - if cursor.position() < start_pos: - cursor.setPosition(start_pos) - - cursor.movePosition(QTextCursor.StartOfBlock) - old_pos = None - while cursor.position() >= start_pos: - new_pos = cursor.position() - if old_pos == new_pos: - break - else: - old_pos = new_pos - line_text = to_text_string(cursor.block().text()) - self.__remove_prefix(prefix, cursor, line_text) - cursor.movePosition(QTextCursor.PreviousBlock) - cursor.endEditBlock() - else: - # Remove prefix from current line - cursor.movePosition(QTextCursor.StartOfBlock) - line_text = to_text_string(cursor.block().text()) - self.__remove_prefix(prefix, cursor, line_text) - - def __remove_prefix(self, prefix, cursor, line_text): - """Handle the removal of the prefix for a single line.""" - start_with_space = line_text.startswith(' ') - if start_with_space: - left_spaces = self.__even_number_of_spaces(line_text) - else: - left_spaces = False - if start_with_space: - right_number_spaces = self.__number_of_spaces(line_text, group=1) - else: - right_number_spaces = self.__number_of_spaces(line_text) - # Handle prefix remove for comments with spaces - if (prefix.strip() and line_text.lstrip().startswith(prefix + ' ') - or line_text.startswith(prefix + ' ') and '#' in prefix): - cursor.movePosition(QTextCursor.Right, - QTextCursor.MoveAnchor, - line_text.find(prefix)) - if (right_number_spaces == 1 - and (left_spaces or not start_with_space) - or (not start_with_space and right_number_spaces % 2 != 0) - or (left_spaces and right_number_spaces % 2 != 0)): - # Handle inserted '# ' with the count of the number of spaces - # at the right and left of the prefix. - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor, len(prefix + ' ')) - else: - # Handle manual insertion of '#' - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor, len(prefix)) - cursor.removeSelectedText() - # Check for prefix without space - elif (prefix.strip() and line_text.lstrip().startswith(prefix) - or line_text.startswith(prefix)): - cursor.movePosition(QTextCursor.Right, - QTextCursor.MoveAnchor, - line_text.find(prefix)) - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor, len(prefix)) - cursor.removeSelectedText() - - def __even_number_of_spaces(self, line_text, group=0): - """ - Get if there is a correct indentation from a group of spaces of a line. - """ - spaces = re.findall(r'\s+', line_text) - if len(spaces) - 1 >= group: - return len(spaces[group]) % len(self.indent_chars) == 0 - - def __number_of_spaces(self, line_text, group=0): - """Get the number of spaces from a group of spaces in a line.""" - spaces = re.findall(r'\s+', line_text) - if len(spaces) - 1 >= group: - return len(spaces[group]) - - def __get_brackets(self, line_text, closing_brackets=[]): - """ - Return unmatched opening brackets and left-over closing brackets. - - (str, []) -> ([(pos, bracket)], [bracket], comment_pos) - - Iterate through line_text to find unmatched brackets. - - Returns three objects as a tuple: - 1) bracket_stack: - a list of tuples of pos and char of each unmatched opening bracket - 2) closing brackets: - this line's unmatched closing brackets + arg closing_brackets. - If this line ad no closing brackets, arg closing_brackets might - be matched with previously unmatched opening brackets in this line. - 3) Pos at which a # comment begins. -1 if it doesn't.' - """ - # Remove inline comment and check brackets - bracket_stack = [] # list containing this lines unmatched opening - # same deal, for closing though. Ignore if bracket stack not empty, - # since they are mismatched in that case. - bracket_unmatched_closing = [] - comment_pos = -1 - deactivate = None - escaped = False - pos, c = None, None - for pos, c in enumerate(line_text): - # Handle '\' inside strings - if escaped: - escaped = False - # Handle strings - elif deactivate: - if c == deactivate: - deactivate = None - elif c == "\\": - escaped = True - elif c in ["'", '"']: - deactivate = c - # Handle comments - elif c == "#": - comment_pos = pos - break - # Handle brackets - elif c in ('(', '[', '{'): - bracket_stack.append((pos, c)) - elif c in (')', ']', '}'): - if bracket_stack and bracket_stack[-1][1] == \ - {')': '(', ']': '[', '}': '{'}[c]: - bracket_stack.pop() - else: - bracket_unmatched_closing.append(c) - del pos, deactivate, escaped - # If no closing brackets are left over from this line, - # check the ones from previous iterations' prevlines - if not bracket_unmatched_closing: - for c in list(closing_brackets): - if bracket_stack and bracket_stack[-1][1] == \ - {')': '(', ']': '[', '}': '{'}[c]: - bracket_stack.pop() - closing_brackets.remove(c) - else: - break - del c - closing_brackets = bracket_unmatched_closing + closing_brackets - return (bracket_stack, closing_brackets, comment_pos) - - def fix_indent(self, *args, **kwargs): - """Indent line according to the preferences""" - if self.is_python_like(): - ret = self.fix_indent_smart(*args, **kwargs) - else: - ret = self.simple_indentation(*args, **kwargs) - return ret - - def simple_indentation(self, forward=True, **kwargs): - """ - Simply preserve the indentation-level of the previous line. - """ - cursor = self.textCursor() - block_nb = cursor.blockNumber() - prev_block = self.document().findBlockByNumber(block_nb - 1) - prevline = to_text_string(prev_block.text()) - - indentation = re.match(r"\s*", prevline).group() - # Unident - if not forward: - indentation = indentation[len(self.indent_chars):] - - cursor.insertText(indentation) - return False # simple indentation don't fix indentation - - def find_indentation(self, forward=True, comment_or_string=False, - cur_indent=None): - """ - Find indentation (Python only, no text selection) - - forward=True: fix indent only if text is not enough indented - (otherwise force indent) - forward=False: fix indent only if text is too much indented - (otherwise force unindent) - - comment_or_string: Do not adjust indent level for - unmatched opening brackets and keywords - - max_blank_lines: maximum number of blank lines to search before giving - up - - cur_indent: current indent. This is the indent before we started - processing. E.g. when returning, indent before rstrip. - - Returns the indentation for the current line - - Assumes self.is_python_like() to return True - """ - cursor = self.textCursor() - block_nb = cursor.blockNumber() - # find the line that contains our scope - line_in_block = False - visual_indent = False - add_indent = 0 # How many levels of indent to add - prevline = None - prevtext = "" - empty_lines = True - - closing_brackets = [] - for prevline in range(block_nb - 1, -1, -1): - cursor.movePosition(QTextCursor.PreviousBlock) - prevtext = to_text_string(cursor.block().text()).rstrip() - - bracket_stack, closing_brackets, comment_pos = self.__get_brackets( - prevtext, closing_brackets) - - if not prevtext: - continue - - if prevtext.endswith((':', '\\')): - # Presume a block was started - line_in_block = True # add one level of indent to correct_indent - # Does this variable actually do *anything* of relevance? - # comment_or_string = True - - if bracket_stack or not closing_brackets: - break - - if prevtext.strip() != '': - empty_lines = False - - if empty_lines and prevline is not None and prevline < block_nb - 2: - # The previous line is too far, ignore - prevtext = '' - prevline = block_nb - 2 - line_in_block = False - - # splits of prevtext happen a few times. Let's just do it once - words = re.split(r'[\s\(\[\{\}\]\)]', prevtext.lstrip()) - - if line_in_block: - add_indent += 1 - - if prevtext and not comment_or_string: - if bracket_stack: - # Hanging indent - if prevtext.endswith(('(', '[', '{')): - add_indent += 1 - if words[0] in ('class', 'def', 'elif', 'except', 'for', - 'if', 'while', 'with'): - add_indent += 1 - elif not ( # I'm not sure this block should exist here - ( - self.tab_stop_width_spaces - if self.indent_chars == '\t' else - len(self.indent_chars) - ) * 2 < len(prevtext)): - visual_indent = True - else: - # There's stuff after unmatched opening brackets - visual_indent = True - elif (words[-1] in ('continue', 'break', 'pass',) - or words[0] == "return" and not line_in_block - ): - add_indent -= 1 - - if prevline: - prevline_indent = self.get_block_indentation(prevline) - else: - prevline_indent = 0 - - if visual_indent: # can only be true if bracket_stack - correct_indent = bracket_stack[-1][0] + 1 - elif add_indent: - # Indent - if self.indent_chars == '\t': - correct_indent = prevline_indent + self.tab_stop_width_spaces * add_indent - else: - correct_indent = prevline_indent + len(self.indent_chars) * add_indent - else: - correct_indent = prevline_indent - - # TODO untangle this block - if prevline and not bracket_stack and not prevtext.endswith(':'): - if forward: - # Keep indentation of previous line - ref_line = block_nb - 1 - else: - # Find indentation context - ref_line = prevline - if cur_indent is None: - cur_indent = self.get_block_indentation(ref_line) - is_blank = not self.get_text_line(ref_line).strip() - trailing_text = self.get_text_line(block_nb).strip() - # If brackets are matched and no block gets opened - # Match the above line's indent and nudge to the next multiple of 4 - - if cur_indent < prevline_indent and (trailing_text or is_blank): - # if line directly above is blank or there is text after cursor - # Ceiling division - correct_indent = -(-cur_indent // len(self.indent_chars)) * \ - len(self.indent_chars) - return correct_indent - - def fix_indent_smart(self, forward=True, comment_or_string=False, - cur_indent=None): - """ - Fix indentation (Python only, no text selection) - - forward=True: fix indent only if text is not enough indented - (otherwise force indent) - forward=False: fix indent only if text is too much indented - (otherwise force unindent) - - comment_or_string: Do not adjust indent level for - unmatched opening brackets and keywords - - max_blank_lines: maximum number of blank lines to search before giving - up - - cur_indent: current indent. This is the indent before we started - processing. E.g. when returning, indent before rstrip. - - Returns True if indent needed to be fixed - - Assumes self.is_python_like() to return True - """ - cursor = self.textCursor() - block_nb = cursor.blockNumber() - indent = self.get_block_indentation(block_nb) - - correct_indent = self.find_indentation( - forward, comment_or_string, cur_indent) - - if correct_indent >= 0 and not ( - indent == correct_indent or - forward and indent > correct_indent or - not forward and indent < correct_indent - ): - # Insert the determined indent - cursor = self.textCursor() - cursor.movePosition(QTextCursor.StartOfBlock) - if self.indent_chars == '\t': - indent = indent // self.tab_stop_width_spaces - cursor.setPosition(cursor.position()+indent, QTextCursor.KeepAnchor) - cursor.removeSelectedText() - if self.indent_chars == '\t': - indent_text = ( - '\t' * (correct_indent // self.tab_stop_width_spaces) + - ' ' * (correct_indent % self.tab_stop_width_spaces) - ) - else: - indent_text = ' '*correct_indent - cursor.insertText(indent_text) - return True - return False - - @Slot() - def clear_all_output(self): - """Removes all output in the ipynb format (Json only)""" - try: - nb = nbformat.reads(self.toPlainText(), as_version=4) - if nb.cells: - for cell in nb.cells: - if 'outputs' in cell: - cell['outputs'] = [] - if 'prompt_number' in cell: - cell['prompt_number'] = None - # We do the following rather than using self.setPlainText - # to benefit from QTextEdit's undo/redo feature. - self.selectAll() - self.skip_rstrip = True - self.insertPlainText(nbformat.writes(nb)) - self.skip_rstrip = False - except Exception as e: - QMessageBox.critical(self, _('Removal error'), - _("It was not possible to remove outputs from " - "this notebook. The error is:\n\n") + \ - to_text_string(e)) - return - - @Slot() - def convert_notebook(self): - """Convert an IPython notebook to a Python script in editor""" - try: - nb = nbformat.reads(self.toPlainText(), as_version=4) - script = nbexporter().from_notebook_node(nb)[0] - except Exception as e: - QMessageBox.critical(self, _('Conversion error'), - _("It was not possible to convert this " - "notebook. The error is:\n\n") + \ - to_text_string(e)) - return - self.sig_new_file.emit(script) - - def indent(self, force=False): - """ - Indent current line or selection - - force=True: indent even if cursor is not a the beginning of the line - """ - leading_text = self.get_text('sol', 'cursor') - if self.has_selected_text(): - self.add_prefix(self.indent_chars) - elif (force or not leading_text.strip() or - (self.tab_indents and self.tab_mode)): - if self.is_python_like(): - if not self.fix_indent(forward=True): - self.add_prefix(self.indent_chars) - else: - self.add_prefix(self.indent_chars) - else: - if len(self.indent_chars) > 1: - length = len(self.indent_chars) - self.insert_text(" "*(length-(len(leading_text) % length))) - else: - self.insert_text(self.indent_chars) - - def indent_or_replace(self): - """Indent or replace by 4 spaces depending on selection and tab mode""" - if (self.tab_indents and self.tab_mode) or not self.has_selected_text(): - self.indent() - else: - cursor = self.textCursor() - if (self.get_selected_text() == - to_text_string(cursor.block().text())): - self.indent() - else: - cursor1 = self.textCursor() - cursor1.setPosition(cursor.selectionStart()) - cursor2 = self.textCursor() - cursor2.setPosition(cursor.selectionEnd()) - if cursor1.blockNumber() != cursor2.blockNumber(): - self.indent() - else: - self.replace(self.indent_chars) - - def unindent(self, force=False): - """ - Unindent current line or selection - - force=True: unindent even if cursor is not a the beginning of the line - """ - if self.has_selected_text(): - if self.indent_chars == "\t": - # Tabs, remove one tab - self.remove_prefix(self.indent_chars) - else: - # Spaces - space_count = len(self.indent_chars) - leading_spaces = self.__spaces_for_prefix() - remainder = leading_spaces % space_count - if remainder: - # Get block on "space multiple grid". - # See spyder-ide/spyder#5734. - self.remove_prefix(" "*remainder) - else: - # Unindent one space multiple - self.remove_prefix(self.indent_chars) - else: - leading_text = self.get_text('sol', 'cursor') - if (force or not leading_text.strip() or - (self.tab_indents and self.tab_mode)): - if self.is_python_like(): - if not self.fix_indent(forward=False): - self.remove_prefix(self.indent_chars) - elif leading_text.endswith('\t'): - self.remove_prefix('\t') - else: - self.remove_prefix(self.indent_chars) - - @Slot() - def toggle_comment(self): - """Toggle comment on current line or selection""" - cursor = self.textCursor() - start_pos, end_pos = sorted([cursor.selectionStart(), - cursor.selectionEnd()]) - cursor.setPosition(end_pos) - last_line = cursor.block().blockNumber() - if cursor.atBlockStart() and start_pos != end_pos: - last_line -= 1 - cursor.setPosition(start_pos) - first_line = cursor.block().blockNumber() - # If the selection contains only commented lines and surrounding - # whitespace, uncomment. Otherwise, comment. - is_comment_or_whitespace = True - at_least_one_comment = False - for _line_nb in range(first_line, last_line+1): - text = to_text_string(cursor.block().text()).lstrip() - is_comment = text.startswith(self.comment_string) - is_whitespace = (text == '') - is_comment_or_whitespace *= (is_comment or is_whitespace) - if is_comment: - at_least_one_comment = True - cursor.movePosition(QTextCursor.NextBlock) - if is_comment_or_whitespace and at_least_one_comment: - self.uncomment() - else: - self.comment() - - def is_comment(self, block): - """Detect inline comments. - - Return True if the block is an inline comment. - """ - if block is None: - return False - text = to_text_string(block.text()).lstrip() - return text.startswith(self.comment_string) - - def comment(self): - """Comment current line or selection.""" - self.add_prefix(self.comment_string + ' ') - - def uncomment(self): - """Uncomment current line or selection.""" - blockcomment = self.unblockcomment() - if not blockcomment: - self.remove_prefix(self.comment_string) - - def __blockcomment_bar(self, compatibility=False): - """Handle versions of blockcomment bar for backwards compatibility.""" - # Blockcomment bar in Spyder version >= 4 - blockcomment_bar = self.comment_string + ' ' + '=' * ( - 79 - len(self.comment_string + ' ')) - if compatibility: - # Blockcomment bar in Spyder version < 4 - blockcomment_bar = self.comment_string + '=' * ( - 79 - len(self.comment_string)) - return blockcomment_bar - - def transform_to_uppercase(self): - """Change to uppercase current line or selection.""" - cursor = self.textCursor() - prev_pos = cursor.position() - selected_text = to_text_string(cursor.selectedText()) - - if len(selected_text) == 0: - prev_pos = cursor.position() - cursor.select(QTextCursor.WordUnderCursor) - selected_text = to_text_string(cursor.selectedText()) - - s = selected_text.upper() - cursor.insertText(s) - self.set_cursor_position(prev_pos) - - def transform_to_lowercase(self): - """Change to lowercase current line or selection.""" - cursor = self.textCursor() - prev_pos = cursor.position() - selected_text = to_text_string(cursor.selectedText()) - - if len(selected_text) == 0: - prev_pos = cursor.position() - cursor.select(QTextCursor.WordUnderCursor) - selected_text = to_text_string(cursor.selectedText()) - - s = selected_text.lower() - cursor.insertText(s) - self.set_cursor_position(prev_pos) - - def blockcomment(self): - """Block comment current line or selection.""" - comline = self.__blockcomment_bar() + self.get_line_separator() - cursor = self.textCursor() - if self.has_selected_text(): - self.extend_selection_to_complete_lines() - start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() - else: - start_pos = end_pos = cursor.position() - cursor.beginEditBlock() - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - while cursor.position() <= end_pos: - cursor.insertText(self.comment_string + " ") - cursor.movePosition(QTextCursor.EndOfBlock) - if cursor.atEnd(): - break - cursor.movePosition(QTextCursor.NextBlock) - end_pos += len(self.comment_string + " ") - cursor.setPosition(end_pos) - cursor.movePosition(QTextCursor.EndOfBlock) - if cursor.atEnd(): - cursor.insertText(self.get_line_separator()) - else: - cursor.movePosition(QTextCursor.NextBlock) - cursor.insertText(comline) - cursor.setPosition(start_pos) - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.insertText(comline) - cursor.endEditBlock() - - def unblockcomment(self): - """Un-block comment current line or selection.""" - # Needed for backward compatibility with Spyder previous blockcomments. - # See spyder-ide/spyder#2845. - unblockcomment = self.__unblockcomment() - if not unblockcomment: - unblockcomment = self.__unblockcomment(compatibility=True) - else: - return unblockcomment - - def __unblockcomment(self, compatibility=False): - """Un-block comment current line or selection helper.""" - def __is_comment_bar(cursor): - return to_text_string(cursor.block().text() - ).startswith( - self.__blockcomment_bar(compatibility=compatibility)) - # Finding first comment bar - cursor1 = self.textCursor() - if __is_comment_bar(cursor1): - return - while not __is_comment_bar(cursor1): - cursor1.movePosition(QTextCursor.PreviousBlock) - if cursor1.blockNumber() == 0: - break - if not __is_comment_bar(cursor1): - return False - - def __in_block_comment(cursor): - cs = self.comment_string - return to_text_string(cursor.block().text()).startswith(cs) - # Finding second comment bar - cursor2 = QTextCursor(cursor1) - cursor2.movePosition(QTextCursor.NextBlock) - while not __is_comment_bar(cursor2) and __in_block_comment(cursor2): - cursor2.movePosition(QTextCursor.NextBlock) - if cursor2.block() == self.document().lastBlock(): - break - if not __is_comment_bar(cursor2): - return False - # Removing block comment - cursor3 = self.textCursor() - cursor3.beginEditBlock() - cursor3.setPosition(cursor1.position()) - cursor3.movePosition(QTextCursor.NextBlock) - while cursor3.position() < cursor2.position(): - cursor3.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - if not cursor3.atBlockEnd(): - # standard commenting inserts '# ' but a trailing space on an - # empty line might be stripped. - if not compatibility: - cursor3.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - cursor3.removeSelectedText() - cursor3.movePosition(QTextCursor.NextBlock) - for cursor in (cursor2, cursor1): - cursor3.setPosition(cursor.position()) - cursor3.select(QTextCursor.BlockUnderCursor) - cursor3.removeSelectedText() - cursor3.endEditBlock() - return True - - #------Kill ring handlers - # Taken from Jupyter's QtConsole - # Copyright (c) 2001-2015, IPython Development Team - # Copyright (c) 2015-, Jupyter Development Team - def kill_line_end(self): - """Kill the text on the current line from the cursor forward""" - cursor = self.textCursor() - cursor.clearSelection() - cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor) - if not cursor.hasSelection(): - # Line deletion - cursor.movePosition(QTextCursor.NextBlock, - QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - def kill_line_start(self): - """Kill the text on the current line from the cursor backward""" - cursor = self.textCursor() - cursor.clearSelection() - cursor.movePosition(QTextCursor.StartOfBlock, - QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - def _get_word_start_cursor(self, position): - """Find the start of the word to the left of the given position. If a - sequence of non-word characters precedes the first word, skip over - them. (This emulates the behavior of bash, emacs, etc.) - """ - document = self.document() - position -= 1 - while (position and not - self.is_letter_or_number(document.characterAt(position))): - position -= 1 - while position and self.is_letter_or_number( - document.characterAt(position)): - position -= 1 - cursor = self.textCursor() - cursor.setPosition(self.next_cursor_position()) - return cursor - - def _get_word_end_cursor(self, position): - """Find the end of the word to the right of the given position. If a - sequence of non-word characters precedes the first word, skip over - them. (This emulates the behavior of bash, emacs, etc.) - """ - document = self.document() - cursor = self.textCursor() - position = cursor.position() - cursor.movePosition(QTextCursor.End) - end = cursor.position() - while (position < end and - not self.is_letter_or_number(document.characterAt(position))): - position = self.next_cursor_position(position) - while (position < end and - self.is_letter_or_number(document.characterAt(position))): - position = self.next_cursor_position(position) - cursor.setPosition(position) - return cursor - - def kill_prev_word(self): - """Kill the previous word""" - position = self.textCursor().position() - cursor = self._get_word_start_cursor(position) - cursor.setPosition(position, QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - def kill_next_word(self): - """Kill the next word""" - position = self.textCursor().position() - cursor = self._get_word_end_cursor(position) - cursor.setPosition(position, QTextCursor.KeepAnchor) - self._kill_ring.kill_cursor(cursor) - self.setTextCursor(cursor) - - #------Autoinsertion of quotes/colons - def __get_current_color(self, cursor=None): - """Get the syntax highlighting color for the current cursor position""" - if cursor is None: - cursor = self.textCursor() - - block = cursor.block() - pos = cursor.position() - block.position() # relative pos within block - layout = block.layout() - block_formats = layout.additionalFormats() - - if block_formats: - # To easily grab current format for autoinsert_colons - if cursor.atBlockEnd(): - current_format = block_formats[-1].format - else: - current_format = None - for fmt in block_formats: - if (pos >= fmt.start) and (pos < fmt.start + fmt.length): - current_format = fmt.format - if current_format is None: - return None - color = current_format.foreground().color().name() - return color - else: - return None - - def in_comment_or_string(self, cursor=None, position=None): - """Is the cursor or position inside or next to a comment or string? - - If *cursor* is None, *position* is used instead. If *position* is also - None, then the current cursor position is used. - """ - if self.highlighter: - if cursor is None: - cursor = self.textCursor() - if position: - cursor.setPosition(position) - current_color = self.__get_current_color(cursor=cursor) - - comment_color = self.highlighter.get_color_name('comment') - string_color = self.highlighter.get_color_name('string') - if (current_color == comment_color) or (current_color == string_color): - return True - else: - return False - else: - return False - - def __colon_keyword(self, text): - stmt_kws = ['def', 'for', 'if', 'while', 'with', 'class', 'elif', - 'except'] - whole_kws = ['else', 'try', 'except', 'finally'] - text = text.lstrip() - words = text.split() - if any([text == wk for wk in whole_kws]): - return True - elif len(words) < 2: - return False - elif any([words[0] == sk for sk in stmt_kws]): - return True - else: - return False - - def __forbidden_colon_end_char(self, text): - end_chars = [':', '\\', '[', '{', '(', ','] - text = text.rstrip() - if any([text.endswith(c) for c in end_chars]): - return True - else: - return False - - def __has_colon_not_in_brackets(self, text): - """ - Return whether a string has a colon which is not between brackets. - This function returns True if the given string has a colon which is - not between a pair of (round, square or curly) brackets. It assumes - that the brackets in the string are balanced. - """ - bracket_ext = self.editor_extensions.get(CloseBracketsExtension) - for pos, char in enumerate(text): - if (char == ':' and - not bracket_ext.unmatched_brackets_in_line(text[:pos])): - return True - return False - - def __has_unmatched_opening_bracket(self): - """ - Checks if there are any unmatched opening brackets before the current - cursor position. - """ - position = self.textCursor().position() - for brace in [']', ')', '}']: - match = self.find_brace_match(position, brace, forward=False) - if match is not None: - return True - return False - - def autoinsert_colons(self): - """Decide if we want to autoinsert colons""" - bracket_ext = self.editor_extensions.get(CloseBracketsExtension) - self.completion_widget.hide() - line_text = self.get_text('sol', 'cursor') - if not self.textCursor().atBlockEnd(): - return False - elif self.in_comment_or_string(): - return False - elif not self.__colon_keyword(line_text): - return False - elif self.__forbidden_colon_end_char(line_text): - return False - elif bracket_ext.unmatched_brackets_in_line(line_text): - return False - elif self.__has_colon_not_in_brackets(line_text): - return False - elif self.__has_unmatched_opening_bracket(): - return False - else: - return True - - def next_char(self): - cursor = self.textCursor() - cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor) - next_char = to_text_string(cursor.selectedText()) - return next_char - - def in_comment(self, cursor=None, position=None): - """Returns True if the given position is inside a comment. - - Parameters - ---------- - cursor : QTextCursor, optional - The position to check. - position : int, optional - The position to check if *cursor* is None. This parameter - is ignored when *cursor* is not None. - - If both *cursor* and *position* are none, then the position returned - by self.textCursor() is used instead. - """ - if self.highlighter: - if cursor is None: - cursor = self.textCursor() - if position is not None: - cursor.setPosition(position) - current_color = self.__get_current_color(cursor) - comment_color = self.highlighter.get_color_name('comment') - return (current_color == comment_color) - else: - return False - - def in_string(self, cursor=None, position=None): - """Returns True if the given position is inside a string. - - Parameters - ---------- - cursor : QTextCursor, optional - The position to check. - position : int, optional - The position to check if *cursor* is None. This parameter - is ignored when *cursor* is not None. - - If both *cursor* and *position* are none, then the position returned - by self.textCursor() is used instead. - """ - if self.highlighter: - if cursor is None: - cursor = self.textCursor() - if position is not None: - cursor.setPosition(position) - current_color = self.__get_current_color(cursor) - string_color = self.highlighter.get_color_name('string') - return (current_color == string_color) - else: - return False - - # ------ Qt Event handlers - def setup_context_menu(self): - """Setup context menu""" - self.undo_action = create_action( - self, _("Undo"), icon=ima.icon('undo'), - shortcut=CONF.get_shortcut('editor', 'undo'), triggered=self.undo) - self.redo_action = create_action( - self, _("Redo"), icon=ima.icon('redo'), - shortcut=CONF.get_shortcut('editor', 'redo'), triggered=self.redo) - self.cut_action = create_action( - self, _("Cut"), icon=ima.icon('editcut'), - shortcut=CONF.get_shortcut('editor', 'cut'), triggered=self.cut) - self.copy_action = create_action( - self, _("Copy"), icon=ima.icon('editcopy'), - shortcut=CONF.get_shortcut('editor', 'copy'), triggered=self.copy) - self.paste_action = create_action( - self, _("Paste"), icon=ima.icon('editpaste'), - shortcut=CONF.get_shortcut('editor', 'paste'), - triggered=self.paste) - selectall_action = create_action( - self, _("Select All"), icon=ima.icon('selectall'), - shortcut=CONF.get_shortcut('editor', 'select all'), - triggered=self.selectAll) - toggle_comment_action = create_action( - self, _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), - shortcut=CONF.get_shortcut('editor', 'toggle comment'), - triggered=self.toggle_comment) - self.clear_all_output_action = create_action( - self, _("Clear all ouput"), icon=ima.icon('ipython_console'), - triggered=self.clear_all_output) - self.ipynb_convert_action = create_action( - self, _("Convert to Python file"), icon=ima.icon('python'), - triggered=self.convert_notebook) - self.gotodef_action = create_action( - self, _("Go to definition"), - shortcut=CONF.get_shortcut('editor', 'go to definition'), - triggered=self.go_to_definition_from_cursor) - - self.inspect_current_object_action = create_action( - self, _("Inspect current object"), - icon=ima.icon('MessageBoxInformation'), - shortcut=CONF.get_shortcut('editor', 'inspect current object'), - triggered=self.sig_show_object_info.emit) - - # Run actions - self.run_cell_action = create_action( - self, _("Run cell"), icon=ima.icon('run_cell'), - shortcut=CONF.get_shortcut('editor', 'run cell'), - triggered=self.sig_run_cell) - self.run_cell_and_advance_action = create_action( - self, _("Run cell and advance"), icon=ima.icon('run_cell_advance'), - shortcut=CONF.get_shortcut('editor', 'run cell and advance'), - triggered=self.sig_run_cell_and_advance) - self.re_run_last_cell_action = create_action( - self, _("Re-run last cell"), - shortcut=CONF.get_shortcut('editor', 're-run last cell'), - triggered=self.sig_re_run_last_cell) - self.run_selection_action = create_action( - self, _("Run &selection or current line"), - icon=ima.icon('run_selection'), - shortcut=CONF.get_shortcut('editor', 'run selection'), - triggered=self.sig_run_selection) - self.run_to_line_action = create_action( - self, _("Run to current line"), - shortcut=CONF.get_shortcut('editor', 'run to line'), - triggered=self.sig_run_to_line) - self.run_from_line_action = create_action( - self, _("Run from current line"), - shortcut=CONF.get_shortcut('editor', 'run from line'), - triggered=self.sig_run_from_line) - self.debug_cell_action = create_action( - self, _("Debug cell"), icon=ima.icon('debug_cell'), - shortcut=CONF.get_shortcut('editor', 'debug cell'), - triggered=self.sig_debug_cell) - - # Zoom actions - zoom_in_action = create_action( - self, _("Zoom in"), icon=ima.icon('zoom_in'), - shortcut=QKeySequence(QKeySequence.ZoomIn), - triggered=self.zoom_in) - zoom_out_action = create_action( - self, _("Zoom out"), icon=ima.icon('zoom_out'), - shortcut=QKeySequence(QKeySequence.ZoomOut), - triggered=self.zoom_out) - zoom_reset_action = create_action( - self, _("Zoom reset"), shortcut=QKeySequence("Ctrl+0"), - triggered=self.zoom_reset) - - # Docstring - writer = self.writer_docstring - self.docstring_action = create_action( - self, _("Generate docstring"), - shortcut=CONF.get_shortcut('editor', 'docstring'), - triggered=writer.write_docstring_at_first_line_of_function) - - # Document formatting - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '' - ) - self.format_action = create_action( - self, - _('Format file or selection with {0}').format( - formatter.capitalize()), - shortcut=CONF.get_shortcut('editor', 'autoformatting'), - triggered=self.format_document_or_range) - - self.format_action.setEnabled(False) - - # Build menu - self.menu = QMenu(self) - actions_1 = [self.run_cell_action, self.run_cell_and_advance_action, - self.re_run_last_cell_action, self.run_selection_action, - self.run_to_line_action, self.run_from_line_action, - self.gotodef_action, self.inspect_current_object_action, - None, self.undo_action, - self.redo_action, None, self.cut_action, - self.copy_action, self.paste_action, selectall_action] - actions_2 = [None, zoom_in_action, zoom_out_action, zoom_reset_action, - None, toggle_comment_action, self.docstring_action, - self.format_action] - if nbformat is not None: - nb_actions = [self.clear_all_output_action, - self.ipynb_convert_action, None] - actions = actions_1 + nb_actions + actions_2 - add_actions(self.menu, actions) - else: - actions = actions_1 + actions_2 - add_actions(self.menu, actions) - - # Read-only context-menu - self.readonly_menu = QMenu(self) - add_actions(self.readonly_menu, - (self.copy_action, None, selectall_action, - self.gotodef_action)) - - def keyReleaseEvent(self, event): - """Override Qt method.""" - self.sig_key_released.emit(event) - key = event.key() - direction_keys = {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down} - if key in direction_keys: - self.request_cursor_event() - - # Update decorations after releasing these keys because they don't - # trigger the emission of the valueChanged signal in - # verticalScrollBar. - # See https://bugreports.qt.io/browse/QTBUG-25365 - if key in {Qt.Key_Up, Qt.Key_Down}: - self.update_decorations_timer.start() - - # This necessary to run our Pygments highlighter again after the - # user generated text changes - if event.text(): - # Stop the active timer and start it again to not run it on - # every event - if self.timer_syntax_highlight.isActive(): - self.timer_syntax_highlight.stop() - - # Adjust interval to rehighlight according to the lines - # present in the file - total_lines = self.get_line_count() - if total_lines < 1000: - self.timer_syntax_highlight.setInterval(600) - elif total_lines < 2000: - self.timer_syntax_highlight.setInterval(800) - else: - self.timer_syntax_highlight.setInterval(1000) - self.timer_syntax_highlight.start() - - self._restore_editor_cursor_and_selections() - super(CodeEditor, self).keyReleaseEvent(event) - event.ignore() - - def event(self, event): - """Qt method override.""" - if event.type() == QEvent.ShortcutOverride: - event.ignore() - return False - else: - return super(CodeEditor, self).event(event) - - def _start_completion_timer(self): - """Helper to start timer for automatic completions or handle them.""" - if not self.automatic_completions: - return - - if self.automatic_completions_after_ms > 0: - self._timer_autocomplete.start( - self.automatic_completions_after_ms) - else: - self._handle_completions() - - def _handle_keypress_event(self, event): - """Handle keypress events.""" - TextEditBaseWidget.keyPressEvent(self, event) - - # Trigger the following actions only if the event generates - # a text change. - text = to_text_string(event.text()) - if text: - # The next three lines are a workaround for a quirk of - # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. - # See spyder-ide/spyder#12663 and - # https://bugreports.qt.io/browse/QTBUG-35861 - if (parse_version(QT_VERSION) < parse_version('5.15') - or os.name == 'nt' or sys.platform == 'darwin'): - cursor = self.textCursor() - cursor.setPosition(cursor.position()) - self.setTextCursor(cursor) - self.sig_text_was_inserted.emit() - - def keyPressEvent(self, event): - """Reimplement Qt method.""" - tab_pressed = False - if self.completions_hint_after_ms > 0: - self._completions_hint_idle = False - self._timer_completions_hint.start(self.completions_hint_after_ms) - else: - self._set_completions_hint_idle() - - # Send the signal to the editor's extension. - event.ignore() - self.sig_key_pressed.emit(event) - - self.kite_call_to_action.handle_key_press(event) - - key = event.key() - text = to_text_string(event.text()) - has_selection = self.has_selected_text() - ctrl = event.modifiers() & Qt.ControlModifier - shift = event.modifiers() & Qt.ShiftModifier - - if text: - self.__clear_occurrences() - - # Only ask for completions if there's some text generated - # as part of the event. Events such as pressing Crtl, - # Shift or Alt don't generate any text. - # Fixes spyder-ide/spyder#11021 - self._start_completion_timer() - - if event.modifiers() and self.is_completion_widget_visible(): - # Hide completion widget before passing event modifiers - # since the keypress could be then a shortcut - # See spyder-ide/spyder#14806 - self.completion_widget.hide() - - if key in {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}: - self.hide_tooltip() - - if event.isAccepted(): - # The event was handled by one of the editor extension. - return - - if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, - Qt.Key_Meta, Qt.KeypadModifier]: - # The user pressed only a modifier key. - if ctrl: - pos = self.mapFromGlobal(QCursor.pos()) - pos = self.calculate_real_position_from_global(pos) - if self._handle_goto_uri_event(pos): - event.accept() - return - - if self._handle_goto_definition_event(pos): - event.accept() - return - return - - # ---- Handle hard coded and builtin actions - operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>', - '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='} - delimiters = {',', ':', ';', '@', '=', '->', '+=', '-=', '*=', '/=', - '//=', '%=', '@=', '&=', '|=', '^=', '>>=', '<<=', '**='} - - if text not in self.auto_completion_characters: - if text in operators or text in delimiters: - self.completion_widget.hide() - if key in (Qt.Key_Enter, Qt.Key_Return): - if not shift and not ctrl: - if (self.add_colons_enabled and self.is_python_like() and - self.autoinsert_colons()): - self.textCursor().beginEditBlock() - self.insert_text(':' + self.get_line_separator()) - if self.strip_trailing_spaces_on_modify: - self.fix_and_strip_indent() - else: - self.fix_indent() - self.textCursor().endEditBlock() - elif self.is_completion_widget_visible(): - self.select_completion_list() - else: - self.textCursor().beginEditBlock() - cur_indent = self.get_block_indentation( - self.textCursor().blockNumber()) - self._handle_keypress_event(event) - # Check if we're in a comment or a string at the - # current position - cmt_or_str_cursor = self.in_comment_or_string() - - # Check if the line start with a comment or string - cursor = self.textCursor() - cursor.setPosition(cursor.block().position(), - QTextCursor.KeepAnchor) - cmt_or_str_line_begin = self.in_comment_or_string( - cursor=cursor) - - # Check if we are in a comment or a string - cmt_or_str = cmt_or_str_cursor and cmt_or_str_line_begin - - if self.strip_trailing_spaces_on_modify: - self.fix_and_strip_indent( - comment_or_string=cmt_or_str, - cur_indent=cur_indent) - else: - self.fix_indent(comment_or_string=cmt_or_str, - cur_indent=cur_indent) - self.textCursor().endEditBlock() - elif key == Qt.Key_Insert and not shift and not ctrl: - self.setOverwriteMode(not self.overwriteMode()) - elif key == Qt.Key_Backspace and not shift and not ctrl: - if has_selection or not self.intelligent_backspace: - self._handle_keypress_event(event) - else: - leading_text = self.get_text('sol', 'cursor') - leading_length = len(leading_text) - trailing_spaces = leading_length - len(leading_text.rstrip()) - trailing_text = self.get_text('cursor', 'eol') - matches = ('()', '[]', '{}', '\'\'', '""') - if (not leading_text.strip() and - (leading_length > len(self.indent_chars))): - if leading_length % len(self.indent_chars) == 0: - self.unindent() - else: - self._handle_keypress_event(event) - elif trailing_spaces and not trailing_text.strip(): - self.remove_suffix(leading_text[-trailing_spaces:]) - elif (leading_text and trailing_text and - (leading_text[-1] + trailing_text[0] in matches)): - cursor = self.textCursor() - cursor.movePosition(QTextCursor.PreviousCharacter) - cursor.movePosition(QTextCursor.NextCharacter, - QTextCursor.KeepAnchor, 2) - cursor.removeSelectedText() - else: - self._handle_keypress_event(event) - elif key == Qt.Key_Home: - self.stdkey_home(shift, ctrl) - elif key == Qt.Key_End: - # See spyder-ide/spyder#495: on MacOS X, it is necessary to - # redefine this basic action which should have been implemented - # natively - self.stdkey_end(shift, ctrl) - elif (text in self.auto_completion_characters and - self.automatic_completions): - self.insert_text(text) - if text == ".": - if not self.in_comment_or_string(): - text = self.get_text('sol', 'cursor') - last_obj = getobj(text) - prev_char = text[-2] if len(text) > 1 else '' - if (prev_char in {')', ']', '}'} or - (last_obj and not last_obj.isdigit())): - # Completions should be triggered immediately when - # an autocompletion character is introduced. - self.do_completion(automatic=True) - else: - self.do_completion(automatic=True) - elif (text in self.signature_completion_characters and - not self.has_selected_text()): - self.insert_text(text) - self.request_signature() - elif (key == Qt.Key_Colon and not has_selection and - self.auto_unindent_enabled): - leading_text = self.get_text('sol', 'cursor') - if leading_text.lstrip() in ('else', 'finally'): - ind = lambda txt: len(txt) - len(txt.lstrip()) - prevtxt = (to_text_string(self.textCursor().block(). - previous().text())) - if self.language == 'Python': - prevtxt = prevtxt.rstrip() - if ind(leading_text) == ind(prevtxt): - self.unindent(force=True) - self._handle_keypress_event(event) - elif (key == Qt.Key_Space and not shift and not ctrl and not - has_selection and self.auto_unindent_enabled): - self.completion_widget.hide() - leading_text = self.get_text('sol', 'cursor') - if leading_text.lstrip() in ('elif', 'except'): - ind = lambda txt: len(txt)-len(txt.lstrip()) - prevtxt = (to_text_string(self.textCursor().block(). - previous().text())) - if self.language == 'Python': - prevtxt = prevtxt.rstrip() - if ind(leading_text) == ind(prevtxt): - self.unindent(force=True) - self._handle_keypress_event(event) - elif key == Qt.Key_Tab and not ctrl: - # Important note: can't be called with a QShortcut because - # of its singular role with respect to widget focus management - tab_pressed = True - if not has_selection and not self.tab_mode: - self.intelligent_tab() - else: - # indent the selected text - self.indent_or_replace() - elif key == Qt.Key_Backtab and not ctrl: - # Backtab, i.e. Shift+, could be treated as a QShortcut but - # there is no point since can't (see above) - tab_pressed = True - if not has_selection and not self.tab_mode: - self.intelligent_backtab() - else: - # indent the selected text - self.unindent() - event.accept() - elif not event.isAccepted(): - self._handle_keypress_event(event) - - self._last_key_pressed_text = text - self._last_pressed_key = key - if self.automatic_completions_after_ms == 0 and not tab_pressed: - self._handle_completions() - - if not event.modifiers(): - # Accept event to avoid it being handled by the parent. - # Modifiers should be passed to the parent because they - # could be shortcuts - event.accept() - - def _handle_completions(self): - """Handle on the fly completions after delay.""" - if not self.automatic_completions: - return - - cursor = self.textCursor() - pos = cursor.position() - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - key = self._last_pressed_key - if key is not None: - if key in [Qt.Key_Return, Qt.Key_Escape, - Qt.Key_Tab, Qt.Key_Backtab, Qt.Key_Space]: - self._last_pressed_key = None - return - - # Correctly handle completions when Backspace key is pressed. - # We should not show the widget if deleting a space before a word. - if key == Qt.Key_Backspace: - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.select(QTextCursor.WordUnderCursor) - prev_text = to_text_string(cursor.selectedText()) - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.setPosition(pos, QTextCursor.KeepAnchor) - prev_char = cursor.selectedText() - if prev_text == '' or prev_char in (u'\u2029', ' ', '\t'): - return - - # Text might be after a dot '.' - if text == '': - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - if text != '.': - text = '' - - # WordUnderCursor fails if the cursor is next to a right brace. - # If the returned text starts with it, we move to the left. - if text.startswith((')', ']', '}')): - cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - is_backspace = ( - self.is_completion_widget_visible() and key == Qt.Key_Backspace) - - if (len(text) >= self.automatic_completions_after_chars - and self._last_key_pressed_text or is_backspace): - # Perform completion on the fly - if not self.in_comment_or_string(): - # Variables can include numbers and underscores - if (text.isalpha() or text.isalnum() or '_' in text - or '.' in text): - self.do_completion(automatic=True) - self._last_key_pressed_text = '' - self._last_pressed_key = None - - def fix_and_strip_indent(self, *args, **kwargs): - """ - Automatically fix indent and strip previous automatic indent. - - args and kwargs are forwarded to self.fix_indent - """ - # Fix indent - cursor_before = self.textCursor().position() - # A change just occurred on the last line (return was pressed) - if cursor_before > 0: - self.last_change_position = cursor_before - 1 - self.fix_indent(*args, **kwargs) - cursor_after = self.textCursor().position() - # Remove previous spaces and update last_auto_indent - nspaces_removed = self.strip_trailing_spaces() - self.last_auto_indent = (cursor_before - nspaces_removed, - cursor_after - nspaces_removed) - - def run_pygments_highlighter(self): - """Run pygments highlighter.""" - if isinstance(self.highlighter, sh.PygmentsSH): - self.highlighter.make_charlist() - - def get_pattern_at(self, coordinates): - """ - Return key, text and cursor for pattern (if found at coordinates). - """ - return self.get_pattern_cursor_at(self.highlighter.patterns, - coordinates) - - def get_pattern_cursor_at(self, pattern, coordinates): - """ - Find pattern located at the line where the coordinate is located. - - This returns the actual match and the cursor that selects the text. - """ - cursor, key, text = None, None, None - break_loop = False - - # Check if the pattern is in line - line = self.get_line_at(coordinates) - - for match in pattern.finditer(line): - for key, value in list(match.groupdict().items()): - if value: - start, end = sh.get_span(match) - - # Get cursor selection if pattern found - cursor = self.cursorForPosition(coordinates) - cursor.movePosition(QTextCursor.StartOfBlock) - line_start_position = cursor.position() - - cursor.setPosition(line_start_position + start, - cursor.MoveAnchor) - start_rect = self.cursorRect(cursor) - cursor.setPosition(line_start_position + end, - cursor.MoveAnchor) - end_rect = self.cursorRect(cursor) - bounding_rect = start_rect.united(end_rect) - - # Check coordinates are located within the selection rect - if bounding_rect.contains(coordinates): - text = line[start:end] - cursor.setPosition(line_start_position + start, - cursor.KeepAnchor) - break_loop = True - break - - if break_loop: - break - - return key, text, cursor - - def _preprocess_file_uri(self, uri): - """Format uri to conform to absolute or relative file paths.""" - fname = uri.replace('file://', '') - if fname[-1] == '/': - fname = fname[:-1] - - # ^/ is used to denote the current project root - if fname.startswith("^/"): - if self.current_project_path is not None: - fname = osp.join(self.current_project_path, fname[2:]) - else: - fname = fname.replace("^/", "~/") - - if fname.startswith("~/"): - fname = osp.expanduser(fname) - - dirname = osp.dirname(osp.abspath(self.filename)) - if osp.isdir(dirname): - if not osp.isfile(fname): - # Maybe relative - fname = osp.join(dirname, fname) - - self.sig_file_uri_preprocessed.emit(fname) - - return fname - - def _handle_goto_definition_event(self, pos): - """Check if goto definition can be applied and apply highlight.""" - text = self.get_word_at(pos) - if text and not sourcecode.is_keyword(to_text_string(text)): - if not self.__cursor_changed: - QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) - self.__cursor_changed = True - cursor = self.cursorForPosition(pos) - cursor.select(QTextCursor.WordUnderCursor) - self.clear_extra_selections('ctrl_click') - self.highlight_selection( - 'ctrl_click', cursor, - foreground_color=self.ctrl_click_color, - underline_color=self.ctrl_click_color, - underline_style=QTextCharFormat.SingleUnderline) - return True - else: - return False - - def _handle_goto_uri_event(self, pos): - """Check if go to uri can be applied and apply highlight.""" - key, pattern_text, cursor = self.get_pattern_at(pos) - if key and pattern_text and cursor: - self._last_hover_pattern_key = key - self._last_hover_pattern_text = pattern_text - - color = self.ctrl_click_color - - if key in ['file']: - fname = self._preprocess_file_uri(pattern_text) - if not osp.isfile(fname): - color = QColor(SpyderPalette.COLOR_ERROR_2) - - self.clear_extra_selections('ctrl_click') - self.highlight_selection( - 'ctrl_click', cursor, - foreground_color=color, - underline_color=color, - underline_style=QTextCharFormat.SingleUnderline) - - if not self.__cursor_changed: - QApplication.setOverrideCursor( - QCursor(Qt.PointingHandCursor)) - self.__cursor_changed = True - - self.sig_uri_found.emit(pattern_text) - return True - else: - self._last_hover_pattern_key = key - self._last_hover_pattern_text = pattern_text - return False - - def go_to_uri_from_cursor(self, uri): - """Go to url from cursor and defined hover patterns.""" - key = self._last_hover_pattern_key - full_uri = uri - - if key in ['file']: - fname = self._preprocess_file_uri(uri) - - if osp.isfile(fname) and encoding.is_text_file(fname): - # Open in editor - self.go_to_definition.emit(fname, 0, 0) - else: - # Use external program - fname = file_uri(fname) - start_file(fname) - elif key in ['mail', 'url']: - if '@' in uri and not uri.startswith('mailto:'): - full_uri = 'mailto:' + uri - quri = QUrl(full_uri) - QDesktopServices.openUrl(quri) - elif key in ['issue']: - # Issue URI - repo_url = uri.replace('#', '/issues/') - if uri.startswith(('gh-', 'bb-', 'gl-')): - number = uri[3:] - remotes = get_git_remotes(self.filename) - remote = remotes.get('upstream', remotes.get('origin')) - if remote: - full_uri = remote_to_url(remote) + '/issues/' + number - else: - full_uri = None - elif uri.startswith('gh:') or ':' not in uri: - # Github - repo_and_issue = repo_url - if uri.startswith('gh:'): - repo_and_issue = repo_url[3:] - full_uri = 'https://github.com/' + repo_and_issue - elif uri.startswith('gl:'): - # Gitlab - full_uri = 'https://gitlab.com/' + repo_url[3:] - elif uri.startswith('bb:'): - # Bitbucket - full_uri = 'https://bitbucket.org/' + repo_url[3:] - - if full_uri: - quri = QUrl(full_uri) - QDesktopServices.openUrl(quri) - else: - QMessageBox.information( - self, - _('Information'), - _('This file is not part of a local repository or ' - 'upstream/origin remotes are not defined!'), - QMessageBox.Ok, - ) - self.hide_tooltip() - return full_uri - - def line_range(self, position): - """ - Get line range from position. - """ - if position is None: - return None - if position >= self.document().characterCount(): - return None - # Check if still on the line - cursor = self.textCursor() - cursor.setPosition(position) - line_range = (cursor.block().position(), - cursor.block().position() - + cursor.block().length() - 1) - return line_range - - def strip_trailing_spaces(self): - """ - Strip trailing spaces if needed. - - Remove trailing whitespace on leaving a non-string line containing it. - Return the number of removed spaces. - """ - if not running_under_pytest(): - if not self.hasFocus(): - # Avoid problem when using split editor - return 0 - # Update current position - current_position = self.textCursor().position() - last_position = self.last_position - self.last_position = current_position - - if self.skip_rstrip: - return 0 - - line_range = self.line_range(last_position) - if line_range is None: - # Doesn't apply - return 0 - - def pos_in_line(pos): - """Check if pos is in last line.""" - if pos is None: - return False - return line_range[0] <= pos <= line_range[1] - - if pos_in_line(current_position): - # Check if still on the line - return 0 - - # Check if end of line in string - cursor = self.textCursor() - cursor.setPosition(line_range[1]) - - if (not self.strip_trailing_spaces_on_modify - or self.in_string(cursor=cursor)): - if self.last_auto_indent is None: - return 0 - elif (self.last_auto_indent != - self.line_range(self.last_auto_indent[0])): - # line not empty - self.last_auto_indent = None - return 0 - line_range = self.last_auto_indent - self.last_auto_indent = None - elif not pos_in_line(self.last_change_position): - # Should process if pressed return or made a change on the line: - return 0 - - cursor.setPosition(line_range[0]) - cursor.setPosition(line_range[1], - QTextCursor.KeepAnchor) - # remove spaces on the right - text = cursor.selectedText() - strip = text.rstrip() - # I think all the characters we can strip are in a single QChar. - # Therefore there shouldn't be any length problems. - N_strip = qstring_length(text[len(strip):]) - - if N_strip > 0: - # Select text to remove - cursor.setPosition(line_range[1] - N_strip) - cursor.setPosition(line_range[1], - QTextCursor.KeepAnchor) - cursor.removeSelectedText() - # Correct last change position - self.last_change_position = line_range[1] - self.last_position = self.textCursor().position() - return N_strip - return 0 - - def move_line_up(self): - """Move up current line or selected text""" - self.__move_line_or_selection(after_current_line=False) - - def move_line_down(self): - """Move down current line or selected text""" - self.__move_line_or_selection(after_current_line=True) - - def __move_line_or_selection(self, after_current_line=True): - cursor = self.textCursor() - # Unfold any folded code block before moving lines up/down - folding_panel = self.panels.get('FoldingPanel') - fold_start_line = cursor.blockNumber() + 1 - block = cursor.block().next() - - if fold_start_line in folding_panel.folding_status: - fold_status = folding_panel.folding_status[fold_start_line] - if fold_status: - folding_panel.toggle_fold_trigger(block) - - if after_current_line: - # Unfold any folded region when moving lines down - fold_start_line = cursor.blockNumber() + 2 - block = cursor.block().next().next() - - if fold_start_line in folding_panel.folding_status: - fold_status = folding_panel.folding_status[fold_start_line] - if fold_status: - folding_panel.toggle_fold_trigger(block) - else: - # Unfold any folded region when moving lines up - block = cursor.block() - offset = 0 - if self.has_selected_text(): - ((selection_start, _), - (selection_end)) = self.get_selection_start_end() - if selection_end != selection_start: - offset = 1 - fold_start_line = block.blockNumber() - 1 - offset - - # Find the innermost code folding region for the current position - enclosing_regions = sorted(list( - folding_panel.current_tree[fold_start_line])) - - folding_status = folding_panel.folding_status - if len(enclosing_regions) > 0: - for region in enclosing_regions: - fold_start_line = region.begin - block = self.document().findBlockByNumber(fold_start_line) - if fold_start_line in folding_status: - fold_status = folding_status[fold_start_line] - if fold_status: - folding_panel.toggle_fold_trigger(block) - - self._TextEditBaseWidget__move_line_or_selection( - after_current_line=after_current_line) - - def mouseMoveEvent(self, event): - """Underline words when pressing """ - # Restart timer every time the mouse is moved - # This is needed to correctly handle hover hints with a delay - self._timer_mouse_moving.start() - - pos = event.pos() - self._last_point = pos - alt = event.modifiers() & Qt.AltModifier - ctrl = event.modifiers() & Qt.ControlModifier - - if alt: - self.sig_alt_mouse_moved.emit(event) - event.accept() - return - - if ctrl: - if self._handle_goto_uri_event(pos): - event.accept() - return - - if self.has_selected_text(): - TextEditBaseWidget.mouseMoveEvent(self, event) - return - - if self.go_to_definition_enabled and ctrl: - if self._handle_goto_definition_event(pos): - event.accept() - return - - if self.__cursor_changed: - self._restore_editor_cursor_and_selections() - else: - if (not self._should_display_hover(pos) - and not self.is_completion_widget_visible()): - self.hide_tooltip() - - TextEditBaseWidget.mouseMoveEvent(self, event) - - def setPlainText(self, txt): - """ - Extends setPlainText to emit the new_text_set signal. - - :param txt: The new text to set. - :param mime_type: Associated mimetype. Setting the mime will update the - pygments lexer. - :param encoding: text encoding - """ - super(CodeEditor, self).setPlainText(txt) - self.new_text_set.emit() - - def focusOutEvent(self, event): - """Extend Qt method""" - self.sig_focus_changed.emit() - self._restore_editor_cursor_and_selections() - super(CodeEditor, self).focusOutEvent(event) - - def focusInEvent(self, event): - formatting_enabled = getattr(self, 'formatting_enabled', False) - self.sig_refresh_formatting.emit(formatting_enabled) - super(CodeEditor, self).focusInEvent(event) - - def leaveEvent(self, event): - """Extend Qt method""" - self.sig_leave_out.emit() - self._restore_editor_cursor_and_selections() - TextEditBaseWidget.leaveEvent(self, event) - - def mousePressEvent(self, event): - """Override Qt method.""" - self.hide_tooltip() - self.kite_call_to_action.handle_mouse_press(event) - - ctrl = event.modifiers() & Qt.ControlModifier - alt = event.modifiers() & Qt.AltModifier - pos = event.pos() - self._mouse_left_button_pressed = event.button() == Qt.LeftButton - - if event.button() == Qt.LeftButton and ctrl: - TextEditBaseWidget.mousePressEvent(self, event) - cursor = self.cursorForPosition(pos) - uri = self._last_hover_pattern_text - if uri: - self.go_to_uri_from_cursor(uri) - else: - self.go_to_definition_from_cursor(cursor) - elif event.button() == Qt.LeftButton and alt: - self.sig_alt_left_mouse_pressed.emit(event) - else: - TextEditBaseWidget.mousePressEvent(self, event) - - def mouseReleaseEvent(self, event): - """Override Qt method.""" - if event.button() == Qt.LeftButton: - self._mouse_left_button_pressed = False - - self.request_cursor_event() - TextEditBaseWidget.mouseReleaseEvent(self, event) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - nonempty_selection = self.has_selected_text() - self.copy_action.setEnabled(nonempty_selection) - self.cut_action.setEnabled(nonempty_selection) - self.clear_all_output_action.setVisible(self.is_json() and - nbformat is not None) - self.ipynb_convert_action.setVisible(self.is_json() and - nbformat is not None) - self.run_cell_action.setVisible(self.is_python_or_ipython()) - self.run_cell_and_advance_action.setVisible(self.is_python_or_ipython()) - self.run_selection_action.setVisible(self.is_python_or_ipython()) - self.run_to_line_action.setVisible(self.is_python_or_ipython()) - self.run_from_line_action.setVisible(self.is_python_or_ipython()) - self.re_run_last_cell_action.setVisible(self.is_python_or_ipython()) - self.gotodef_action.setVisible(self.go_to_definition_enabled) - - formatter = CONF.get( - 'completions', - ('provider_configuration', 'lsp', 'values', 'formatting'), - '' - ) - self.format_action.setText(_( - 'Format file or selection with {0}').format( - formatter.capitalize())) - - # Check if a docstring is writable - writer = self.writer_docstring - writer.line_number_cursor = self.get_line_number_at(event.pos()) - result = writer.get_function_definition_from_first_line() - - if result: - self.docstring_action.setEnabled(True) - else: - self.docstring_action.setEnabled(False) - - # Code duplication go_to_definition_from_cursor and mouse_move_event - cursor = self.textCursor() - text = to_text_string(cursor.selectedText()) - if len(text) == 0: - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - - self.undo_action.setEnabled(self.document().isUndoAvailable()) - self.redo_action.setEnabled(self.document().isRedoAvailable()) - menu = self.menu - if self.isReadOnly(): - menu = self.readonly_menu - menu.popup(event.globalPos()) - event.accept() - - def _restore_editor_cursor_and_selections(self): - """Restore the cursor and extra selections of this code editor.""" - if self.__cursor_changed: - self.__cursor_changed = False - QApplication.restoreOverrideCursor() - self.clear_extra_selections('ctrl_click') - self._last_hover_pattern_key = None - self._last_hover_pattern_text = None - - #------ Drag and drop - def dragEnterEvent(self, event): - """ - Reimplemented Qt method. - - Inform Qt about the types of data that the widget accepts. - """ - logger.debug("dragEnterEvent was received") - all_urls = mimedata2url(event.mimeData()) - if all_urls: - # Let the parent widget handle this - logger.debug("Let the parent widget handle this dragEnterEvent") - event.ignore() - else: - logger.debug("Call TextEditBaseWidget dragEnterEvent method") - TextEditBaseWidget.dragEnterEvent(self, event) - - def dropEvent(self, event): - """ - Reimplemented Qt method. - - Unpack dropped data and handle it. - """ - logger.debug("dropEvent was received") - if mimedata2url(event.mimeData()): - logger.debug("Let the parent widget handle this") - event.ignore() - else: - logger.debug("Call TextEditBaseWidget dropEvent method") - TextEditBaseWidget.dropEvent(self, event) - - #------ Paint event - def paintEvent(self, event): - """Overrides paint event to update the list of visible blocks""" - self.update_visible_blocks(event) - TextEditBaseWidget.paintEvent(self, event) - self.painted.emit(event) - - def update_visible_blocks(self, event): - """Update the list of visible blocks/lines position""" - self.__visible_blocks[:] = [] - block = self.firstVisibleBlock() - blockNumber = block.blockNumber() - top = int(self.blockBoundingGeometry(block).translated( - self.contentOffset()).top()) - bottom = top + int(self.blockBoundingRect(block).height()) - ebottom_bottom = self.height() - - while block.isValid(): - visible = bottom <= ebottom_bottom - if not visible: - break - if block.isVisible(): - self.__visible_blocks.append((top, blockNumber+1, block)) - block = block.next() - top = bottom - bottom = top + int(self.blockBoundingRect(block).height()) - blockNumber = block.blockNumber() - - def _draw_editor_cell_divider(self): - """Draw a line on top of a define cell""" - if self.supported_cell_language: - cell_line_color = self.comment_color - painter = QPainter(self.viewport()) - pen = painter.pen() - pen.setStyle(Qt.SolidLine) - pen.setBrush(cell_line_color) - painter.setPen(pen) - - for top, line_number, block in self.visible_blocks: - if is_cell_header(block): - painter.drawLine(4, top, self.width(), top) - - @property - def visible_blocks(self): - """ - Returns the list of visible blocks. - - Each element in the list is a tuple made up of the line top position, - the line number (already 1 based), and the QTextBlock itself. - - :return: A list of tuple(top position, line number, block) - :rtype: List of tuple(int, int, QtGui.QTextBlock) - """ - return self.__visible_blocks - - def is_editor(self): - return True - - def popup_docstring(self, prev_text, prev_pos): - """Show the menu for generating docstring.""" - line_text = self.textCursor().block().text() - if line_text != prev_text: - return - - if prev_pos != self.textCursor().position(): - return - - writer = self.writer_docstring - if writer.get_function_definition_from_below_last_line(): - point = self.cursorRect().bottomRight() - point = self.calculate_real_position(point) - point = self.mapToGlobal(point) - - self.menu_docstring = QMenuOnlyForEnter(self) - self.docstring_action = create_action( - self, _("Generate docstring"), icon=ima.icon('TextFileIcon'), - triggered=writer.write_docstring) - self.menu_docstring.addAction(self.docstring_action) - self.menu_docstring.setActiveAction(self.docstring_action) - self.menu_docstring.popup(point) - - def delayed_popup_docstring(self): - """Show context menu for docstring. - - This method is called after typing '''. After typing ''', this function - waits 300ms. If there was no input for 300ms, show the context menu. - """ - line_text = self.textCursor().block().text() - pos = self.textCursor().position() - - timer = QTimer() - timer.singleShot(300, lambda: self.popup_docstring(line_text, pos)) - - def set_current_project_path(self, root_path=None): - """ - Set the current active project root path. - - Parameters - ---------- - root_path: str or None, optional - Path to current project root path. Default is None. - """ - self.current_project_path = root_path - - def count_leading_empty_lines(self, cell): - """Count the number of leading empty cells.""" - lines = cell.splitlines(keepends=True) - if not lines: - return 0 - for i, line in enumerate(lines): - if line and not line.isspace(): - return i - return len(lines) - - def ipython_to_python(self, code): - """Transform IPython code to python code.""" - tm = TransformerManager() - number_empty_lines = self.count_leading_empty_lines(code) - try: - code = tm.transform_cell(code) - except SyntaxError: - return code - return '\n' * number_empty_lines + code - - def is_letter_or_number(self, char): - """ - Returns whether the specified unicode character is a letter or a - number. - """ - cat = category(char) - return cat.startswith('L') or cat.startswith('N') - - -# ============================================================================= -# Editor + Class browser test -# ============================================================================= -class TestWidget(QSplitter): - def __init__(self, parent): - QSplitter.__init__(self, parent) - self.editor = CodeEditor(self) - self.editor.setup_editor(linenumbers=True, markers=True, tab_mode=False, - font=QFont("Courier New", 10), - show_blanks=True, color_scheme='Zenburn') - self.addWidget(self.editor) - self.setWindowIcon(ima.icon('spyder')) - - def load(self, filename): - self.editor.set_text_from_file(filename) - self.setWindowTitle("%s - %s (%s)" % (_("Editor"), - osp.basename(filename), - osp.dirname(filename))) - self.editor.hide_tooltip() - - -def test(fname): - from spyder.utils.qthelpers import qapplication - app = qapplication(test_time=5) - win = TestWidget(None) - win.show() - win.load(fname) - win.resize(900, 700) - sys.exit(app.exec_()) - - -if __name__ == '__main__': - if len(sys.argv) > 1: - fname = sys.argv[1] - else: - fname = __file__ - test(fname) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Editor widget based on QtGui.QPlainTextEdit +""" + +# TODO: Try to separate this module from spyder to create a self +# consistent editor module (Qt source code and shell widgets library) + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from unicodedata import category +import logging +import functools +import os +import os.path as osp +import re +import sre_constants +import sys +import textwrap +from pkg_resources import parse_version + +# Third party imports +from diff_match_patch import diff_match_patch +from IPython.core.inputtransformer2 import TransformerManager +from qtpy import QT_VERSION +from qtpy.compat import to_qvariant +from qtpy.QtCore import (QEvent, QEventLoop, QRegExp, Qt, QTimer, QThread, + QUrl, Signal, Slot) +from qtpy.QtGui import (QColor, QCursor, QFont, QKeySequence, QPaintEvent, + QPainter, QMouseEvent, QTextCursor, QDesktopServices, + QKeyEvent, QTextDocument, QTextFormat, QTextOption, + QTextCharFormat, QTextLayout) +from qtpy.QtWidgets import (QApplication, QMenu, QMessageBox, QSplitter, + QScrollBar) +from spyder_kernels.utils.dochelpers import getobj +from three_merge import merge + + +# Local imports +from spyder.api.panel import Panel +from spyder.config.base import _, get_debug_level, running_under_pytest +from spyder.config.manager import CONF +from spyder.plugins.editor.api.decoration import TextDecoration +from spyder.plugins.editor.extensions import (CloseBracketsExtension, + CloseQuotesExtension, + DocstringWriterExtension, + QMenuOnlyForEnter, + EditorExtensionsManager, + SnippetsExtension) +from spyder.plugins.completion.providers.kite.widgets import KiteCallToAction +from spyder.plugins.completion.api import (CompletionRequestTypes, + TextDocumentSyncKind, + DiagnosticSeverity) +from spyder.plugins.editor.panels import (ClassFunctionDropdown, + DebuggerPanel, EdgeLine, + FoldingPanel, IndentationGuide, + LineNumberArea, PanelsManager, + ScrollFlagArea) +from spyder.plugins.editor.utils.editor import (TextHelper, BlockUserData, + get_file_language) +from spyder.plugins.editor.utils.debugger import DebuggerManager +from spyder.plugins.editor.utils.kill_ring import QtKillRing +from spyder.plugins.editor.utils.languages import ALL_LANGUAGES, CELL_LANGUAGES +from spyder.plugins.editor.panels.utils import ( + merge_folding, collect_folding_regions) +from spyder.plugins.completion.decorators import ( + request, handles, class_register) +from spyder.plugins.editor.widgets.codeeditor_widgets import GoToLineDialog +from spyder.plugins.editor.widgets.base import TextEditBaseWidget +from spyder.plugins.outlineexplorer.api import (OutlineExplorerData as OED, + is_cell_header) +from spyder.py3compat import PY2, to_text_string, is_string, is_text_string +from spyder.utils import encoding, sourcecode +from spyder.utils.clipboard_helper import CLIPBOARD_HELPER +from spyder.utils.icon_manager import ima +from spyder.utils import syntaxhighlighters as sh +from spyder.utils.palette import SpyderPalette, QStylePalette +from spyder.utils.qthelpers import (add_actions, create_action, file_uri, + mimedata2url, start_file) +from spyder.utils.vcs import get_git_remotes, remote_to_url +from spyder.utils.qstringhelpers import qstring_length + + +try: + import nbformat as nbformat + from nbconvert import PythonExporter as nbexporter +except Exception: + nbformat = None # analysis:ignore + +logger = logging.getLogger(__name__) + + +# Regexp to detect noqa inline comments. +NOQA_INLINE_REGEXP = re.compile(r"#?noqa", re.IGNORECASE) + + +def schedule_request(req=None, method=None, requires_response=True): + """Call function req and then emit its results to the completion server.""" + if req is None: + return functools.partial(schedule_request, method=method, + requires_response=requires_response) + + @functools.wraps(req) + def wrapper(self, *args, **kwargs): + params = req(self, *args, **kwargs) + if params is not None and self.completions_available: + self._pending_server_requests.append( + (method, params, requires_response)) + self._server_requests_timer.start() + return wrapper + + +@class_register +class CodeEditor(TextEditBaseWidget): + """Source Code Editor Widget based exclusively on Qt""" + + LANGUAGES = { + 'Python': (sh.PythonSH, '#'), + 'IPython': (sh.IPythonSH, '#'), + 'Cython': (sh.CythonSH, '#'), + 'Fortran77': (sh.Fortran77SH, 'c'), + 'Fortran': (sh.FortranSH, '!'), + 'Idl': (sh.IdlSH, ';'), + 'Diff': (sh.DiffSH, ''), + 'GetText': (sh.GetTextSH, '#'), + 'Nsis': (sh.NsisSH, '#'), + 'Html': (sh.HtmlSH, ''), + 'Yaml': (sh.YamlSH, '#'), + 'Cpp': (sh.CppSH, '//'), + 'OpenCL': (sh.OpenCLSH, '//'), + 'Enaml': (sh.EnamlSH, '#'), + 'Markdown': (sh.MarkdownSH, '#'), + # Every other language + 'None': (sh.TextSH, ''), + } + + TAB_ALWAYS_INDENTS = ( + 'py', 'pyw', 'python', 'ipy', 'c', 'cpp', 'cl', 'h', 'pyt', 'pyi' + ) + + # Timeout to update decorations (through a QTimer) when a position + # changed is detected in the vertical scrollbar or when releasing + # the up/down arrow keys. + UPDATE_DECORATIONS_TIMEOUT = 500 # milliseconds + + # Timeouts (in milliseconds) to sychronize symbols and folding after + # linting results arrive, according to the number of lines in the file. + SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS = { + # Lines: Timeout + 500: 350, + 1500: 800, + 2500: 1200, + 6500: 1800 + } + + # Custom signal to be emitted upon completion of the editor's paintEvent + painted = Signal(QPaintEvent) + + # To have these attrs when early viewportEvent's are triggered + edge_line = None + indent_guides = None + + sig_breakpoints_changed = Signal() + sig_repaint_breakpoints = Signal() + sig_debug_stop = Signal((int,), ()) + sig_debug_start = Signal() + sig_breakpoints_saved = Signal() + sig_filename_changed = Signal(str) + sig_bookmarks_changed = Signal() + go_to_definition = Signal(str, int, int) + sig_show_object_info = Signal(int) + sig_run_selection = Signal() + sig_run_to_line = Signal() + sig_run_from_line = Signal() + sig_run_cell_and_advance = Signal() + sig_run_cell = Signal() + sig_re_run_last_cell = Signal() + sig_debug_cell = Signal() + sig_cursor_position_changed = Signal(int, int) + sig_new_file = Signal(str) + sig_refresh_formatting = Signal(bool) + + #: Signal emitted when the editor loses focus + sig_focus_changed = Signal() + + #: Signal emitted when a key is pressed + sig_key_pressed = Signal(QKeyEvent) + + #: Signal emitted when a key is released + sig_key_released = Signal(QKeyEvent) + + #: Signal emitted when the alt key is pressed and the left button of the + # mouse is clicked + sig_alt_left_mouse_pressed = Signal(QMouseEvent) + + #: Signal emitted when the alt key is pressed and the cursor moves over + # the editor + sig_alt_mouse_moved = Signal(QMouseEvent) + + #: Signal emitted when the cursor leaves the editor + sig_leave_out = Signal() + + #: Signal emitted when the flags need to be updated in the scrollflagarea + sig_flags_changed = Signal() + + #: Signal emitted when the syntax color theme of the editor. + sig_theme_colors_changed = Signal(dict) + + #: Signal emitted when a new text is set on the widget + new_text_set = Signal() + + # -- LSP signals + #: Signal emitted when an LSP request is sent to the LSP manager + sig_perform_completion_request = Signal(str, str, dict) + + #: Signal emitted when a response is received from the completion plugin + # For now it's only used on tests, but it could be used to track + # and profile completion diagnostics. + completions_response_signal = Signal(str, object) + + #: Signal to display object information on the Help plugin + sig_display_object_info = Signal(str, bool) + + #: Signal only used for tests + # TODO: Remove it! + sig_signature_invoked = Signal(dict) + + #: Signal emitted when processing code analysis warnings is finished + sig_process_code_analysis = Signal() + + # Used for testing. When the mouse moves with Ctrl/Cmd pressed and + # a URI is found, this signal is emitted + sig_uri_found = Signal(str) + + sig_file_uri_preprocessed = Signal(str) + """ + This signal is emitted when the go to uri for a file has been + preprocessed. + + Parameters + ---------- + fpath: str + The preprocessed file path. + """ + + # Signal with the info about the current completion item documentation + # str: object name + # str: object signature/documentation + # bool: force showing the info + sig_show_completion_object_info = Signal(str, str, bool) + + # Used to indicate if text was inserted into the editor + sig_text_was_inserted = Signal() + + # Used to indicate that text will be inserted into the editor + sig_will_insert_text = Signal(str) + + # Used to indicate that a text selection will be removed + sig_will_remove_selection = Signal(tuple, tuple) + + # Used to indicate that text will be pasted + sig_will_paste_text = Signal(str) + + # Used to indicate that an undo operation will take place + sig_undo = Signal() + + # Used to indicate that an undo operation will take place + sig_redo = Signal() + + # Used to start the status spinner in the editor + sig_start_operation_in_progress = Signal() + + # Used to start the status spinner in the editor + sig_stop_operation_in_progress = Signal() + + # Used to signal font change + sig_font_changed = Signal() + + def __init__(self, parent=None): + TextEditBaseWidget.__init__(self, parent) + + self.setFocusPolicy(Qt.StrongFocus) + + # Projects + self.current_project_path = None + + # Caret (text cursor) + self.setCursorWidth(CONF.get('main', 'cursor/width')) + + self.text_helper = TextHelper(self) + + self._panels = PanelsManager(self) + + # Mouse moving timer / Hover hints handling + # See: mouseMoveEvent + self.tooltip_widget.sig_help_requested.connect( + self.show_object_info) + self.tooltip_widget.sig_completion_help_requested.connect( + self.show_completion_object_info) + self._last_point = None + self._last_hover_word = None + self._last_hover_cursor = None + self._timer_mouse_moving = QTimer(self) + self._timer_mouse_moving.setInterval(350) + self._timer_mouse_moving.setSingleShot(True) + self._timer_mouse_moving.timeout.connect(self._handle_hover) + + # Typing keys / handling on the fly completions + # See: keyPressEvent + self._last_key_pressed_text = '' + self._last_pressed_key = None + self._timer_autocomplete = QTimer(self) + self._timer_autocomplete.setSingleShot(True) + self._timer_autocomplete.timeout.connect(self._handle_completions) + + # Handle completions hints + self._completions_hint_idle = False + self._timer_completions_hint = QTimer(self) + self._timer_completions_hint.setSingleShot(True) + self._timer_completions_hint.timeout.connect( + self._set_completions_hint_idle) + self.completion_widget.sig_completion_hint.connect( + self.show_hint_for_completion) + + # Request symbols and folding after a timeout. + # See: process_diagnostics + self._timer_sync_symbols_and_folding = QTimer(self) + self._timer_sync_symbols_and_folding.setSingleShot(True) + self._timer_sync_symbols_and_folding.timeout.connect( + self.sync_symbols_and_folding) + self.blockCountChanged.connect( + self.set_sync_symbols_and_folding_timeout) + + # Goto uri + self._last_hover_pattern_key = None + self._last_hover_pattern_text = None + + # 79-col edge line + self.edge_line = self.panels.register(EdgeLine(), + Panel.Position.FLOATING) + + # indent guides + self.indent_guides = self.panels.register(IndentationGuide(), + Panel.Position.FLOATING) + # Blanks enabled + self.blanks_enabled = False + + # Underline errors and warnings + self.underline_errors_enabled = False + + # Scrolling past the end of the document + self.scrollpastend_enabled = False + + self.background = QColor('white') + + # Folding + self.panels.register(FoldingPanel()) + + # Debugger panel (Breakpoints) + self.debugger = DebuggerManager(self) + self.panels.register(DebuggerPanel()) + # Update breakpoints if the number of lines in the file changes + self.blockCountChanged.connect(self.sig_breakpoints_changed) + + # Line number area management + self.linenumberarea = self.panels.register(LineNumberArea()) + + # Class and Method/Function Dropdowns + self.classfuncdropdown = self.panels.register( + ClassFunctionDropdown(), + Panel.Position.TOP, + ) + + # Colors to be defined in _apply_highlighter_color_scheme() + # Currentcell color and current line color are defined in base.py + self.occurrence_color = None + self.ctrl_click_color = None + self.sideareas_color = None + self.matched_p_color = None + self.unmatched_p_color = None + self.normal_color = None + self.comment_color = None + + # --- Syntax highlight entrypoint --- + # + # - if set, self.highlighter is responsible for + # - coloring raw text data inside editor on load + # - coloring text data when editor is cloned + # - updating document highlight on line edits + # - providing color palette (scheme) for the editor + # - providing data for Outliner + # - self.highlighter is not responsible for + # - background highlight for current line + # - background highlight for search / current line occurrences + + self.highlighter_class = sh.TextSH + self.highlighter = None + ccs = 'Spyder' + if ccs not in sh.COLOR_SCHEME_NAMES: + ccs = sh.COLOR_SCHEME_NAMES[0] + self.color_scheme = ccs + + self.highlight_current_line_enabled = False + + # Vertical scrollbar + # This is required to avoid a "RuntimeError: no access to protected + # functions or signals for objects not created from Python" in + # Linux Ubuntu. See spyder-ide/spyder#5215. + self.setVerticalScrollBar(QScrollBar()) + + # Highlights and flag colors + self.warning_color = SpyderPalette.COLOR_WARN_2 + self.error_color = SpyderPalette.COLOR_ERROR_1 + self.todo_color = SpyderPalette.GROUP_9 + self.breakpoint_color = SpyderPalette.ICON_3 + self.occurrence_color = QColor(SpyderPalette.GROUP_2).lighter(160) + self.found_results_color = QColor(SpyderPalette.COLOR_OCCURRENCE_4) + + # Scrollbar flag area + self.scrollflagarea = self.panels.register(ScrollFlagArea(), + Panel.Position.RIGHT) + self.panels.refresh() + + self.document_id = id(self) + + # Indicate occurrences of the selected word + self.cursorPositionChanged.connect(self.__cursor_position_changed) + self.__find_first_pos = None + + self.language = None + self.supported_language = False + self.supported_cell_language = False + self.comment_string = None + self._kill_ring = QtKillRing(self) + + # Block user data + self.blockCountChanged.connect(self.update_bookmarks) + + # Highlight using Pygments highlighter timer + # --------------------------------------------------------------------- + # For files that use the PygmentsSH we parse the full file inside + # the highlighter in order to generate the correct coloring. + self.timer_syntax_highlight = QTimer(self) + self.timer_syntax_highlight.setSingleShot(True) + self.timer_syntax_highlight.timeout.connect( + self.run_pygments_highlighter) + + # Mark occurrences timer + self.occurrence_highlighting = None + self.occurrence_timer = QTimer(self) + self.occurrence_timer.setSingleShot(True) + self.occurrence_timer.setInterval(1500) + self.occurrence_timer.timeout.connect(self.__mark_occurrences) + self.occurrences = [] + + # Update decorations + self.update_decorations_timer = QTimer(self) + self.update_decorations_timer.setSingleShot(True) + self.update_decorations_timer.setInterval( + self.UPDATE_DECORATIONS_TIMEOUT) + self.update_decorations_timer.timeout.connect( + self.update_decorations) + self.verticalScrollBar().valueChanged.connect( + lambda value: self.update_decorations_timer.start()) + + # LSP + self.textChanged.connect(self.schedule_document_did_change) + self._pending_server_requests = [] + self._server_requests_timer = QTimer(self) + self._server_requests_timer.setSingleShot(True) + self._server_requests_timer.setInterval(100) + self._server_requests_timer.timeout.connect( + self.process_server_requests) + + # Mark found results + self.textChanged.connect(self.__text_has_changed) + self.found_results = [] + + # Docstring + self.writer_docstring = DocstringWriterExtension(self) + + # Context menu + self.gotodef_action = None + self.setup_context_menu() + + # Tab key behavior + self.tab_indents = None + self.tab_mode = True # see CodeEditor.set_tab_mode + + # Intelligent backspace mode + self.intelligent_backspace = True + + # Automatic (on the fly) completions + self.automatic_completions = True + self.automatic_completions_after_chars = 3 + self.automatic_completions_after_ms = 300 + + # Code Folding + self.code_folding = True + self.update_folding_thread = QThread(None) + self.update_folding_thread.finished.connect(self.finish_code_folding) + + # Completions hint + self.completions_hint = True + self.completions_hint_after_ms = 500 + + self.close_parentheses_enabled = True + self.close_quotes_enabled = False + self.add_colons_enabled = True + self.auto_unindent_enabled = True + + # Autoformat on save + self.format_on_save = False + self.format_eventloop = QEventLoop(None) + self.format_timer = QTimer(self) + + # Mouse tracking + self.setMouseTracking(True) + self.__cursor_changed = False + self._mouse_left_button_pressed = False + self.ctrl_click_color = QColor(Qt.blue) + + self._bookmarks_blocks = {} + self.bookmarks = [] + + # Keyboard shortcuts + self.shortcuts = self.create_shortcuts() + + # Paint event + self.__visible_blocks = [] # Visible blocks, update with repaint + self.painted.connect(self._draw_editor_cell_divider) + + # Outline explorer + self.oe_proxy = None + + # Line stripping + self.last_change_position = None + self.last_position = None + self.last_auto_indent = None + self.skip_rstrip = False + self.strip_trailing_spaces_on_modify = True + + # Hover hints + self.hover_hints_enabled = None + + # Language Server + self.filename = None + self.completions_available = False + self.text_version = 0 + self.save_include_text = True + self.open_close_notifications = True + self.sync_mode = TextDocumentSyncKind.FULL + self.will_save_notify = False + self.will_save_until_notify = False + self.enable_hover = True + self.auto_completion_characters = [] + self.resolve_completions_enabled = False + self.signature_completion_characters = [] + self.go_to_definition_enabled = False + self.find_references_enabled = False + self.highlight_enabled = False + self.formatting_enabled = False + self.range_formatting_enabled = False + self.document_symbols_enabled = False + self.formatting_characters = [] + self.completion_args = None + self.folding_supported = False + self.is_cloned = False + self.operation_in_progress = False + self.formatting_in_progress = False + + # Diagnostics + self.update_diagnostics_thread = QThread(None) + self.update_diagnostics_thread.run = self.set_errors + self.update_diagnostics_thread.finished.connect( + self.finish_code_analysis) + self._diagnostics = [] + + # Editor Extensions + self.editor_extensions = EditorExtensionsManager(self) + self.editor_extensions.add(CloseQuotesExtension()) + self.editor_extensions.add(SnippetsExtension()) + self.editor_extensions.add(CloseBracketsExtension()) + + # Text diffs across versions + self.differ = diff_match_patch() + self.previous_text = '' + self.patch = [] + self.leading_whitespaces = {} + + # re-use parent of completion_widget (usually the main window) + completion_parent = self.completion_widget.parent() + self.kite_call_to_action = KiteCallToAction(self, completion_parent) + + # Some events should not be triggered during undo/redo + # such as line stripping + self.is_undoing = False + self.is_redoing = False + + # Timer to Avoid too many calls to rehighlight. + self._rehighlight_timer = QTimer(self) + self._rehighlight_timer.setSingleShot(True) + self._rehighlight_timer.setInterval(150) + + # --- Helper private methods + # ------------------------------------------------------------------------ + def process_server_requests(self): + """Process server requests.""" + # Check if document needs to be updated: + if self._document_server_needs_update: + self.document_did_change() + self._document_server_needs_update = False + for method, params, requires_response in self._pending_server_requests: + self.emit_request(method, params, requires_response) + self._pending_server_requests = [] + + # --- Hover/Hints + def _should_display_hover(self, point): + """Check if a hover hint should be displayed:""" + if not self._mouse_left_button_pressed: + return (self.hover_hints_enabled and point + and self.get_word_at(point)) + + def _handle_hover(self): + """Handle hover hint trigger after delay.""" + self._timer_mouse_moving.stop() + pos = self._last_point + + # These are textual characters but should not trigger a completion + # FIXME: update per language + ignore_chars = ['(', ')', '.'] + + if self._should_display_hover(pos): + key, pattern_text, cursor = self.get_pattern_at(pos) + text = self.get_word_at(pos) + if pattern_text: + ctrl_text = 'Cmd' if sys.platform == "darwin" else 'Ctrl' + if key in ['file']: + hint_text = ctrl_text + ' + ' + _('click to open file') + elif key in ['mail']: + hint_text = ctrl_text + ' + ' + _('click to send email') + elif key in ['url']: + hint_text = ctrl_text + ' + ' + _('click to open url') + else: + hint_text = ctrl_text + ' + ' + _('click to open') + + hint_text = ' {} '.format(hint_text) + + self.show_tooltip(text=hint_text, at_point=pos) + return + + cursor = self.cursorForPosition(pos) + cursor_offset = cursor.position() + line, col = cursor.blockNumber(), cursor.columnNumber() + self._last_point = pos + if text and self._last_hover_word != text: + if all(char not in text for char in ignore_chars): + self._last_hover_word = text + self.request_hover(line, col, cursor_offset) + else: + self.hide_tooltip() + elif not self.is_completion_widget_visible(): + self.hide_tooltip() + + def blockuserdata_list(self): + """Get the list of all user data in document.""" + block = self.document().firstBlock() + while block.isValid(): + data = block.userData() + if data: + yield data + block = block.next() + + def outlineexplorer_data_list(self): + """Get the list of all user data in document.""" + for data in self.blockuserdata_list(): + if data.oedata: + yield data.oedata + + # ---- Keyboard Shortcuts + + def create_cursor_callback(self, attr): + """Make a callback for cursor move event type, (e.g. "Start")""" + def cursor_move_event(): + cursor = self.textCursor() + move_type = getattr(QTextCursor, attr) + cursor.movePosition(move_type) + self.setTextCursor(cursor) + return cursor_move_event + + def create_shortcuts(self): + """Create the local shortcuts for the CodeEditor.""" + shortcut_context_name_callbacks = ( + ('editor', 'code completion', self.do_completion), + ('editor', 'duplicate line down', self.duplicate_line_down), + ('editor', 'duplicate line up', self.duplicate_line_up), + ('editor', 'delete line', self.delete_line), + ('editor', 'move line up', self.move_line_up), + ('editor', 'move line down', self.move_line_down), + ('editor', 'go to new line', self.go_to_new_line), + ('editor', 'go to definition', self.go_to_definition_from_cursor), + ('editor', 'toggle comment', self.toggle_comment), + ('editor', 'blockcomment', self.blockcomment), + ('editor', 'unblockcomment', self.unblockcomment), + ('editor', 'transform to uppercase', self.transform_to_uppercase), + ('editor', 'transform to lowercase', self.transform_to_lowercase), + ('editor', 'indent', lambda: self.indent(force=True)), + ('editor', 'unindent', lambda: self.unindent(force=True)), + ('editor', 'start of line', + self.create_cursor_callback('StartOfLine')), + ('editor', 'end of line', + self.create_cursor_callback('EndOfLine')), + ('editor', 'previous line', self.create_cursor_callback('Up')), + ('editor', 'next line', self.create_cursor_callback('Down')), + ('editor', 'previous char', self.create_cursor_callback('Left')), + ('editor', 'next char', self.create_cursor_callback('Right')), + ('editor', 'previous word', + self.create_cursor_callback('PreviousWord')), + ('editor', 'next word', self.create_cursor_callback('NextWord')), + ('editor', 'kill to line end', self.kill_line_end), + ('editor', 'kill to line start', self.kill_line_start), + ('editor', 'yank', self._kill_ring.yank), + ('editor', 'rotate kill ring', self._kill_ring.rotate), + ('editor', 'kill previous word', self.kill_prev_word), + ('editor', 'kill next word', self.kill_next_word), + ('editor', 'start of document', + self.create_cursor_callback('Start')), + ('editor', 'end of document', + self.create_cursor_callback('End')), + ('editor', 'undo', self.undo), + ('editor', 'redo', self.redo), + ('editor', 'cut', self.cut), + ('editor', 'copy', self.copy), + ('editor', 'paste', self.paste), + ('editor', 'delete', self.delete), + ('editor', 'select all', self.selectAll), + ('editor', 'docstring', + self.writer_docstring.write_docstring_for_shortcut), + ('editor', 'autoformatting', self.format_document_or_range), + ('array_builder', 'enter array inline', self.enter_array_inline), + ('array_builder', 'enter array table', self.enter_array_table) + ) + + shortcuts = [] + for context, name, callback in shortcut_context_name_callbacks: + shortcuts.append( + CONF.config_shortcut( + callback, context=context, name=name, parent=self)) + return shortcuts + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + def closeEvent(self, event): + if isinstance(self.highlighter, sh.PygmentsSH): + self.highlighter.stop() + self.update_folding_thread.quit() + self.update_folding_thread.wait() + self.update_diagnostics_thread.quit() + self.update_diagnostics_thread.wait() + TextEditBaseWidget.closeEvent(self, event) + + def get_document_id(self): + return self.document_id + + def set_as_clone(self, editor): + """Set as clone editor""" + self.setDocument(editor.document()) + self.document_id = editor.get_document_id() + self.highlighter = editor.highlighter + self._rehighlight_timer.timeout.connect( + self.highlighter.rehighlight) + self.eol_chars = editor.eol_chars + self._apply_highlighter_color_scheme() + self.highlighter.sig_font_changed.connect(self.sync_font) + + # ---- Widget setup and options + def toggle_wrap_mode(self, enable): + """Enable/disable wrap mode""" + self.set_wrap_mode('word' if enable else None) + + def toggle_line_numbers(self, linenumbers=True, markers=False): + """Enable/disable line numbers.""" + self.linenumberarea.setup_margins(linenumbers, markers) + + @property + def panels(self): + """ + Returns a reference to the + :class:`spyder.widgets.panels.managers.PanelsManager` + used to manage the collection of installed panels + """ + return self._panels + + def setup_editor(self, + linenumbers=True, + language=None, + markers=False, + font=None, + color_scheme=None, + wrap=False, + tab_mode=True, + strip_mode=False, + intelligent_backspace=True, + automatic_completions=True, + automatic_completions_after_chars=3, + automatic_completions_after_ms=300, + completions_hint=True, + completions_hint_after_ms=500, + hover_hints=True, + code_snippets=True, + highlight_current_line=True, + highlight_current_cell=True, + occurrence_highlighting=True, + scrollflagarea=True, + edge_line=True, + edge_line_columns=(79,), + show_blanks=False, + underline_errors=False, + close_parentheses=True, + close_quotes=False, + add_colons=True, + auto_unindent=True, + indent_chars=" "*4, + tab_stop_width_spaces=4, + cloned_from=None, + filename=None, + occurrence_timeout=1500, + show_class_func_dropdown=False, + indent_guides=False, + scroll_past_end=False, + show_debug_panel=True, + folding=True, + remove_trailing_spaces=False, + remove_trailing_newlines=False, + add_newline=False, + format_on_save=False): + """ + Set-up configuration for the CodeEditor instance. + + Usually the parameters here are related with a configurable preference + in the Preference Dialog and Editor configurations: + + linenumbers: Enable/Disable line number panel. Default True. + language: Set editor language for example python. Default None. + markers: Enable/Disable markers panel. Used to show elements like + Code Analysis. Default False. + font: Base font for the Editor to use. Default None. + color_scheme: Initial color scheme for the Editor to use. Default None. + wrap: Enable/Disable line wrap. Default False. + tab_mode: Enable/Disable using Tab as delimiter between word, + Default True. + strip_mode: strip_mode: Enable/Disable striping trailing spaces when + modifying the file. Default False. + intelligent_backspace: Enable/Disable automatically unindenting + inserted text (unindenting happens if the leading text length of + the line isn't module of the length of indentation chars being use) + Default True. + automatic_completions: Enable/Disable automatic completions. + The behavior of the trigger of this the completions can be + established with the two following kwargs. Default True. + automatic_completions_after_chars: Number of charts to type to trigger + an automatic completion. Default 3. + automatic_completions_after_ms: Number of milliseconds to pass before + an autocompletion is triggered. Default 300. + completions_hint: Enable/Disable documentation hints for completions. + Default True. + completions_hint_after_ms: Number of milliseconds over a completion + item to show the documentation hint. Default 500. + hover_hints: Enable/Disable documentation hover hints. Default True. + code_snippets: Enable/Disable code snippets completions. Default True. + highlight_current_line: Enable/Disable current line highlighting. + Default True. + highlight_current_cell: Enable/Disable current cell highlighting. + Default True. + occurrence_highlighting: Enable/Disable highlighting of current word + occurrence in the file. Default True. + scrollflagarea : Enable/Disable flag area that shows at the left of + the scroll bar. Default True. + edge_line: Enable/Disable vertical line to show max number of + characters per line. Customizable number of columns in the + following kwarg. Default True. + edge_line_columns: Number of columns/characters where the editor + horizontal edge line will show. Default (79,). + show_blanks: Enable/Disable blanks highlighting. Default False. + underline_errors: Enable/Disable showing and underline to highlight + errors. Default False. + close_parentheses: Enable/Disable automatic parentheses closing + insertion. Default True. + close_quotes: Enable/Disable automatic closing of quotes. + Default False. + add_colons: Enable/Disable automatic addition of colons. Default True. + auto_unindent: Enable/Disable automatically unindentation before else, + elif, finally or except statements. Default True. + indent_chars: Characters to use for indentation. Default " "*4. + tab_stop_width_spaces: Enable/Disable using tabs for indentation. + Default 4. + cloned_from: Editor instance used as template to instantiate this + CodeEditor instance. Default None. + filename: Initial filename to show. Default None. + occurrence_timeout : Timeout in milliseconds to start highlighting + matches/occurrences for the current word under the cursor. + Default 1500 ms. + show_class_func_dropdown: Enable/Disable a Matlab like widget to show + classes and functions available in the current file. Default False. + indent_guides: Enable/Disable highlighting of code indentation. + Default False. + scroll_past_end: Enable/Disable possibility to scroll file passed + its end. Default False. + show_debug_panel: Enable/Disable debug panel. Default True. + folding: Enable/Disable code folding. Default True. + remove_trailing_spaces: Remove trailing whitespaces on lines. + Default False. + remove_trailing_newlines: Remove extra lines at the end of the file. + Default False. + add_newline: Add a newline at the end of the file if there is not one. + Default False. + format_on_save: Autoformat file automatically when saving. + Default False. + """ + + self.set_close_parentheses_enabled(close_parentheses) + self.set_close_quotes_enabled(close_quotes) + self.set_add_colons_enabled(add_colons) + self.set_auto_unindent_enabled(auto_unindent) + self.set_indent_chars(indent_chars) + + # Show/hide the debug panel depending on the language and parameter + self.set_debug_panel(show_debug_panel, language) + + # Show/hide folding panel depending on parameter + self.toggle_code_folding(folding) + + # Scrollbar flag area + self.scrollflagarea.set_enabled(scrollflagarea) + + # Debugging + self.debugger.set_filename(filename) + + # Edge line + self.edge_line.set_enabled(edge_line) + self.edge_line.set_columns(edge_line_columns) + + # Indent guides + self.toggle_identation_guides(indent_guides) + if self.indent_chars == '\t': + self.indent_guides.set_indentation_width( + tab_stop_width_spaces) + else: + self.indent_guides.set_indentation_width(len(self.indent_chars)) + + # Blanks + self.set_blanks_enabled(show_blanks) + + # Remove trailing whitespaces + self.set_remove_trailing_spaces(remove_trailing_spaces) + + # Remove trailing newlines + self.set_remove_trailing_newlines(remove_trailing_newlines) + + # Add newline at the end + self.set_add_newline(add_newline) + + # Scrolling past the end + self.set_scrollpastend_enabled(scroll_past_end) + + # Line number area and indent guides + if cloned_from: + self.setFont(font) # this is required for line numbers area + # Needed to show indent guides for splited editor panels + # See spyder-ide/spyder#10900 + self.patch = cloned_from.patch + self.is_cloned = True + self.toggle_line_numbers(linenumbers, markers) + + # Lexer + self.filename = filename + self.set_language(language, filename) + + # Underline errors and warnings + self.set_underline_errors_enabled(underline_errors) + + # Highlight current cell + self.set_highlight_current_cell(highlight_current_cell) + + # Highlight current line + self.set_highlight_current_line(highlight_current_line) + + # Occurrence highlighting + self.set_occurrence_highlighting(occurrence_highlighting) + self.set_occurrence_timeout(occurrence_timeout) + + # Tab always indents (even when cursor is not at the begin of line) + self.set_tab_mode(tab_mode) + + # Intelligent backspace + self.toggle_intelligent_backspace(intelligent_backspace) + + # Automatic completions + self.toggle_automatic_completions(automatic_completions) + self.set_automatic_completions_after_chars( + automatic_completions_after_chars) + self.set_automatic_completions_after_ms(automatic_completions_after_ms) + + # Completions hint + self.toggle_completions_hint(completions_hint) + self.set_completions_hint_after_ms(completions_hint_after_ms) + + # Hover hints + self.toggle_hover_hints(hover_hints) + + # Code snippets + self.toggle_code_snippets(code_snippets) + + # Autoformat on save + self.toggle_format_on_save(format_on_save) + + if cloned_from is not None: + self.set_as_clone(cloned_from) + self.panels.refresh() + elif font is not None: + self.set_font(font, color_scheme) + elif color_scheme is not None: + self.set_color_scheme(color_scheme) + + # Set tab spacing after font is set + self.set_tab_stop_width_spaces(tab_stop_width_spaces) + + self.toggle_wrap_mode(wrap) + + # Class/Function dropdown will be disabled if we're not in a Python + # file. + self.classfuncdropdown.setVisible(show_class_func_dropdown + and self.is_python_like()) + + self.set_strip_mode(strip_mode) + + # --- Language Server Protocol methods ----------------------------------- + # ------------------------------------------------------------------------ + @Slot(str, dict) + def handle_response(self, method, params): + if method in self.handler_registry: + handler_name = self.handler_registry[method] + handler = getattr(self, handler_name) + handler(params) + # This signal is only used on tests. + # It could be used to track and profile LSP diagnostics. + self.completions_response_signal.emit(method, params) + + def emit_request(self, method, params, requires_response): + """Send request to LSP manager.""" + params['requires_response'] = requires_response + params['response_instance'] = self + self.sig_perform_completion_request.emit( + self.language.lower(), method, params) + + def log_lsp_handle_errors(self, message): + """ + Log errors when handling LSP responses. + + This works when debugging is on or off. + """ + if get_debug_level() > 0: + # We log the error normally when running on debug mode. + logger.error(message, exc_info=True) + else: + # We need this because logger.error activates our error + # report dialog but it doesn't show the entire traceback + # there. So we intentionally leave an error in this call + # to get the entire stack info generated by it, which + # gives the info we need from users. + if PY2: + logger.error(message, exc_info=True) + print(message, file=sys.stderr) + else: + logger.error('%', 1, stack_info=True) + + # ------------- LSP: Configuration and protocol start/end ---------------- + def start_completion_services(self): + """Start completion services for this instance.""" + self.completions_available = True + + if self.is_cloned: + additional_msg = " cloned editor" + else: + additional_msg = "" + self.document_did_open() + + logger.debug(u"Completion services available for {0}: {1}".format( + additional_msg, self.filename)) + + def register_completion_capabilities(self, capabilities): + """ + Register completion server capabilities. + + Parameters + ---------- + capabilities: dict + Capabilities supported by a language server. + """ + sync_options = capabilities['textDocumentSync'] + completion_options = capabilities['completionProvider'] + signature_options = capabilities['signatureHelpProvider'] + range_formatting_options = ( + capabilities['documentOnTypeFormattingProvider']) + self.open_close_notifications = sync_options.get('openClose', False) + self.sync_mode = sync_options.get('change', TextDocumentSyncKind.NONE) + self.will_save_notify = sync_options.get('willSave', False) + self.will_save_until_notify = sync_options.get('willSaveWaitUntil', + False) + self.save_include_text = sync_options['save']['includeText'] + self.enable_hover = capabilities['hoverProvider'] + self.folding_supported = capabilities.get( + 'foldingRangeProvider', False) + self.auto_completion_characters = ( + completion_options['triggerCharacters']) + self.resolve_completions_enabled = ( + completion_options.get('resolveProvider', False)) + self.signature_completion_characters = ( + signature_options['triggerCharacters'] + ['=']) # FIXME: + self.go_to_definition_enabled = capabilities['definitionProvider'] + self.find_references_enabled = capabilities['referencesProvider'] + self.highlight_enabled = capabilities['documentHighlightProvider'] + self.formatting_enabled = capabilities['documentFormattingProvider'] + self.range_formatting_enabled = ( + capabilities['documentRangeFormattingProvider']) + self.document_symbols_enabled = ( + capabilities['documentSymbolProvider'] + ) + self.formatting_characters.append( + range_formatting_options['firstTriggerCharacter']) + self.formatting_characters += ( + range_formatting_options.get('moreTriggerCharacter', [])) + + if self.formatting_enabled: + self.format_action.setEnabled(True) + self.sig_refresh_formatting.emit(True) + + self.completions_available = True + + def stop_completion_services(self): + logger.debug('Stopping completion services for %s' % self.filename) + self.completions_available = False + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_OPEN, + requires_response=False) + def document_did_open(self): + """Send textDocument/didOpen request to the server.""" + cursor = self.textCursor() + text = self.get_text_with_eol() + if self.is_ipython(): + # Send valid python text to LSP as it doesn't support IPython + text = self.ipython_to_python(text) + params = { + 'file': self.filename, + 'language': self.language, + 'version': self.text_version, + 'text': text, + 'codeeditor': self, + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + } + return params + + # ------------- LSP: Symbols --------------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_SYMBOL) + def request_symbols(self): + """Request document symbols.""" + if not self.document_symbols_enabled: + return + if self.oe_proxy is not None: + self.oe_proxy.emit_request_in_progress() + params = {'file': self.filename} + return params + + @handles(CompletionRequestTypes.DOCUMENT_SYMBOL) + def process_symbols(self, params): + """Handle symbols response.""" + try: + symbols = params['params'] + symbols = [] if symbols is None else symbols + self.classfuncdropdown.update_data(symbols) + if self.oe_proxy is not None: + self.oe_proxy.update_outline_info(symbols) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing symbols") + + # ------------- LSP: Linting --------------------------------------- + def schedule_document_did_change(self): + """Schedule a document update.""" + self._document_server_needs_update = True + self._server_requests_timer.start() + + @request( + method=CompletionRequestTypes.DOCUMENT_DID_CHANGE, + requires_response=False) + def document_did_change(self): + """Send textDocument/didChange request to the server.""" + # Cancel formatting + self.formatting_in_progress = False + text = self.get_text_with_eol() + if self.is_ipython(): + # Send valid python text to LSP + text = self.ipython_to_python(text) + + self.text_version += 1 + + self.patch = self.differ.patch_make(self.previous_text, text) + self.previous_text = text + cursor = self.textCursor() + params = { + 'file': self.filename, + 'version': self.text_version, + 'text': text, + 'diff': self.patch, + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + } + return params + + @handles(CompletionRequestTypes.DOCUMENT_PUBLISH_DIAGNOSTICS) + def process_diagnostics(self, params): + """Handle linting response.""" + # The LSP spec doesn't require that folding and symbols + # are treated in the same way as linting, i.e. to be + # recomputed on didChange, didOpen and didSave. However, + # we think that's necessary to maintain accurate folding + # and symbols all the time. Therefore, we decided to call + # those requests here, but after a certain timeout to + # avoid performance issues. + self._timer_sync_symbols_and_folding.start() + + # Process results (runs in a thread) + self.process_code_analysis(params['params']) + + def set_sync_symbols_and_folding_timeout(self): + """ + Set timeout to sync symbols and folding according to the file + size. + """ + current_lines = self.get_line_count() + timeout = None + + for lines in self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.keys(): + if (current_lines // lines) == 0: + timeout = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS[lines] + break + + if not timeout: + timeouts = self.SYNC_SYMBOLS_AND_FOLDING_TIMEOUTS.values() + timeout = list(timeouts)[-1] + + self._timer_sync_symbols_and_folding.setInterval(timeout) + + def sync_symbols_and_folding(self): + """ + Synchronize symbols and folding after linting results arrive. + """ + self.request_folding() + self.request_symbols() + + def process_code_analysis(self, diagnostics): + """Process code analysis results in a thread.""" + self.cleanup_code_analysis() + self._diagnostics = diagnostics + + # Process diagnostics in a thread to improve performance. + self.update_diagnostics_thread.start() + + def cleanup_code_analysis(self): + """Remove all code analysis markers""" + self.setUpdatesEnabled(False) + self.clear_extra_selections('code_analysis_highlight') + self.clear_extra_selections('code_analysis_underline') + for data in self.blockuserdata_list(): + data.code_analysis = [] + + self.setUpdatesEnabled(True) + # When the new code analysis results are empty, it is necessary + # to update manually the scrollflag and linenumber areas (otherwise, + # the old flags will still be displayed): + self.sig_flags_changed.emit() + self.linenumberarea.update() + + def set_errors(self): + """Set errors and warnings in the line number area.""" + try: + self._process_code_analysis(underline=False) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing linting") + + def underline_errors(self): + """Underline errors and warnings.""" + try: + # Clear current selections before painting the new ones. + # This prevents accumulating them when moving around in or editing + # the file, which generated a memory leakage and sluggishness + # after some time. + self.clear_extra_selections('code_analysis_underline') + self._process_code_analysis(underline=True) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing linting") + + def finish_code_analysis(self): + """Finish processing code analysis results.""" + self.linenumberarea.update() + if self.underline_errors_enabled: + self.underline_errors() + self.sig_process_code_analysis.emit() + self.sig_flags_changed.emit() + + def errors_present(self): + """ + Return True if there are errors or warnings present in the file. + """ + return bool(len(self._diagnostics)) + + def _process_code_analysis(self, underline): + """ + Process all code analysis results. + + Parameters + ---------- + underline: bool + Determines if errors and warnings are going to be set in + the line number area or underlined. It's better to separate + these two processes for perfomance reasons. That's because + setting errors can be done in a thread whereas underlining + them can't. + """ + document = self.document() + if underline: + first_block, last_block = self.get_buffer_block_numbers() + + for diagnostic in self._diagnostics: + if self.is_ipython() and ( + diagnostic["message"] == "undefined name 'get_ipython'"): + # get_ipython is defined in IPython files + continue + source = diagnostic.get('source', '') + msg_range = diagnostic['range'] + start = msg_range['start'] + end = msg_range['end'] + code = diagnostic.get('code', 'E') + message = diagnostic['message'] + severity = diagnostic.get( + 'severity', DiagnosticSeverity.ERROR) + + block = document.findBlockByNumber(start['line']) + text = block.text() + + # Skip messages according to certain criteria. + # This one works for any programming language + if 'analysis:ignore' in text: + continue + + # This only works for Python. + if self.language == 'Python': + if NOQA_INLINE_REGEXP.search(text) is not None: + continue + + data = block.userData() + if not data: + data = BlockUserData(self) + + if underline: + block_nb = block.blockNumber() + if first_block <= block_nb <= last_block: + error = severity == DiagnosticSeverity.ERROR + color = self.error_color if error else self.warning_color + color = QColor(color) + color.setAlpha(255) + block.color = color + + data.selection_start = start + data.selection_end = end + + self.highlight_selection('code_analysis_underline', + data._selection(), + underline_color=block.color) + else: + # Don't append messages to data for cloned editors to avoid + # showing them twice or more times on hover. + # Fixes spyder-ide/spyder#15618 + if not self.is_cloned: + data.code_analysis.append( + (source, code, severity, message) + ) + block.setUserData(data) + + # ------------- LSP: Completion --------------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_COMPLETION) + def do_completion(self, automatic=False): + """Trigger completion.""" + cursor = self.textCursor() + current_word = self.get_current_word( + completion=True, + valid_python_variable=False + ) + + params = { + 'file': self.filename, + 'line': cursor.blockNumber(), + 'column': cursor.columnNumber(), + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + 'current_word': current_word + } + self.completion_args = (self.textCursor().position(), automatic) + return params + + @handles(CompletionRequestTypes.DOCUMENT_COMPLETION) + def process_completion(self, params): + """Handle completion response.""" + args = self.completion_args + if args is None: + # This should not happen + return + self.completion_args = None + position, automatic = args + + start_cursor = self.textCursor() + start_cursor.movePosition(QTextCursor.StartOfBlock) + line_text = self.get_text(start_cursor.position(), 'eol') + leading_whitespace = self.compute_whitespace(line_text) + indentation_whitespace = ' ' * leading_whitespace + eol_char = self.get_line_separator() + + try: + completions = params['params'] + completions = ([] if completions is None else + [completion for completion in completions + if completion.get('insertText') + or completion.get('textEdit', {}).get('newText')]) + prefix = self.get_current_word(completion=True, + valid_python_variable=False) + if (len(completions) == 1 + and completions[0].get('insertText') == prefix + and not completions[0].get('textEdit', {}).get('newText')): + completions.pop() + + replace_end = self.textCursor().position() + under_cursor = self.get_current_word_and_position(completion=True) + if under_cursor: + word, replace_start = under_cursor + else: + word = '' + replace_start = replace_end + first_letter = '' + if len(word) > 0: + first_letter = word[0] + + def sort_key(completion): + if 'textEdit' in completion: + text_insertion = completion['textEdit']['newText'] + else: + text_insertion = completion['insertText'] + first_insert_letter = text_insertion[0] + case_mismatch = ( + (first_letter.isupper() and first_insert_letter.islower()) + or + (first_letter.islower() and first_insert_letter.isupper()) + ) + # False < True, so case matches go first + return (case_mismatch, completion['sortText']) + + completion_list = sorted(completions, key=sort_key) + + # Allow for textEdit completions to be filtered by Spyder + # if on-the-fly completions are disabled, only if the + # textEdit range matches the word under the cursor. + for completion in completion_list: + if 'textEdit' in completion: + c_replace_start = completion['textEdit']['range']['start'] + c_replace_end = completion['textEdit']['range']['end'] + if (c_replace_start == replace_start + and c_replace_end == replace_end): + insert_text = completion['textEdit']['newText'] + completion['filterText'] = insert_text + completion['insertText'] = insert_text + del completion['textEdit'] + + if 'insertText' in completion: + insert_text = completion['insertText'] + insert_text_lines = insert_text.splitlines() + reindented_text = [insert_text_lines[0]] + for insert_line in insert_text_lines[1:]: + insert_line = indentation_whitespace + insert_line + reindented_text.append(insert_line) + reindented_text = eol_char.join(reindented_text) + completion['insertText'] = reindented_text + + self.completion_widget.show_list( + completion_list, position, automatic) + + self.kite_call_to_action.handle_processed_completions(completions) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + self.kite_call_to_action.hide_coverage_cta() + return + except Exception: + self.log_lsp_handle_errors('Error when processing completions') + + @schedule_request(method=CompletionRequestTypes.COMPLETION_RESOLVE) + def resolve_completion_item(self, item): + return { + 'file': self.filename, + 'completion_item': item + } + + @handles(CompletionRequestTypes.COMPLETION_RESOLVE) + def handle_completion_item_resolution(self, response): + try: + response = response['params'] + + if not response: + return + + self.completion_widget.augment_completion_info(response) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors( + "Error when handling completion item resolution") + + # ------------- LSP: Signature Hints ------------------------------------ + @schedule_request(method=CompletionRequestTypes.DOCUMENT_SIGNATURE) + def request_signature(self): + """Ask for signature.""" + line, column = self.get_cursor_line_column() + offset = self.get_position('cursor') + params = { + 'file': self.filename, + 'line': line, + 'column': column, + 'offset': offset + } + return params + + @handles(CompletionRequestTypes.DOCUMENT_SIGNATURE) + def process_signatures(self, params): + """Handle signature response.""" + try: + signature_params = params['params'] + + if (signature_params is not None and + 'activeParameter' in signature_params): + self.sig_signature_invoked.emit(signature_params) + signature_data = signature_params['signatures'] + documentation = signature_data['documentation'] + + if isinstance(documentation, dict): + documentation = documentation['value'] + + # The language server returns encoded text with + # spaces defined as `\xa0` + documentation = documentation.replace(u'\xa0', ' ') + + parameter_idx = signature_params['activeParameter'] + parameters = signature_data['parameters'] + parameter = None + if len(parameters) > 0 and parameter_idx < len(parameters): + parameter_data = parameters[parameter_idx] + parameter = parameter_data['label'] + + signature = signature_data['label'] + + # This method is part of spyder/widgets/mixins + self.show_calltip( + signature=signature, + parameter=parameter, + language=self.language, + documentation=documentation, + ) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing signature") + + # ------------- LSP: Hover/Mouse --------------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_CURSOR_EVENT) + def request_cursor_event(self): + text = self.get_text_with_eol() + cursor = self.textCursor() + params = { + 'file': self.filename, + 'version': self.text_version, + 'text': text, + 'offset': cursor.position(), + 'selection_start': cursor.selectionStart(), + 'selection_end': cursor.selectionEnd(), + } + return params + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_HOVER) + def request_hover(self, line, col, offset, show_hint=True, clicked=True): + """Request hover information.""" + params = { + 'file': self.filename, + 'line': line, + 'column': col, + 'offset': offset + } + self._show_hint = show_hint + self._request_hover_clicked = clicked + return params + + @handles(CompletionRequestTypes.DOCUMENT_HOVER) + def handle_hover_response(self, contents): + """Handle hover response.""" + if running_under_pytest(): + from unittest.mock import Mock + + # On some tests this is returning a Mock + if isinstance(contents, Mock): + return + + try: + content = contents['params'] + + # - Don't display hover if there's no content to display. + # - Prevent spurious errors when a client returns a list. + if not content or isinstance(content, list): + return + + self.sig_display_object_info.emit( + content, + self._request_hover_clicked + ) + if content is not None and self._show_hint and self._last_point: + # This is located in spyder/widgets/mixins.py + word = self._last_hover_word + content = content.replace(u'\xa0', ' ') + self.show_hint(content, inspect_word=word, + at_point=self._last_point) + self._last_point = None + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing hover") + + # ------------- LSP: Go To Definition ---------------------------- + @Slot() + @schedule_request(method=CompletionRequestTypes.DOCUMENT_DEFINITION) + def go_to_definition_from_cursor(self, cursor=None): + """Go to definition from cursor instance (QTextCursor).""" + if (not self.go_to_definition_enabled or + self.in_comment_or_string()): + return + + if cursor is None: + cursor = self.textCursor() + + text = to_text_string(cursor.selectedText()) + + if len(text) == 0: + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + if text is not None: + line, column = self.get_cursor_line_column() + params = { + 'file': self.filename, + 'line': line, + 'column': column + } + return params + + @handles(CompletionRequestTypes.DOCUMENT_DEFINITION) + def handle_go_to_definition(self, position): + """Handle go to definition response.""" + try: + position = position['params'] + if position is not None: + def_range = position['range'] + start = def_range['start'] + if self.filename == position['file']: + self.go_to_line(start['line'] + 1, + start['character'], + None, + word=None) + else: + self.go_to_definition.emit(position['file'], + start['line'] + 1, + start['character']) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors( + "Error when processing go to definition") + + # ------------- LSP: Document/Selection formatting -------------------- + def format_document_or_range(self): + if self.has_selected_text() and self.range_formatting_enabled: + self.format_document_range() + else: + self.format_document() + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_FORMATTING) + def format_document(self): + if not self.formatting_enabled: + return + if self.formatting_in_progress: + # Already waiting for a formatting + return + + using_spaces = self.indent_chars != '\t' + tab_size = (len(self.indent_chars) if using_spaces else + self.tab_stop_width_spaces) + params = { + 'file': self.filename, + 'options': { + 'tab_size': tab_size, + 'insert_spaces': using_spaces, + 'trim_trailing_whitespace': self.remove_trailing_spaces, + 'insert_final_new_line': self.add_newline, + 'trim_final_new_lines': self.remove_trailing_newlines + } + } + + # Sets the document into read-only and updates its corresponding + # tab name to display the filename into parenthesis + self.setReadOnly(True) + self.document().setModified(True) + self.sig_start_operation_in_progress.emit() + self.operation_in_progress = True + self.formatting_in_progress = True + + return params + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) + def format_document_range(self): + if not self.range_formatting_enabled or not self.has_selected_text(): + return + if self.formatting_in_progress: + # Already waiting for a formatting + return + + start, end = self.get_selection_start_end() + start_line, start_col = start + end_line, end_col = end + using_spaces = self.indent_chars != '\t' + tab_size = (len(self.indent_chars) if using_spaces else + self.tab_stop_width_spaces) + + fmt_range = { + 'start': { + 'line': start_line, + 'character': start_col + }, + 'end': { + 'line': end_line, + 'character': end_col + } + } + params = { + 'file': self.filename, + 'range': fmt_range, + 'options': { + 'tab_size': tab_size, + 'insert_spaces': using_spaces, + 'trim_trailing_whitespace': self.remove_trailing_spaces, + 'insert_final_new_line': self.add_newline, + 'trim_final_new_lines': self.remove_trailing_newlines + } + } + + # Sets the document into read-only and updates its corresponding + # tab name to display the filename into parenthesis + self.setReadOnly(True) + self.document().setModified(True) + self.sig_start_operation_in_progress.emit() + self.operation_in_progress = True + self.formatting_in_progress = True + + return params + + @handles(CompletionRequestTypes.DOCUMENT_FORMATTING) + def handle_document_formatting(self, edits): + try: + if self.formatting_in_progress: + self._apply_document_edits(edits) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing document " + "formatting") + finally: + # Remove read-only parenthesis and highlight document modification + self.setReadOnly(False) + self.document().setModified(False) + self.document().setModified(True) + self.sig_stop_operation_in_progress.emit() + self.operation_in_progress = False + self.formatting_in_progress = False + + @handles(CompletionRequestTypes.DOCUMENT_RANGE_FORMATTING) + def handle_document_range_formatting(self, edits): + try: + if self.formatting_in_progress: + self._apply_document_edits(edits) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing document " + "selection formatting") + finally: + # Remove read-only parenthesis and highlight document modification + self.setReadOnly(False) + self.document().setModified(False) + self.document().setModified(True) + self.sig_stop_operation_in_progress.emit() + self.operation_in_progress = False + self.formatting_in_progress = False + + def _apply_document_edits(self, edits): + """Apply a set of atomic document edits to the current editor text.""" + edits = edits['params'] + if edits is None: + return + + # We need to use here toPlainText (which returns text with '\n' + # for eols) and not get_text_with_eol, so that applying the + # text edits that come from the LSP in the way implemented below + # works as expected. That's because we assume eol chars of length + # one in our algorithm. + # Fixes spyder-ide/spyder#16180 + text = self.toPlainText() + + text_tokens = list(text) + merged_text = None + for edit in edits: + edit_range = edit['range'] + repl_text = edit['newText'] + start, end = edit_range['start'], edit_range['end'] + start_line, start_col = start['line'], start['character'] + end_line, end_col = end['line'], end['character'] + + start_pos = self.get_position_line_number(start_line, start_col) + end_pos = self.get_position_line_number(end_line, end_col) + + # Replace repl_text eols for '\n' to match the ones used in + # `text`. + repl_eol = sourcecode.get_eol_chars(repl_text) + if repl_eol is not None and repl_eol != '\n': + repl_text = repl_text.replace(repl_eol, '\n') + + text_tokens = list(text_tokens) + this_edit = list(repl_text) + + if end_line == self.document().blockCount(): + end_pos = self.get_position('eof') + end_pos += 1 + + if (end_pos == len(text_tokens) and + text_tokens[end_pos - 1] == '\n'): + end_pos += 1 + + this_edition = (text_tokens[:max(start_pos - 1, 0)] + + this_edit + + text_tokens[end_pos - 1:]) + + text_edit = ''.join(this_edition) + if merged_text is None: + merged_text = text_edit + else: + merged_text = merge(text_edit, merged_text, text) + + if merged_text is not None: + # Restore eol chars after applying edits. + merged_text = merged_text.replace('\n', self.get_line_separator()) + + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.Start) + cursor.movePosition(QTextCursor.End, + QTextCursor.KeepAnchor) + cursor.insertText(merged_text) + cursor.endEditBlock() + + # ------------- LSP: Code folding ranges ------------------------------- + def compute_whitespace(self, line): + tab_size = self.tab_stop_width_spaces + whitespace_regex = re.compile(r'(\s+).*') + whitespace_match = whitespace_regex.match(line) + total_whitespace = 0 + if whitespace_match is not None: + whitespace_chars = whitespace_match.group(1) + whitespace_chars = whitespace_chars.replace( + '\t', tab_size * ' ') + total_whitespace = len(whitespace_chars) + return total_whitespace + + def update_whitespace_count(self, line, column): + self.leading_whitespaces = {} + lines = to_text_string(self.toPlainText()).splitlines() + for i, text in enumerate(lines): + total_whitespace = self.compute_whitespace(text) + self.leading_whitespaces[i] = total_whitespace + + def cleanup_folding(self): + """Cleanup folding pane.""" + folding_panel = self.panels.get(FoldingPanel) + folding_panel.folding_regions = {} + + @schedule_request(method=CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) + def request_folding(self): + """Request folding.""" + if not self.folding_supported or not self.code_folding: + return + params = {'file': self.filename} + return params + + @handles(CompletionRequestTypes.DOCUMENT_FOLDING_RANGE) + def handle_folding_range(self, response): + """Handle folding response.""" + ranges = response['params'] + if ranges is None: + return + + # Compute extended_ranges here because get_text_region ends up + # calling paintEvent and that method can't be called in a + # thread due to Qt restrictions. + try: + extended_ranges = [] + for start, end in ranges: + text_region = self.get_text_region(start, end) + extended_ranges.append((start, end, text_region)) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing folding") + + # Update folding in a thread + self.update_folding_thread.run = functools.partial( + self.update_and_merge_folding, extended_ranges) + self.update_folding_thread.start() + + def update_and_merge_folding(self, extended_ranges): + """Update and merge new folding information.""" + try: + folding_panel = self.panels.get(FoldingPanel) + + current_tree, root = merge_folding( + extended_ranges, folding_panel.current_tree, + folding_panel.root) + + folding_info = collect_folding_regions(root) + self._folding_info = (current_tree, root, *folding_info) + except RuntimeError: + # This is triggered when a codeeditor instance was removed + # before the response can be processed. + return + except Exception: + self.log_lsp_handle_errors("Error when processing folding") + + def finish_code_folding(self): + """Finish processing code folding.""" + folding_panel = self.panels.get(FoldingPanel) + folding_panel.update_folding(self._folding_info) + + # Update indent guides, which depend on folding + if self.indent_guides._enabled and len(self.patch) > 0: + line, column = self.get_cursor_line_column() + self.update_whitespace_count(line, column) + + # ------------- LSP: Save/close file ----------------------------------- + @schedule_request(method=CompletionRequestTypes.DOCUMENT_DID_SAVE, + requires_response=False) + def notify_save(self): + """Send save request.""" + params = {'file': self.filename} + if self.save_include_text: + params['text'] = self.get_text_with_eol() + return params + + @request(method=CompletionRequestTypes.DOCUMENT_DID_CLOSE, + requires_response=False) + def notify_close(self): + """Send close request.""" + self._pending_server_requests = [] + self._server_requests_timer.stop() + if self.completions_available: + # This is necessary to prevent an error in our tests. + try: + # Servers can send an empty publishDiagnostics reply to clear + # diagnostics after they receive a didClose request. Since + # we also ask for symbols and folding when processing + # diagnostics, we need to prevent it from happening + # before sending that request here. + self._timer_sync_symbols_and_folding.timeout.disconnect( + self.sync_symbols_and_folding) + except (TypeError, RuntimeError): + pass + + params = { + 'file': self.filename, + 'codeeditor': self + } + return params + + # ------------------------------------------------------------------------- + def set_debug_panel(self, show_debug_panel, language): + """Enable/disable debug panel.""" + debugger_panel = self.panels.get(DebuggerPanel) + if (is_text_string(language) and + language.lower() in ALL_LANGUAGES['Python'] and + show_debug_panel): + debugger_panel.setVisible(True) + else: + debugger_panel.setVisible(False) + + def update_debugger_panel_state(self, state, last_step, force=False): + """Update debugger panel state.""" + debugger_panel = self.panels.get(DebuggerPanel) + if force: + debugger_panel.start_clean() + return + elif state and 'fname' in last_step: + fname = last_step['fname'] + if (fname and self.filename + and osp.normcase(fname) == osp.normcase(self.filename)): + debugger_panel.start_clean() + return + debugger_panel.stop_clean() + + def set_folding_panel(self, folding): + """Enable/disable folding panel.""" + folding_panel = self.panels.get(FoldingPanel) + folding_panel.setVisible(folding) + + def set_tab_mode(self, enable): + """ + enabled = tab always indent + (otherwise tab indents only when cursor is at the beginning of a line) + """ + self.tab_mode = enable + + def set_strip_mode(self, enable): + """ + Strip all trailing spaces if enabled. + """ + self.strip_trailing_spaces_on_modify = enable + + def toggle_intelligent_backspace(self, state): + self.intelligent_backspace = state + + def toggle_automatic_completions(self, state): + self.automatic_completions = state + + def toggle_hover_hints(self, state): + self.hover_hints_enabled = state + + def toggle_code_snippets(self, state): + self.code_snippets = state + + def toggle_format_on_save(self, state): + self.format_on_save = state + + def toggle_code_folding(self, state): + self.code_folding = state + self.set_folding_panel(state) + if not state and self.indent_guides._enabled: + self.code_folding = True + + def toggle_identation_guides(self, state): + if state and not self.code_folding: + self.code_folding = True + self.indent_guides.set_enabled(state) + + def toggle_completions_hint(self, state): + """Enable/disable completion hint.""" + self.completions_hint = state + + def set_automatic_completions_after_chars(self, number): + """ + Set the number of characters after which auto completion is fired. + """ + self.automatic_completions_after_chars = number + + def set_automatic_completions_after_ms(self, ms): + """ + Set the amount of time in ms after which auto completion is fired. + """ + self.automatic_completions_after_ms = ms + + def set_completions_hint_after_ms(self, ms): + """ + Set the amount of time in ms after which the completions hint is shown. + """ + self.completions_hint_after_ms = ms + + def set_close_parentheses_enabled(self, enable): + """Enable/disable automatic parentheses insertion feature""" + self.close_parentheses_enabled = enable + bracket_extension = self.editor_extensions.get(CloseBracketsExtension) + if bracket_extension is not None: + bracket_extension.enabled = enable + + def set_close_quotes_enabled(self, enable): + """Enable/disable automatic quote insertion feature""" + self.close_quotes_enabled = enable + quote_extension = self.editor_extensions.get(CloseQuotesExtension) + if quote_extension is not None: + quote_extension.enabled = enable + + def set_add_colons_enabled(self, enable): + """Enable/disable automatic colons insertion feature""" + self.add_colons_enabled = enable + + def set_auto_unindent_enabled(self, enable): + """Enable/disable automatic unindent after else/elif/finally/except""" + self.auto_unindent_enabled = enable + + def set_occurrence_highlighting(self, enable): + """Enable/disable occurrence highlighting""" + self.occurrence_highlighting = enable + if not enable: + self.__clear_occurrences() + + def set_occurrence_timeout(self, timeout): + """Set occurrence highlighting timeout (ms)""" + self.occurrence_timer.setInterval(timeout) + + def set_underline_errors_enabled(self, state): + """Toggle the underlining of errors and warnings.""" + self.underline_errors_enabled = state + if not state: + self.clear_extra_selections('code_analysis_underline') + + def set_highlight_current_line(self, enable): + """Enable/disable current line highlighting""" + self.highlight_current_line_enabled = enable + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + + def set_highlight_current_cell(self, enable): + """Enable/disable current line highlighting""" + hl_cell_enable = enable and self.supported_cell_language + self.highlight_current_cell_enabled = hl_cell_enable + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + + def set_language(self, language, filename=None): + extra_supported_languages = {'stil': 'STIL'} + self.tab_indents = language in self.TAB_ALWAYS_INDENTS + self.comment_string = '' + self.language = 'Text' + self.supported_language = False + sh_class = sh.TextSH + language = 'None' if language is None else language + if language is not None: + for (key, value) in ALL_LANGUAGES.items(): + if language.lower() in value: + self.supported_language = True + sh_class, comment_string = self.LANGUAGES[key] + if key == 'IPython': + self.language = 'Python' + else: + self.language = key + self.comment_string = comment_string + if key in CELL_LANGUAGES: + self.supported_cell_language = True + self.has_cell_separators = True + break + + if filename is not None and not self.supported_language: + sh_class = sh.guess_pygments_highlighter(filename) + self.support_language = sh_class is not sh.TextSH + if self.support_language: + # Pygments report S for the lexer name of R files + if sh_class._lexer.name == 'S': + self.language = 'R' + else: + self.language = sh_class._lexer.name + else: + _, ext = osp.splitext(filename) + ext = ext.lower() + if ext in extra_supported_languages: + self.language = extra_supported_languages[ext] + + self._set_highlighter(sh_class) + self.completion_widget.set_language(self.language) + + def _set_highlighter(self, sh_class): + self.highlighter_class = sh_class + if self.highlighter is not None: + # Removing old highlighter + # TODO: test if leaving parent/document as is eats memory + self.highlighter.setParent(None) + self.highlighter.setDocument(None) + self.highlighter = self.highlighter_class(self.document(), + self.font(), + self.color_scheme) + self._apply_highlighter_color_scheme() + + self.highlighter.editor = self + self.highlighter.sig_font_changed.connect(self.sync_font) + self._rehighlight_timer.timeout.connect( + self.highlighter.rehighlight) + + def sync_font(self): + """Highlighter changed font, update.""" + self.setFont(self.highlighter.font) + self.sig_font_changed.emit() + + def get_cell_list(self): + """Get all cells.""" + if self.highlighter is None: + return [] + + # Filter out old cells + def good(oedata): + return oedata.is_valid() and oedata.def_type == oedata.CELL + + self.highlighter._cell_list = [ + oedata for oedata in self.highlighter._cell_list if good(oedata)] + + return sorted( + {oedata.block.blockNumber(): oedata + for oedata in self.highlighter._cell_list}.items()) + + def is_json(self): + return (isinstance(self.highlighter, sh.PygmentsSH) and + self.highlighter._lexer.name == 'JSON') + + def is_python(self): + return self.highlighter_class is sh.PythonSH + + def is_ipython(self): + return self.highlighter_class is sh.IPythonSH + + def is_python_or_ipython(self): + return self.is_python() or self.is_ipython() + + def is_cython(self): + return self.highlighter_class is sh.CythonSH + + def is_enaml(self): + return self.highlighter_class is sh.EnamlSH + + def is_python_like(self): + return (self.is_python() or self.is_ipython() + or self.is_cython() or self.is_enaml()) + + def intelligent_tab(self): + """Provide intelligent behavior for Tab key press.""" + leading_text = self.get_text('sol', 'cursor') + if not leading_text.strip() or leading_text.endswith('#'): + # blank line or start of comment + self.indent_or_replace() + elif self.in_comment_or_string() and not leading_text.endswith(' '): + # in a word in a comment + self.do_completion() + elif leading_text.endswith('import ') or leading_text[-1] == '.': + # blank import or dot completion + self.do_completion() + elif (leading_text.split()[0] in ['from', 'import'] and + ';' not in leading_text): + # import line with a single statement + # (prevents lines like: `import pdb; pdb.set_trace()`) + self.do_completion() + elif leading_text[-1] in '(,' or leading_text.endswith(', '): + self.indent_or_replace() + elif leading_text.endswith(' '): + # if the line ends with a space, indent + self.indent_or_replace() + elif re.search(r"[^\d\W]\w*\Z", leading_text, re.UNICODE): + # if the line ends with a non-whitespace character + self.do_completion() + else: + self.indent_or_replace() + + def intelligent_backtab(self): + """Provide intelligent behavior for Shift+Tab key press""" + leading_text = self.get_text('sol', 'cursor') + if not leading_text.strip(): + # blank line + self.unindent() + elif self.in_comment_or_string(): + self.unindent() + elif leading_text[-1] in '(,' or leading_text.endswith(', '): + position = self.get_position('cursor') + self.show_object_info(position) + else: + # if the line ends with any other character but comma + self.unindent() + + def rehighlight(self): + """Rehighlight the whole document.""" + if self.highlighter is not None: + self.highlighter.rehighlight() + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + + def trim_trailing_spaces(self): + """Remove trailing spaces""" + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.Start) + while True: + cursor.movePosition(QTextCursor.EndOfBlock) + text = to_text_string(cursor.block().text()) + length = len(text)-len(text.rstrip()) + if length > 0: + cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, + length) + cursor.removeSelectedText() + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock) + cursor.endEditBlock() + + def trim_trailing_newlines(self): + """Remove extra newlines at the end of the document.""" + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.End) + line = cursor.blockNumber() + this_line = self.get_text_line(line) + previous_line = self.get_text_line(line - 1) + + # Don't try to trim new lines for a file with a single line. + # Fixes spyder-ide/spyder#16401 + if self.get_line_count() > 1: + while this_line == '': + cursor.movePosition(QTextCursor.PreviousBlock, + QTextCursor.KeepAnchor) + + if self.add_newline: + if this_line == '' and previous_line != '': + cursor.movePosition(QTextCursor.NextBlock, + QTextCursor.KeepAnchor) + + line -= 1 + if line == 0: + break + + this_line = self.get_text_line(line) + previous_line = self.get_text_line(line - 1) + + if not self.add_newline: + cursor.movePosition(QTextCursor.EndOfBlock, + QTextCursor.KeepAnchor) + + cursor.removeSelectedText() + cursor.endEditBlock() + + def add_newline_to_file(self): + """Add a newline to the end of the file if it does not exist.""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + line = cursor.blockNumber() + this_line = self.get_text_line(line) + if this_line != '': + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.insertText(self.get_line_separator()) + cursor.endEditBlock() + + def fix_indentation(self): + """Replace tabs by spaces.""" + text_before = to_text_string(self.toPlainText()) + text_after = sourcecode.fix_indentation(text_before, self.indent_chars) + if text_before != text_after: + # We do the following rather than using self.setPlainText + # to benefit from QTextEdit's undo/redo feature. + self.selectAll() + self.skip_rstrip = True + self.insertPlainText(text_after) + self.skip_rstrip = False + + def get_current_object(self): + """Return current object (string) """ + source_code = to_text_string(self.toPlainText()) + offset = self.get_position('cursor') + return sourcecode.get_primary_at(source_code, offset) + + def next_cursor_position(self, position=None, + mode=QTextLayout.SkipCharacters): + """ + Get next valid cursor position. + + Adapted from: + https://github.com/qt/qtbase/blob/5.15.2/src/gui/text/qtextdocument_p.cpp#L1361 + """ + cursor = self.textCursor() + if cursor.atEnd(): + return position + if position is None: + position = cursor.position() + else: + cursor.setPosition(position) + it = cursor.block() + start = it.position() + end = start + it.length() - 1 + if (position == end): + return end + 1 + return it.layout().nextCursorPosition(position - start, mode) + start + + @Slot() + def delete(self): + """Remove selected text or next character.""" + if not self.has_selected_text(): + cursor = self.textCursor() + if not cursor.atEnd(): + cursor.setPosition( + self.next_cursor_position(), QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + self.remove_selected_text() + + #------Find occurrences + def __find_first(self, text): + """Find first occurrence: scan whole document""" + flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords + cursor = self.textCursor() + # Scanning whole document + cursor.movePosition(QTextCursor.Start) + regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) + cursor = self.document().find(regexp, cursor, flags) + self.__find_first_pos = cursor.position() + return cursor + + def __find_next(self, text, cursor): + """Find next occurrence""" + flags = QTextDocument.FindCaseSensitively|QTextDocument.FindWholeWords + regexp = QRegExp(r"\b%s\b" % QRegExp.escape(text), Qt.CaseSensitive) + cursor = self.document().find(regexp, cursor, flags) + if cursor.position() != self.__find_first_pos: + return cursor + + def __cursor_position_changed(self): + """Cursor position has changed""" + line, column = self.get_cursor_line_column() + self.sig_cursor_position_changed.emit(line, column) + + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + if self.occurrence_highlighting: + self.occurrence_timer.start() + + # Strip if needed + self.strip_trailing_spaces() + + def __clear_occurrences(self): + """Clear occurrence markers""" + self.occurrences = [] + self.clear_extra_selections('occurrences') + self.sig_flags_changed.emit() + + def get_selection(self, cursor, foreground_color=None, + background_color=None, underline_color=None, + outline_color=None, + underline_style=QTextCharFormat.SingleUnderline): + """Get selection.""" + if cursor is None: + return + + selection = TextDecoration(cursor) + if foreground_color is not None: + selection.format.setForeground(foreground_color) + if background_color is not None: + selection.format.setBackground(background_color) + if underline_color is not None: + selection.format.setProperty(QTextFormat.TextUnderlineStyle, + to_qvariant(underline_style)) + selection.format.setProperty(QTextFormat.TextUnderlineColor, + to_qvariant(underline_color)) + if outline_color is not None: + selection.set_outline(outline_color) + return selection + + def highlight_selection(self, key, cursor, foreground_color=None, + background_color=None, underline_color=None, + outline_color=None, + underline_style=QTextCharFormat.SingleUnderline): + + selection = self.get_selection( + cursor, foreground_color, background_color, underline_color, + outline_color, underline_style) + if selection is None: + return + extra_selections = self.get_extra_selections(key) + extra_selections.append(selection) + self.set_extra_selections(key, extra_selections) + + def __mark_occurrences(self): + """Marking occurrences of the currently selected word""" + self.__clear_occurrences() + + if not self.supported_language: + return + + text = self.get_selected_text().strip() + if not text: + text = self.get_current_word() + if text is None: + return + if (self.has_selected_text() and + self.get_selected_text().strip() != text): + return + + if (self.is_python_like() and + (sourcecode.is_keyword(to_text_string(text)) or + to_text_string(text) == 'self')): + return + + # Highlighting all occurrences of word *text* + cursor = self.__find_first(text) + self.occurrences = [] + extra_selections = self.get_extra_selections('occurrences') + first_occurrence = None + while cursor: + block = cursor.block() + if not block.userData(): + # Add user data to check block validity + block.setUserData(BlockUserData(self)) + self.occurrences.append(block) + + selection = self.get_selection(cursor) + if len(selection.cursor.selectedText()) > 0: + extra_selections.append(selection) + if len(extra_selections) == 1: + first_occurrence = selection + else: + selection.format.setBackground(self.occurrence_color) + first_occurrence.format.setBackground( + self.occurrence_color) + cursor = self.__find_next(text, cursor) + self.set_extra_selections('occurrences', extra_selections) + + if len(self.occurrences) > 1 and self.occurrences[-1] == 0: + # XXX: this is never happening with PySide but it's necessary + # for PyQt4... this must be related to a different behavior for + # the QTextDocument.find function between those two libraries + self.occurrences.pop(-1) + self.sig_flags_changed.emit() + + #-----highlight found results (find/replace widget) + def highlight_found_results(self, pattern, word=False, regexp=False, + case=False): + """Highlight all found patterns""" + pattern = to_text_string(pattern) + if not pattern: + return + if not regexp: + pattern = re.escape(to_text_string(pattern)) + pattern = r"\b%s\b" % pattern if word else pattern + text = to_text_string(self.toPlainText()) + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + try: + regobj = re.compile(pattern, flags=re_flags) + except sre_constants.error: + return + extra_selections = [] + self.found_results = [] + has_unicode = len(text) != qstring_length(text) + for match in regobj.finditer(text): + if has_unicode: + pos1, pos2 = sh.get_span(match) + else: + pos1, pos2 = match.span() + selection = TextDecoration(self.textCursor()) + selection.format.setBackground(self.found_results_color) + selection.cursor.setPosition(pos1) + + block = selection.cursor.block() + if not block.userData(): + # Add user data to check block validity + block.setUserData(BlockUserData(self)) + self.found_results.append(block) + + selection.cursor.setPosition(pos2, QTextCursor.KeepAnchor) + extra_selections.append(selection) + self.set_extra_selections('find', extra_selections) + + def clear_found_results(self): + """Clear found results highlighting""" + self.found_results = [] + self.clear_extra_selections('find') + self.sig_flags_changed.emit() + + def __text_has_changed(self): + """Text has changed, eventually clear found results highlighting""" + self.last_change_position = self.textCursor().position() + if self.found_results: + self.clear_found_results() + + def get_linenumberarea_width(self): + """ + Return current line number area width. + + This method is left for backward compatibility (BaseEditMixin + define it), any changes should be in LineNumberArea class. + """ + return self.linenumberarea.get_width() + + def calculate_real_position(self, point): + """Add offset to a point, to take into account the panels.""" + point.setX(point.x() + self.panels.margin_size(Panel.Position.LEFT)) + point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) + return point + + def calculate_real_position_from_global(self, point): + """Add offset to a point, to take into account the panels.""" + point.setX(point.x() - self.panels.margin_size(Panel.Position.LEFT)) + point.setY(point.y() + self.panels.margin_size(Panel.Position.TOP)) + return point + + def get_linenumber_from_mouse_event(self, event): + """Return line number from mouse event""" + block = self.firstVisibleBlock() + line_number = block.blockNumber() + top = self.blockBoundingGeometry(block).translated( + self.contentOffset()).top() + bottom = top + self.blockBoundingRect(block).height() + while block.isValid() and top < event.pos().y(): + block = block.next() + if block.isVisible(): # skip collapsed blocks + top = bottom + bottom = top + self.blockBoundingRect(block).height() + line_number += 1 + return line_number + + def select_lines(self, linenumber_pressed, linenumber_released): + """Select line(s) after a mouse press/mouse press drag event""" + find_block_by_number = self.document().findBlockByNumber + move_n_blocks = (linenumber_released - linenumber_pressed) + start_line = linenumber_pressed + start_block = find_block_by_number(start_line - 1) + + cursor = self.textCursor() + cursor.setPosition(start_block.position()) + + # Select/drag downwards + if move_n_blocks > 0: + for n in range(abs(move_n_blocks) + 1): + cursor.movePosition(cursor.NextBlock, cursor.KeepAnchor) + # Select/drag upwards or select single line + else: + cursor.movePosition(cursor.NextBlock) + for n in range(abs(move_n_blocks) + 1): + cursor.movePosition(cursor.PreviousBlock, cursor.KeepAnchor) + + # Account for last line case + if linenumber_released == self.blockCount(): + cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor) + else: + cursor.movePosition(cursor.StartOfBlock, cursor.KeepAnchor) + + self.setTextCursor(cursor) + + # ----- Code bookmarks + def add_bookmark(self, slot_num, line=None, column=None): + """Add bookmark to current block's userData.""" + if line is None: + # Triggered by shortcut, else by spyder start + line, column = self.get_cursor_line_column() + block = self.document().findBlockByNumber(line) + data = block.userData() + if not data: + data = BlockUserData(self) + if slot_num not in data.bookmarks: + data.bookmarks.append((slot_num, column)) + block.setUserData(data) + self._bookmarks_blocks[id(block)] = block + self.sig_bookmarks_changed.emit() + + def get_bookmarks(self): + """Get bookmarks by going over all blocks.""" + bookmarks = {} + pruned_bookmarks_blocks = {} + for block_id in self._bookmarks_blocks: + block = self._bookmarks_blocks[block_id] + if block.isValid(): + data = block.userData() + if data and data.bookmarks: + pruned_bookmarks_blocks[block_id] = block + line_number = block.blockNumber() + for slot_num, column in data.bookmarks: + bookmarks[slot_num] = [line_number, column] + self._bookmarks_blocks = pruned_bookmarks_blocks + return bookmarks + + def clear_bookmarks(self): + """Clear bookmarks for all blocks.""" + self.bookmarks = {} + for data in self.blockuserdata_list(): + data.bookmarks = [] + self._bookmarks_blocks = {} + + def set_bookmarks(self, bookmarks): + """Set bookmarks when opening file.""" + self.clear_bookmarks() + for slot_num, bookmark in bookmarks.items(): + self.add_bookmark(slot_num, bookmark[1], bookmark[2]) + + def update_bookmarks(self): + """Emit signal to update bookmarks.""" + self.sig_bookmarks_changed.emit() + + # -----Code introspection + def show_completion_object_info(self, name, signature): + """Trigger show completion info in Help Pane.""" + force = True + self.sig_show_completion_object_info.emit(name, signature, force) + + def show_object_info(self, position): + """Trigger a calltip""" + self.sig_show_object_info.emit(position) + + # -----blank spaces + def set_blanks_enabled(self, state): + """Toggle blanks visibility""" + self.blanks_enabled = state + option = self.document().defaultTextOption() + option.setFlags(option.flags() | \ + QTextOption.AddSpaceForLineAndParagraphSeparators) + if self.blanks_enabled: + option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) + else: + option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) + self.document().setDefaultTextOption(option) + # Rehighlight to make the spaces less apparent. + self.rehighlight() + + def set_scrollpastend_enabled(self, state): + """ + Allow user to scroll past the end of the document to have the last + line on top of the screen + """ + self.scrollpastend_enabled = state + self.setCenterOnScroll(state) + self.setDocument(self.document()) + + def resizeEvent(self, event): + """Reimplemented Qt method to handle p resizing""" + TextEditBaseWidget.resizeEvent(self, event) + self.panels.resize() + + def showEvent(self, event): + """Overrides showEvent to update the viewport margins.""" + super(CodeEditor, self).showEvent(event) + self.panels.refresh() + + #-----Misc. + def _apply_highlighter_color_scheme(self): + """Apply color scheme from syntax highlighter to the editor""" + hl = self.highlighter + if hl is not None: + self.set_palette(background=hl.get_background_color(), + foreground=hl.get_foreground_color()) + self.currentline_color = hl.get_currentline_color() + self.currentcell_color = hl.get_currentcell_color() + self.occurrence_color = hl.get_occurrence_color() + self.ctrl_click_color = hl.get_ctrlclick_color() + self.sideareas_color = hl.get_sideareas_color() + self.comment_color = hl.get_comment_color() + self.normal_color = hl.get_foreground_color() + self.matched_p_color = hl.get_matched_p_color() + self.unmatched_p_color = hl.get_unmatched_p_color() + + self.edge_line.update_color() + self.indent_guides.update_color() + + self.sig_theme_colors_changed.emit( + {'occurrence': self.occurrence_color}) + + def apply_highlighter_settings(self, color_scheme=None): + """Apply syntax highlighter settings""" + if self.highlighter is not None: + # Updating highlighter settings (font and color scheme) + self.highlighter.setup_formats(self.font()) + if color_scheme is not None: + self.set_color_scheme(color_scheme) + else: + self._rehighlight_timer.start() + + def set_font(self, font, color_scheme=None): + """Set font""" + # Note: why using this method to set color scheme instead of + # 'set_color_scheme'? To avoid rehighlighting the document twice + # at startup. + if color_scheme is not None: + self.color_scheme = color_scheme + self.setFont(font) + self.panels.refresh() + self.apply_highlighter_settings(color_scheme) + + def set_color_scheme(self, color_scheme): + """Set color scheme for syntax highlighting""" + self.color_scheme = color_scheme + if self.highlighter is not None: + # this calls self.highlighter.rehighlight() + self.highlighter.set_color_scheme(color_scheme) + self._apply_highlighter_color_scheme() + if self.highlight_current_cell_enabled: + self.highlight_current_cell() + else: + self.unhighlight_current_cell() + if self.highlight_current_line_enabled: + self.highlight_current_line() + else: + self.unhighlight_current_line() + + def set_text(self, text): + """Set the text of the editor""" + self.setPlainText(text) + self.set_eol_chars(text=text) + + if (isinstance(self.highlighter, sh.PygmentsSH) + and not running_under_pytest()): + self.highlighter.make_charlist() + + def set_text_from_file(self, filename, language=None): + """Set the text of the editor from file *fname*""" + self.filename = filename + text, _enc = encoding.read(filename) + if language is None: + language = get_file_language(filename, text) + self.set_language(language, filename) + self.set_text(text) + + def append(self, text): + """Append text to the end of the text widget""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.insertText(text) + + def adjust_indentation(self, line, indent_adjustment): + """Adjust indentation.""" + if indent_adjustment == 0 or line == "": + return line + using_spaces = self.indent_chars != '\t' + + if indent_adjustment > 0: + if using_spaces: + return ' ' * indent_adjustment + line + else: + return ( + self.indent_chars + * (indent_adjustment // self.tab_stop_width_spaces) + + line) + + max_indent = self.get_line_indentation(line) + indent_adjustment = min(max_indent, -indent_adjustment) + + indent_adjustment = (indent_adjustment if using_spaces else + indent_adjustment // self.tab_stop_width_spaces) + + return line[indent_adjustment:] + + @Slot() + def paste(self): + """ + Insert text or file/folder path copied from clipboard. + + Reimplement QPlainTextEdit's method to fix the following issue: + on Windows, pasted text has only 'LF' EOL chars even if the original + text has 'CRLF' EOL chars. + The function also changes the clipboard data if they are copied as + files/folders but does not change normal text data except if they are + multiple lines. Since we are changing clipboard data we cannot use + paste, which directly pastes from clipboard instead we use + insertPlainText and pass the formatted/changed text without modifying + clipboard content. + """ + clipboard = QApplication.clipboard() + text = to_text_string(clipboard.text()) + + if clipboard.mimeData().hasUrls(): + # Have copied file and folder urls pasted as text paths. + # See spyder-ide/spyder#8644 for details. + urls = clipboard.mimeData().urls() + if all([url.isLocalFile() for url in urls]): + if len(urls) > 1: + sep_chars = ',' + self.get_line_separator() + text = sep_chars.join('"' + url.toLocalFile(). + replace(osp.os.sep, '/') + + '"' for url in urls) + else: + # The `urls` list can be empty, so we need to check that + # before proceeding. + # Fixes spyder-ide/spyder#17521 + if urls: + text = urls[0].toLocalFile().replace(osp.os.sep, '/') + + eol_chars = self.get_line_separator() + if len(text.splitlines()) > 1: + text = eol_chars.join((text + eol_chars).splitlines()) + + # Align multiline text based on first line + cursor = self.textCursor() + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.setPosition(cursor.selectionStart()) + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + preceding_text = cursor.selectedText() + first_line_selected, *remaining_lines = (text + eol_chars).splitlines() + first_line = preceding_text + first_line_selected + + first_line_adjustment = 0 + + # Dedent if automatic indentation makes code invalid + # Minimum indentation = max of current and paster indentation + if (self.is_python_like() and len(preceding_text.strip()) == 0 + and len(first_line.strip()) > 0): + # Correct indentation + desired_indent = self.find_indentation() + if desired_indent: + # minimum indentation is either the current indentation + # or the indentation of the paster text + desired_indent = max( + desired_indent, + self.get_line_indentation(first_line_selected), + self.get_line_indentation(preceding_text)) + first_line_adjustment = ( + desired_indent - self.get_line_indentation(first_line)) + # Only dedent, don't indent + first_line_adjustment = min(first_line_adjustment, 0) + # Only dedent, don't indent + first_line = self.adjust_indentation( + first_line, first_line_adjustment) + + # Fix indentation of multiline text based on first line + if len(remaining_lines) > 0 and len(first_line.strip()) > 0: + lines_adjustment = first_line_adjustment + lines_adjustment += CLIPBOARD_HELPER.remaining_lines_adjustment( + preceding_text) + + # Make sure the code is not flattened + indentations = [ + self.get_line_indentation(line) + for line in remaining_lines if line.strip() != ""] + if indentations: + max_dedent = min(indentations) + lines_adjustment = max(lines_adjustment, -max_dedent) + # Get new text + remaining_lines = [ + self.adjust_indentation(line, lines_adjustment) + for line in remaining_lines] + + text = eol_chars.join([first_line, *remaining_lines]) + + self.skip_rstrip = True + self.sig_will_paste_text.emit(text) + cursor.removeSelectedText() + cursor.insertText(text) + cursor.endEditBlock() + self.sig_text_was_inserted.emit() + + self.skip_rstrip = False + + def _save_clipboard_indentation(self): + """ + Save the indentation corresponding to the clipboard data. + + Must be called right after copying. + """ + cursor = self.textCursor() + cursor.setPosition(cursor.selectionStart()) + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + preceding_text = cursor.selectedText() + CLIPBOARD_HELPER.save_indentation( + preceding_text, self.tab_stop_width_spaces) + + @Slot() + def cut(self): + """Reimplement cut to signal listeners about changes on the text.""" + has_selected_text = self.has_selected_text() + if not has_selected_text: + return + start, end = self.get_selection_start_end() + self.sig_will_remove_selection.emit(start, end) + TextEditBaseWidget.cut(self) + self._save_clipboard_indentation() + self.sig_text_was_inserted.emit() + + @Slot() + def copy(self): + """Reimplement copy to save indentation.""" + TextEditBaseWidget.copy(self) + self._save_clipboard_indentation() + + @Slot() + def undo(self): + """Reimplement undo to decrease text version number.""" + if self.document().isUndoAvailable(): + self.text_version -= 1 + self.skip_rstrip = True + self.is_undoing = True + TextEditBaseWidget.undo(self) + self.sig_undo.emit() + self.sig_text_was_inserted.emit() + self.is_undoing = False + self.skip_rstrip = False + + @Slot() + def redo(self): + """Reimplement redo to increase text version number.""" + if self.document().isRedoAvailable(): + self.text_version += 1 + self.skip_rstrip = True + self.is_redoing = True + TextEditBaseWidget.redo(self) + self.sig_redo.emit() + self.sig_text_was_inserted.emit() + self.is_redoing = False + self.skip_rstrip = False + + # ========================================================================= + # High-level editor features + # ========================================================================= + @Slot() + def center_cursor_on_next_focus(self): + """QPlainTextEdit's "centerCursor" requires the widget to be visible""" + self.centerCursor() + self.focus_in.disconnect(self.center_cursor_on_next_focus) + + def go_to_line(self, line, start_column=0, end_column=0, word=''): + """Go to line number *line* and eventually highlight it""" + self.text_helper.goto_line(line, column=start_column, + end_column=end_column, move=True, + word=word) + + def exec_gotolinedialog(self): + """Execute the GoToLineDialog dialog box""" + dlg = GoToLineDialog(self) + if dlg.exec_(): + self.go_to_line(dlg.get_line_number()) + + def hide_tooltip(self): + """ + Hide the tooltip widget. + + The tooltip widget is a special QLabel that looks like a tooltip, + this method is here so it can be hidden as necessary. For example, + when the user leaves the Linenumber area when hovering over lint + warnings and errors. + """ + self._timer_mouse_moving.stop() + self._last_hover_word = None + self.clear_extra_selections('code_analysis_highlight') + if self.tooltip_widget.isVisible(): + self.tooltip_widget.hide() + + def _set_completions_hint_idle(self): + self._completions_hint_idle = True + self.completion_widget.trigger_completion_hint() + + # --- Hint for completions + def show_hint_for_completion(self, word, documentation, at_point): + """Show hint for completion element.""" + if self.completions_hint and self._completions_hint_idle: + documentation = documentation.replace(u'\xa0', ' ') + completion_doc = {'name': word, + 'signature': documentation} + + if documentation and len(documentation) > 0: + self.show_hint( + documentation, + inspect_word=word, + at_point=at_point, + completion_doc=completion_doc, + max_lines=self._DEFAULT_MAX_LINES, + max_width=self._DEFAULT_COMPLETION_HINT_MAX_WIDTH) + self.tooltip_widget.move(at_point) + return + self.hide_tooltip() + + def update_decorations(self): + """Update decorations on the visible portion of the screen.""" + if self.underline_errors_enabled: + self.underline_errors() + + # This is required to update decorations whether there are or not + # underline errors in the visible portion of the screen. + # See spyder-ide/spyder#14268. + self.decorations.update() + + def show_code_analysis_results(self, line_number, block_data): + """Show warning/error messages.""" + # Diagnostic severity + icons = { + DiagnosticSeverity.ERROR: 'error', + DiagnosticSeverity.WARNING: 'warning', + DiagnosticSeverity.INFORMATION: 'information', + DiagnosticSeverity.HINT: 'hint', + } + + code_analysis = block_data.code_analysis + + # Size must be adapted from font + fm = self.fontMetrics() + size = fm.height() + template = ( + ' ' + '{} ({} {})' + ) + + msglist = [] + max_lines_msglist = 25 + sorted_code_analysis = sorted(code_analysis, key=lambda i: i[2]) + for src, code, sev, msg in sorted_code_analysis: + if src == 'pylint' and '[' in msg and ']' in msg: + # Remove extra redundant info from pylint messages + msg = msg.split(']')[-1] + + msg = msg.strip() + # Avoid messing TODO, FIXME + # Prevent error if msg only has one element + if len(msg) > 1: + msg = msg[0].upper() + msg[1:] + + # Get individual lines following paragraph format and handle + # symbols like '<' and '>' to not mess with br tags + msg = msg.replace('<', '<').replace('>', '>') + paragraphs = msg.splitlines() + new_paragraphs = [] + long_paragraphs = 0 + lines_per_message = 6 + for paragraph in paragraphs: + new_paragraph = textwrap.wrap( + paragraph, + width=self._DEFAULT_MAX_HINT_WIDTH) + if lines_per_message > 2: + if len(new_paragraph) > 1: + new_paragraph = '
'.join(new_paragraph[:2]) + '...' + long_paragraphs += 1 + lines_per_message -= 2 + else: + new_paragraph = '
'.join(new_paragraph) + lines_per_message -= 1 + new_paragraphs.append(new_paragraph) + + if len(new_paragraphs) > 1: + # Define max lines taking into account that in the same + # tooltip you can find multiple warnings and messages + # and each one can have multiple lines + if long_paragraphs != 0: + max_lines = 3 + max_lines_msglist -= max_lines * 2 + else: + max_lines = 5 + max_lines_msglist -= max_lines + msg = '
'.join(new_paragraphs[:max_lines]) + '
' + else: + msg = '
'.join(new_paragraphs) + + base_64 = ima.base64_from_icon(icons[sev], size, size) + if max_lines_msglist >= 0: + msglist.append(template.format(base_64, msg, src, + code, size=size)) + + if msglist: + self.show_tooltip( + title=_("Code analysis"), + text='\n'.join(msglist), + title_color=QStylePalette.COLOR_ACCENT_4, + at_line=line_number, + with_html_format=True + ) + self.highlight_line_warning(block_data) + + def highlight_line_warning(self, block_data): + """Highlight errors and warnings in this editor.""" + self.clear_extra_selections('code_analysis_highlight') + self.highlight_selection('code_analysis_highlight', + block_data._selection(), + background_color=block_data.color) + self.linenumberarea.update() + + def get_current_warnings(self): + """ + Get all warnings for the current editor and return + a list with the message and line number. + """ + block = self.document().firstBlock() + line_count = self.document().blockCount() + warnings = [] + while True: + data = block.userData() + if data and data.code_analysis: + for warning in data.code_analysis: + warnings.append([warning[-1], block.blockNumber() + 1]) + # See spyder-ide/spyder#9924 + if block.blockNumber() + 1 == line_count: + break + block = block.next() + return warnings + + def go_to_next_warning(self): + """ + Go to next code warning message and return new cursor position. + """ + block = self.textCursor().block() + line_count = self.document().blockCount() + for __ in range(line_count): + line_number = block.blockNumber() + 1 + if line_number < line_count: + block = block.next() + else: + block = self.document().firstBlock() + + data = block.userData() + if data and data.code_analysis: + line_number = block.blockNumber() + 1 + self.go_to_line(line_number) + self.show_code_analysis_results(line_number, data) + return self.get_position('cursor') + + def go_to_previous_warning(self): + """ + Go to previous code warning message and return new cursor position. + """ + block = self.textCursor().block() + line_count = self.document().blockCount() + for __ in range(line_count): + line_number = block.blockNumber() + 1 + if line_number > 1: + block = block.previous() + else: + block = self.document().lastBlock() + + data = block.userData() + if data and data.code_analysis: + line_number = block.blockNumber() + 1 + self.go_to_line(line_number) + self.show_code_analysis_results(line_number, data) + return self.get_position('cursor') + + def cell_list(self): + """Get the outline explorer data for all cells.""" + for oedata in self.outlineexplorer_data_list(): + if oedata.def_type == OED.CELL: + yield oedata + + def get_cell_code(self, cell): + """ + Get cell code for a given cell. + + If the cell doesn't exist, raises an exception + """ + selected_block = None + if is_string(cell): + for oedata in self.cell_list(): + if oedata.def_name == cell: + selected_block = oedata.block + break + else: + if cell == 0: + selected_block = self.document().firstBlock() + else: + cell_list = list(self.cell_list()) + if cell <= len(cell_list): + selected_block = cell_list[cell - 1].block + + if not selected_block: + raise RuntimeError("Cell {} not found.".format(repr(cell))) + + cursor = QTextCursor(selected_block) + cell_code, _ = self.get_cell_as_executable_code(cursor) + return cell_code + + def get_cell_count(self): + """Get number of cells in document.""" + return 1 + len(list(self.cell_list())) + + #------Tasks management + def go_to_next_todo(self): + """Go to next todo and return new cursor position""" + block = self.textCursor().block() + line_count = self.document().blockCount() + while True: + if block.blockNumber()+1 < line_count: + block = block.next() + else: + block = self.document().firstBlock() + data = block.userData() + if data and data.todo: + break + line_number = block.blockNumber()+1 + self.go_to_line(line_number) + self.show_tooltip( + title=_("To do"), + text=data.todo, + title_color=QStylePalette.COLOR_ACCENT_4, + at_line=line_number, + ) + + return self.get_position('cursor') + + def process_todo(self, todo_results): + """Process todo finder results""" + for data in self.blockuserdata_list(): + data.todo = '' + + for message, line_number in todo_results: + block = self.document().findBlockByNumber(line_number - 1) + data = block.userData() + if not data: + data = BlockUserData(self) + data.todo = message + block.setUserData(data) + self.sig_flags_changed.emit() + + #------Comments/Indentation + def add_prefix(self, prefix): + """Add prefix to current line or selected line(s)""" + cursor = self.textCursor() + if self.has_selected_text(): + # Add prefix to selected line(s) + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + + # Let's see if selection begins at a block start + first_pos = min([start_pos, end_pos]) + first_cursor = self.textCursor() + first_cursor.setPosition(first_pos) + + cursor.beginEditBlock() + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock) + if cursor.position() < start_pos: + cursor.setPosition(start_pos) + move_number = self.__spaces_for_prefix() + + while cursor.position() >= start_pos: + cursor.movePosition(QTextCursor.StartOfBlock) + line_text = to_text_string(cursor.block().text()) + if (self.get_character(cursor.position()) == ' ' + and '#' in prefix and not line_text.isspace() + or (not line_text.startswith(' ') + and line_text != '')): + cursor.movePosition(QTextCursor.Right, + QTextCursor.MoveAnchor, + move_number) + cursor.insertText(prefix) + elif '#' not in prefix: + cursor.insertText(prefix) + if cursor.blockNumber() == 0: + # Avoid infinite loop when indenting the very first line + break + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.endEditBlock() + else: + # Add prefix to current line + cursor.beginEditBlock() + cursor.movePosition(QTextCursor.StartOfBlock) + if self.get_character(cursor.position()) == ' ' and '#' in prefix: + cursor.movePosition(QTextCursor.NextWord) + cursor.insertText(prefix) + cursor.endEditBlock() + + def __spaces_for_prefix(self): + """Find the less indented level of text.""" + cursor = self.textCursor() + if self.has_selected_text(): + # Add prefix to selected line(s) + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + + # Let's see if selection begins at a block start + first_pos = min([start_pos, end_pos]) + first_cursor = self.textCursor() + first_cursor.setPosition(first_pos) + + cursor.beginEditBlock() + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock) + if cursor.position() < start_pos: + cursor.setPosition(start_pos) + + number_spaces = -1 + while cursor.position() >= start_pos: + cursor.movePosition(QTextCursor.StartOfBlock) + line_text = to_text_string(cursor.block().text()) + start_with_space = line_text.startswith(' ') + left_number_spaces = self.__number_of_spaces(line_text) + if not start_with_space: + left_number_spaces = 0 + if ((number_spaces == -1 + or number_spaces > left_number_spaces) + and not line_text.isspace() and line_text != ''): + number_spaces = left_number_spaces + if cursor.blockNumber() == 0: + # Avoid infinite loop when indenting the very first line + break + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.movePosition(QTextCursor.EndOfBlock) + cursor.endEditBlock() + return number_spaces + + def remove_suffix(self, suffix): + """ + Remove suffix from current line (there should not be any selection) + """ + cursor = self.textCursor() + cursor.setPosition(cursor.position() - qstring_length(suffix), + QTextCursor.KeepAnchor) + if to_text_string(cursor.selectedText()) == suffix: + cursor.removeSelectedText() + + def remove_prefix(self, prefix): + """Remove prefix from current line or selected line(s)""" + cursor = self.textCursor() + if self.has_selected_text(): + # Remove prefix from selected line(s) + start_pos, end_pos = sorted([cursor.selectionStart(), + cursor.selectionEnd()]) + cursor.setPosition(start_pos) + if not cursor.atBlockStart(): + cursor.movePosition(QTextCursor.StartOfBlock) + start_pos = cursor.position() + cursor.beginEditBlock() + cursor.setPosition(end_pos) + # Check if end_pos is at the start of a block: if so, starting + # changes from the previous block + if cursor.atBlockStart(): + cursor.movePosition(QTextCursor.PreviousBlock) + if cursor.position() < start_pos: + cursor.setPosition(start_pos) + + cursor.movePosition(QTextCursor.StartOfBlock) + old_pos = None + while cursor.position() >= start_pos: + new_pos = cursor.position() + if old_pos == new_pos: + break + else: + old_pos = new_pos + line_text = to_text_string(cursor.block().text()) + self.__remove_prefix(prefix, cursor, line_text) + cursor.movePosition(QTextCursor.PreviousBlock) + cursor.endEditBlock() + else: + # Remove prefix from current line + cursor.movePosition(QTextCursor.StartOfBlock) + line_text = to_text_string(cursor.block().text()) + self.__remove_prefix(prefix, cursor, line_text) + + def __remove_prefix(self, prefix, cursor, line_text): + """Handle the removal of the prefix for a single line.""" + start_with_space = line_text.startswith(' ') + if start_with_space: + left_spaces = self.__even_number_of_spaces(line_text) + else: + left_spaces = False + if start_with_space: + right_number_spaces = self.__number_of_spaces(line_text, group=1) + else: + right_number_spaces = self.__number_of_spaces(line_text) + # Handle prefix remove for comments with spaces + if (prefix.strip() and line_text.lstrip().startswith(prefix + ' ') + or line_text.startswith(prefix + ' ') and '#' in prefix): + cursor.movePosition(QTextCursor.Right, + QTextCursor.MoveAnchor, + line_text.find(prefix)) + if (right_number_spaces == 1 + and (left_spaces or not start_with_space) + or (not start_with_space and right_number_spaces % 2 != 0) + or (left_spaces and right_number_spaces % 2 != 0)): + # Handle inserted '# ' with the count of the number of spaces + # at the right and left of the prefix. + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor, len(prefix + ' ')) + else: + # Handle manual insertion of '#' + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor, len(prefix)) + cursor.removeSelectedText() + # Check for prefix without space + elif (prefix.strip() and line_text.lstrip().startswith(prefix) + or line_text.startswith(prefix)): + cursor.movePosition(QTextCursor.Right, + QTextCursor.MoveAnchor, + line_text.find(prefix)) + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor, len(prefix)) + cursor.removeSelectedText() + + def __even_number_of_spaces(self, line_text, group=0): + """ + Get if there is a correct indentation from a group of spaces of a line. + """ + spaces = re.findall(r'\s+', line_text) + if len(spaces) - 1 >= group: + return len(spaces[group]) % len(self.indent_chars) == 0 + + def __number_of_spaces(self, line_text, group=0): + """Get the number of spaces from a group of spaces in a line.""" + spaces = re.findall(r'\s+', line_text) + if len(spaces) - 1 >= group: + return len(spaces[group]) + + def __get_brackets(self, line_text, closing_brackets=[]): + """ + Return unmatched opening brackets and left-over closing brackets. + + (str, []) -> ([(pos, bracket)], [bracket], comment_pos) + + Iterate through line_text to find unmatched brackets. + + Returns three objects as a tuple: + 1) bracket_stack: + a list of tuples of pos and char of each unmatched opening bracket + 2) closing brackets: + this line's unmatched closing brackets + arg closing_brackets. + If this line ad no closing brackets, arg closing_brackets might + be matched with previously unmatched opening brackets in this line. + 3) Pos at which a # comment begins. -1 if it doesn't.' + """ + # Remove inline comment and check brackets + bracket_stack = [] # list containing this lines unmatched opening + # same deal, for closing though. Ignore if bracket stack not empty, + # since they are mismatched in that case. + bracket_unmatched_closing = [] + comment_pos = -1 + deactivate = None + escaped = False + pos, c = None, None + for pos, c in enumerate(line_text): + # Handle '\' inside strings + if escaped: + escaped = False + # Handle strings + elif deactivate: + if c == deactivate: + deactivate = None + elif c == "\\": + escaped = True + elif c in ["'", '"']: + deactivate = c + # Handle comments + elif c == "#": + comment_pos = pos + break + # Handle brackets + elif c in ('(', '[', '{'): + bracket_stack.append((pos, c)) + elif c in (')', ']', '}'): + if bracket_stack and bracket_stack[-1][1] == \ + {')': '(', ']': '[', '}': '{'}[c]: + bracket_stack.pop() + else: + bracket_unmatched_closing.append(c) + del pos, deactivate, escaped + # If no closing brackets are left over from this line, + # check the ones from previous iterations' prevlines + if not bracket_unmatched_closing: + for c in list(closing_brackets): + if bracket_stack and bracket_stack[-1][1] == \ + {')': '(', ']': '[', '}': '{'}[c]: + bracket_stack.pop() + closing_brackets.remove(c) + else: + break + del c + closing_brackets = bracket_unmatched_closing + closing_brackets + return (bracket_stack, closing_brackets, comment_pos) + + def fix_indent(self, *args, **kwargs): + """Indent line according to the preferences""" + if self.is_python_like(): + ret = self.fix_indent_smart(*args, **kwargs) + else: + ret = self.simple_indentation(*args, **kwargs) + return ret + + def simple_indentation(self, forward=True, **kwargs): + """ + Simply preserve the indentation-level of the previous line. + """ + cursor = self.textCursor() + block_nb = cursor.blockNumber() + prev_block = self.document().findBlockByNumber(block_nb - 1) + prevline = to_text_string(prev_block.text()) + + indentation = re.match(r"\s*", prevline).group() + # Unident + if not forward: + indentation = indentation[len(self.indent_chars):] + + cursor.insertText(indentation) + return False # simple indentation don't fix indentation + + def find_indentation(self, forward=True, comment_or_string=False, + cur_indent=None): + """ + Find indentation (Python only, no text selection) + + forward=True: fix indent only if text is not enough indented + (otherwise force indent) + forward=False: fix indent only if text is too much indented + (otherwise force unindent) + + comment_or_string: Do not adjust indent level for + unmatched opening brackets and keywords + + max_blank_lines: maximum number of blank lines to search before giving + up + + cur_indent: current indent. This is the indent before we started + processing. E.g. when returning, indent before rstrip. + + Returns the indentation for the current line + + Assumes self.is_python_like() to return True + """ + cursor = self.textCursor() + block_nb = cursor.blockNumber() + # find the line that contains our scope + line_in_block = False + visual_indent = False + add_indent = 0 # How many levels of indent to add + prevline = None + prevtext = "" + empty_lines = True + + closing_brackets = [] + for prevline in range(block_nb - 1, -1, -1): + cursor.movePosition(QTextCursor.PreviousBlock) + prevtext = to_text_string(cursor.block().text()).rstrip() + + bracket_stack, closing_brackets, comment_pos = self.__get_brackets( + prevtext, closing_brackets) + + if not prevtext: + continue + + if prevtext.endswith((':', '\\')): + # Presume a block was started + line_in_block = True # add one level of indent to correct_indent + # Does this variable actually do *anything* of relevance? + # comment_or_string = True + + if bracket_stack or not closing_brackets: + break + + if prevtext.strip() != '': + empty_lines = False + + if empty_lines and prevline is not None and prevline < block_nb - 2: + # The previous line is too far, ignore + prevtext = '' + prevline = block_nb - 2 + line_in_block = False + + # splits of prevtext happen a few times. Let's just do it once + words = re.split(r'[\s\(\[\{\}\]\)]', prevtext.lstrip()) + + if line_in_block: + add_indent += 1 + + if prevtext and not comment_or_string: + if bracket_stack: + # Hanging indent + if prevtext.endswith(('(', '[', '{')): + add_indent += 1 + if words[0] in ('class', 'def', 'elif', 'except', 'for', + 'if', 'while', 'with'): + add_indent += 1 + elif not ( # I'm not sure this block should exist here + ( + self.tab_stop_width_spaces + if self.indent_chars == '\t' else + len(self.indent_chars) + ) * 2 < len(prevtext)): + visual_indent = True + else: + # There's stuff after unmatched opening brackets + visual_indent = True + elif (words[-1] in ('continue', 'break', 'pass',) + or words[0] == "return" and not line_in_block + ): + add_indent -= 1 + + if prevline: + prevline_indent = self.get_block_indentation(prevline) + else: + prevline_indent = 0 + + if visual_indent: # can only be true if bracket_stack + correct_indent = bracket_stack[-1][0] + 1 + elif add_indent: + # Indent + if self.indent_chars == '\t': + correct_indent = prevline_indent + self.tab_stop_width_spaces * add_indent + else: + correct_indent = prevline_indent + len(self.indent_chars) * add_indent + else: + correct_indent = prevline_indent + + # TODO untangle this block + if prevline and not bracket_stack and not prevtext.endswith(':'): + if forward: + # Keep indentation of previous line + ref_line = block_nb - 1 + else: + # Find indentation context + ref_line = prevline + if cur_indent is None: + cur_indent = self.get_block_indentation(ref_line) + is_blank = not self.get_text_line(ref_line).strip() + trailing_text = self.get_text_line(block_nb).strip() + # If brackets are matched and no block gets opened + # Match the above line's indent and nudge to the next multiple of 4 + + if cur_indent < prevline_indent and (trailing_text or is_blank): + # if line directly above is blank or there is text after cursor + # Ceiling division + correct_indent = -(-cur_indent // len(self.indent_chars)) * \ + len(self.indent_chars) + return correct_indent + + def fix_indent_smart(self, forward=True, comment_or_string=False, + cur_indent=None): + """ + Fix indentation (Python only, no text selection) + + forward=True: fix indent only if text is not enough indented + (otherwise force indent) + forward=False: fix indent only if text is too much indented + (otherwise force unindent) + + comment_or_string: Do not adjust indent level for + unmatched opening brackets and keywords + + max_blank_lines: maximum number of blank lines to search before giving + up + + cur_indent: current indent. This is the indent before we started + processing. E.g. when returning, indent before rstrip. + + Returns True if indent needed to be fixed + + Assumes self.is_python_like() to return True + """ + cursor = self.textCursor() + block_nb = cursor.blockNumber() + indent = self.get_block_indentation(block_nb) + + correct_indent = self.find_indentation( + forward, comment_or_string, cur_indent) + + if correct_indent >= 0 and not ( + indent == correct_indent or + forward and indent > correct_indent or + not forward and indent < correct_indent + ): + # Insert the determined indent + cursor = self.textCursor() + cursor.movePosition(QTextCursor.StartOfBlock) + if self.indent_chars == '\t': + indent = indent // self.tab_stop_width_spaces + cursor.setPosition(cursor.position()+indent, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + if self.indent_chars == '\t': + indent_text = ( + '\t' * (correct_indent // self.tab_stop_width_spaces) + + ' ' * (correct_indent % self.tab_stop_width_spaces) + ) + else: + indent_text = ' '*correct_indent + cursor.insertText(indent_text) + return True + return False + + @Slot() + def clear_all_output(self): + """Removes all output in the ipynb format (Json only)""" + try: + nb = nbformat.reads(self.toPlainText(), as_version=4) + if nb.cells: + for cell in nb.cells: + if 'outputs' in cell: + cell['outputs'] = [] + if 'prompt_number' in cell: + cell['prompt_number'] = None + # We do the following rather than using self.setPlainText + # to benefit from QTextEdit's undo/redo feature. + self.selectAll() + self.skip_rstrip = True + self.insertPlainText(nbformat.writes(nb)) + self.skip_rstrip = False + except Exception as e: + QMessageBox.critical(self, _('Removal error'), + _("It was not possible to remove outputs from " + "this notebook. The error is:\n\n") + \ + to_text_string(e)) + return + + @Slot() + def convert_notebook(self): + """Convert an IPython notebook to a Python script in editor""" + try: + nb = nbformat.reads(self.toPlainText(), as_version=4) + script = nbexporter().from_notebook_node(nb)[0] + except Exception as e: + QMessageBox.critical(self, _('Conversion error'), + _("It was not possible to convert this " + "notebook. The error is:\n\n") + \ + to_text_string(e)) + return + self.sig_new_file.emit(script) + + def indent(self, force=False): + """ + Indent current line or selection + + force=True: indent even if cursor is not a the beginning of the line + """ + leading_text = self.get_text('sol', 'cursor') + if self.has_selected_text(): + self.add_prefix(self.indent_chars) + elif (force or not leading_text.strip() or + (self.tab_indents and self.tab_mode)): + if self.is_python_like(): + if not self.fix_indent(forward=True): + self.add_prefix(self.indent_chars) + else: + self.add_prefix(self.indent_chars) + else: + if len(self.indent_chars) > 1: + length = len(self.indent_chars) + self.insert_text(" "*(length-(len(leading_text) % length))) + else: + self.insert_text(self.indent_chars) + + def indent_or_replace(self): + """Indent or replace by 4 spaces depending on selection and tab mode""" + if (self.tab_indents and self.tab_mode) or not self.has_selected_text(): + self.indent() + else: + cursor = self.textCursor() + if (self.get_selected_text() == + to_text_string(cursor.block().text())): + self.indent() + else: + cursor1 = self.textCursor() + cursor1.setPosition(cursor.selectionStart()) + cursor2 = self.textCursor() + cursor2.setPosition(cursor.selectionEnd()) + if cursor1.blockNumber() != cursor2.blockNumber(): + self.indent() + else: + self.replace(self.indent_chars) + + def unindent(self, force=False): + """ + Unindent current line or selection + + force=True: unindent even if cursor is not a the beginning of the line + """ + if self.has_selected_text(): + if self.indent_chars == "\t": + # Tabs, remove one tab + self.remove_prefix(self.indent_chars) + else: + # Spaces + space_count = len(self.indent_chars) + leading_spaces = self.__spaces_for_prefix() + remainder = leading_spaces % space_count + if remainder: + # Get block on "space multiple grid". + # See spyder-ide/spyder#5734. + self.remove_prefix(" "*remainder) + else: + # Unindent one space multiple + self.remove_prefix(self.indent_chars) + else: + leading_text = self.get_text('sol', 'cursor') + if (force or not leading_text.strip() or + (self.tab_indents and self.tab_mode)): + if self.is_python_like(): + if not self.fix_indent(forward=False): + self.remove_prefix(self.indent_chars) + elif leading_text.endswith('\t'): + self.remove_prefix('\t') + else: + self.remove_prefix(self.indent_chars) + + @Slot() + def toggle_comment(self): + """Toggle comment on current line or selection""" + cursor = self.textCursor() + start_pos, end_pos = sorted([cursor.selectionStart(), + cursor.selectionEnd()]) + cursor.setPosition(end_pos) + last_line = cursor.block().blockNumber() + if cursor.atBlockStart() and start_pos != end_pos: + last_line -= 1 + cursor.setPosition(start_pos) + first_line = cursor.block().blockNumber() + # If the selection contains only commented lines and surrounding + # whitespace, uncomment. Otherwise, comment. + is_comment_or_whitespace = True + at_least_one_comment = False + for _line_nb in range(first_line, last_line+1): + text = to_text_string(cursor.block().text()).lstrip() + is_comment = text.startswith(self.comment_string) + is_whitespace = (text == '') + is_comment_or_whitespace *= (is_comment or is_whitespace) + if is_comment: + at_least_one_comment = True + cursor.movePosition(QTextCursor.NextBlock) + if is_comment_or_whitespace and at_least_one_comment: + self.uncomment() + else: + self.comment() + + def is_comment(self, block): + """Detect inline comments. + + Return True if the block is an inline comment. + """ + if block is None: + return False + text = to_text_string(block.text()).lstrip() + return text.startswith(self.comment_string) + + def comment(self): + """Comment current line or selection.""" + self.add_prefix(self.comment_string + ' ') + + def uncomment(self): + """Uncomment current line or selection.""" + blockcomment = self.unblockcomment() + if not blockcomment: + self.remove_prefix(self.comment_string) + + def __blockcomment_bar(self, compatibility=False): + """Handle versions of blockcomment bar for backwards compatibility.""" + # Blockcomment bar in Spyder version >= 4 + blockcomment_bar = self.comment_string + ' ' + '=' * ( + 79 - len(self.comment_string + ' ')) + if compatibility: + # Blockcomment bar in Spyder version < 4 + blockcomment_bar = self.comment_string + '=' * ( + 79 - len(self.comment_string)) + return blockcomment_bar + + def transform_to_uppercase(self): + """Change to uppercase current line or selection.""" + cursor = self.textCursor() + prev_pos = cursor.position() + selected_text = to_text_string(cursor.selectedText()) + + if len(selected_text) == 0: + prev_pos = cursor.position() + cursor.select(QTextCursor.WordUnderCursor) + selected_text = to_text_string(cursor.selectedText()) + + s = selected_text.upper() + cursor.insertText(s) + self.set_cursor_position(prev_pos) + + def transform_to_lowercase(self): + """Change to lowercase current line or selection.""" + cursor = self.textCursor() + prev_pos = cursor.position() + selected_text = to_text_string(cursor.selectedText()) + + if len(selected_text) == 0: + prev_pos = cursor.position() + cursor.select(QTextCursor.WordUnderCursor) + selected_text = to_text_string(cursor.selectedText()) + + s = selected_text.lower() + cursor.insertText(s) + self.set_cursor_position(prev_pos) + + def blockcomment(self): + """Block comment current line or selection.""" + comline = self.__blockcomment_bar() + self.get_line_separator() + cursor = self.textCursor() + if self.has_selected_text(): + self.extend_selection_to_complete_lines() + start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() + else: + start_pos = end_pos = cursor.position() + cursor.beginEditBlock() + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + while cursor.position() <= end_pos: + cursor.insertText(self.comment_string + " ") + cursor.movePosition(QTextCursor.EndOfBlock) + if cursor.atEnd(): + break + cursor.movePosition(QTextCursor.NextBlock) + end_pos += len(self.comment_string + " ") + cursor.setPosition(end_pos) + cursor.movePosition(QTextCursor.EndOfBlock) + if cursor.atEnd(): + cursor.insertText(self.get_line_separator()) + else: + cursor.movePosition(QTextCursor.NextBlock) + cursor.insertText(comline) + cursor.setPosition(start_pos) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.insertText(comline) + cursor.endEditBlock() + + def unblockcomment(self): + """Un-block comment current line or selection.""" + # Needed for backward compatibility with Spyder previous blockcomments. + # See spyder-ide/spyder#2845. + unblockcomment = self.__unblockcomment() + if not unblockcomment: + unblockcomment = self.__unblockcomment(compatibility=True) + else: + return unblockcomment + + def __unblockcomment(self, compatibility=False): + """Un-block comment current line or selection helper.""" + def __is_comment_bar(cursor): + return to_text_string(cursor.block().text() + ).startswith( + self.__blockcomment_bar(compatibility=compatibility)) + # Finding first comment bar + cursor1 = self.textCursor() + if __is_comment_bar(cursor1): + return + while not __is_comment_bar(cursor1): + cursor1.movePosition(QTextCursor.PreviousBlock) + if cursor1.blockNumber() == 0: + break + if not __is_comment_bar(cursor1): + return False + + def __in_block_comment(cursor): + cs = self.comment_string + return to_text_string(cursor.block().text()).startswith(cs) + # Finding second comment bar + cursor2 = QTextCursor(cursor1) + cursor2.movePosition(QTextCursor.NextBlock) + while not __is_comment_bar(cursor2) and __in_block_comment(cursor2): + cursor2.movePosition(QTextCursor.NextBlock) + if cursor2.block() == self.document().lastBlock(): + break + if not __is_comment_bar(cursor2): + return False + # Removing block comment + cursor3 = self.textCursor() + cursor3.beginEditBlock() + cursor3.setPosition(cursor1.position()) + cursor3.movePosition(QTextCursor.NextBlock) + while cursor3.position() < cursor2.position(): + cursor3.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + if not cursor3.atBlockEnd(): + # standard commenting inserts '# ' but a trailing space on an + # empty line might be stripped. + if not compatibility: + cursor3.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + cursor3.removeSelectedText() + cursor3.movePosition(QTextCursor.NextBlock) + for cursor in (cursor2, cursor1): + cursor3.setPosition(cursor.position()) + cursor3.select(QTextCursor.BlockUnderCursor) + cursor3.removeSelectedText() + cursor3.endEditBlock() + return True + + #------Kill ring handlers + # Taken from Jupyter's QtConsole + # Copyright (c) 2001-2015, IPython Development Team + # Copyright (c) 2015-, Jupyter Development Team + def kill_line_end(self): + """Kill the text on the current line from the cursor forward""" + cursor = self.textCursor() + cursor.clearSelection() + cursor.movePosition(QTextCursor.EndOfLine, QTextCursor.KeepAnchor) + if not cursor.hasSelection(): + # Line deletion + cursor.movePosition(QTextCursor.NextBlock, + QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + def kill_line_start(self): + """Kill the text on the current line from the cursor backward""" + cursor = self.textCursor() + cursor.clearSelection() + cursor.movePosition(QTextCursor.StartOfBlock, + QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + def _get_word_start_cursor(self, position): + """Find the start of the word to the left of the given position. If a + sequence of non-word characters precedes the first word, skip over + them. (This emulates the behavior of bash, emacs, etc.) + """ + document = self.document() + position -= 1 + while (position and not + self.is_letter_or_number(document.characterAt(position))): + position -= 1 + while position and self.is_letter_or_number( + document.characterAt(position)): + position -= 1 + cursor = self.textCursor() + cursor.setPosition(self.next_cursor_position()) + return cursor + + def _get_word_end_cursor(self, position): + """Find the end of the word to the right of the given position. If a + sequence of non-word characters precedes the first word, skip over + them. (This emulates the behavior of bash, emacs, etc.) + """ + document = self.document() + cursor = self.textCursor() + position = cursor.position() + cursor.movePosition(QTextCursor.End) + end = cursor.position() + while (position < end and + not self.is_letter_or_number(document.characterAt(position))): + position = self.next_cursor_position(position) + while (position < end and + self.is_letter_or_number(document.characterAt(position))): + position = self.next_cursor_position(position) + cursor.setPosition(position) + return cursor + + def kill_prev_word(self): + """Kill the previous word""" + position = self.textCursor().position() + cursor = self._get_word_start_cursor(position) + cursor.setPosition(position, QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + def kill_next_word(self): + """Kill the next word""" + position = self.textCursor().position() + cursor = self._get_word_end_cursor(position) + cursor.setPosition(position, QTextCursor.KeepAnchor) + self._kill_ring.kill_cursor(cursor) + self.setTextCursor(cursor) + + #------Autoinsertion of quotes/colons + def __get_current_color(self, cursor=None): + """Get the syntax highlighting color for the current cursor position""" + if cursor is None: + cursor = self.textCursor() + + block = cursor.block() + pos = cursor.position() - block.position() # relative pos within block + layout = block.layout() + block_formats = layout.additionalFormats() + + if block_formats: + # To easily grab current format for autoinsert_colons + if cursor.atBlockEnd(): + current_format = block_formats[-1].format + else: + current_format = None + for fmt in block_formats: + if (pos >= fmt.start) and (pos < fmt.start + fmt.length): + current_format = fmt.format + if current_format is None: + return None + color = current_format.foreground().color().name() + return color + else: + return None + + def in_comment_or_string(self, cursor=None, position=None): + """Is the cursor or position inside or next to a comment or string? + + If *cursor* is None, *position* is used instead. If *position* is also + None, then the current cursor position is used. + """ + if self.highlighter: + if cursor is None: + cursor = self.textCursor() + if position: + cursor.setPosition(position) + current_color = self.__get_current_color(cursor=cursor) + + comment_color = self.highlighter.get_color_name('comment') + string_color = self.highlighter.get_color_name('string') + if (current_color == comment_color) or (current_color == string_color): + return True + else: + return False + else: + return False + + def __colon_keyword(self, text): + stmt_kws = ['def', 'for', 'if', 'while', 'with', 'class', 'elif', + 'except'] + whole_kws = ['else', 'try', 'except', 'finally'] + text = text.lstrip() + words = text.split() + if any([text == wk for wk in whole_kws]): + return True + elif len(words) < 2: + return False + elif any([words[0] == sk for sk in stmt_kws]): + return True + else: + return False + + def __forbidden_colon_end_char(self, text): + end_chars = [':', '\\', '[', '{', '(', ','] + text = text.rstrip() + if any([text.endswith(c) for c in end_chars]): + return True + else: + return False + + def __has_colon_not_in_brackets(self, text): + """ + Return whether a string has a colon which is not between brackets. + This function returns True if the given string has a colon which is + not between a pair of (round, square or curly) brackets. It assumes + that the brackets in the string are balanced. + """ + bracket_ext = self.editor_extensions.get(CloseBracketsExtension) + for pos, char in enumerate(text): + if (char == ':' and + not bracket_ext.unmatched_brackets_in_line(text[:pos])): + return True + return False + + def __has_unmatched_opening_bracket(self): + """ + Checks if there are any unmatched opening brackets before the current + cursor position. + """ + position = self.textCursor().position() + for brace in [']', ')', '}']: + match = self.find_brace_match(position, brace, forward=False) + if match is not None: + return True + return False + + def autoinsert_colons(self): + """Decide if we want to autoinsert colons""" + bracket_ext = self.editor_extensions.get(CloseBracketsExtension) + self.completion_widget.hide() + line_text = self.get_text('sol', 'cursor') + if not self.textCursor().atBlockEnd(): + return False + elif self.in_comment_or_string(): + return False + elif not self.__colon_keyword(line_text): + return False + elif self.__forbidden_colon_end_char(line_text): + return False + elif bracket_ext.unmatched_brackets_in_line(line_text): + return False + elif self.__has_colon_not_in_brackets(line_text): + return False + elif self.__has_unmatched_opening_bracket(): + return False + else: + return True + + def next_char(self): + cursor = self.textCursor() + cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor) + next_char = to_text_string(cursor.selectedText()) + return next_char + + def in_comment(self, cursor=None, position=None): + """Returns True if the given position is inside a comment. + + Parameters + ---------- + cursor : QTextCursor, optional + The position to check. + position : int, optional + The position to check if *cursor* is None. This parameter + is ignored when *cursor* is not None. + + If both *cursor* and *position* are none, then the position returned + by self.textCursor() is used instead. + """ + if self.highlighter: + if cursor is None: + cursor = self.textCursor() + if position is not None: + cursor.setPosition(position) + current_color = self.__get_current_color(cursor) + comment_color = self.highlighter.get_color_name('comment') + return (current_color == comment_color) + else: + return False + + def in_string(self, cursor=None, position=None): + """Returns True if the given position is inside a string. + + Parameters + ---------- + cursor : QTextCursor, optional + The position to check. + position : int, optional + The position to check if *cursor* is None. This parameter + is ignored when *cursor* is not None. + + If both *cursor* and *position* are none, then the position returned + by self.textCursor() is used instead. + """ + if self.highlighter: + if cursor is None: + cursor = self.textCursor() + if position is not None: + cursor.setPosition(position) + current_color = self.__get_current_color(cursor) + string_color = self.highlighter.get_color_name('string') + return (current_color == string_color) + else: + return False + + # ------ Qt Event handlers + def setup_context_menu(self): + """Setup context menu""" + self.undo_action = create_action( + self, _("Undo"), icon=ima.icon('undo'), + shortcut=CONF.get_shortcut('editor', 'undo'), triggered=self.undo) + self.redo_action = create_action( + self, _("Redo"), icon=ima.icon('redo'), + shortcut=CONF.get_shortcut('editor', 'redo'), triggered=self.redo) + self.cut_action = create_action( + self, _("Cut"), icon=ima.icon('editcut'), + shortcut=CONF.get_shortcut('editor', 'cut'), triggered=self.cut) + self.copy_action = create_action( + self, _("Copy"), icon=ima.icon('editcopy'), + shortcut=CONF.get_shortcut('editor', 'copy'), triggered=self.copy) + self.paste_action = create_action( + self, _("Paste"), icon=ima.icon('editpaste'), + shortcut=CONF.get_shortcut('editor', 'paste'), + triggered=self.paste) + selectall_action = create_action( + self, _("Select All"), icon=ima.icon('selectall'), + shortcut=CONF.get_shortcut('editor', 'select all'), + triggered=self.selectAll) + toggle_comment_action = create_action( + self, _("Comment")+"/"+_("Uncomment"), icon=ima.icon('comment'), + shortcut=CONF.get_shortcut('editor', 'toggle comment'), + triggered=self.toggle_comment) + self.clear_all_output_action = create_action( + self, _("Clear all ouput"), icon=ima.icon('ipython_console'), + triggered=self.clear_all_output) + self.ipynb_convert_action = create_action( + self, _("Convert to Python file"), icon=ima.icon('python'), + triggered=self.convert_notebook) + self.gotodef_action = create_action( + self, _("Go to definition"), + shortcut=CONF.get_shortcut('editor', 'go to definition'), + triggered=self.go_to_definition_from_cursor) + + self.inspect_current_object_action = create_action( + self, _("Inspect current object"), + icon=ima.icon('MessageBoxInformation'), + shortcut=CONF.get_shortcut('editor', 'inspect current object'), + triggered=self.sig_show_object_info.emit) + + # Run actions + self.run_cell_action = create_action( + self, _("Run cell"), icon=ima.icon('run_cell'), + shortcut=CONF.get_shortcut('editor', 'run cell'), + triggered=self.sig_run_cell) + self.run_cell_and_advance_action = create_action( + self, _("Run cell and advance"), icon=ima.icon('run_cell_advance'), + shortcut=CONF.get_shortcut('editor', 'run cell and advance'), + triggered=self.sig_run_cell_and_advance) + self.re_run_last_cell_action = create_action( + self, _("Re-run last cell"), + shortcut=CONF.get_shortcut('editor', 're-run last cell'), + triggered=self.sig_re_run_last_cell) + self.run_selection_action = create_action( + self, _("Run &selection or current line"), + icon=ima.icon('run_selection'), + shortcut=CONF.get_shortcut('editor', 'run selection'), + triggered=self.sig_run_selection) + self.run_to_line_action = create_action( + self, _("Run to current line"), + shortcut=CONF.get_shortcut('editor', 'run to line'), + triggered=self.sig_run_to_line) + self.run_from_line_action = create_action( + self, _("Run from current line"), + shortcut=CONF.get_shortcut('editor', 'run from line'), + triggered=self.sig_run_from_line) + self.debug_cell_action = create_action( + self, _("Debug cell"), icon=ima.icon('debug_cell'), + shortcut=CONF.get_shortcut('editor', 'debug cell'), + triggered=self.sig_debug_cell) + + # Zoom actions + zoom_in_action = create_action( + self, _("Zoom in"), icon=ima.icon('zoom_in'), + shortcut=QKeySequence(QKeySequence.ZoomIn), + triggered=self.zoom_in) + zoom_out_action = create_action( + self, _("Zoom out"), icon=ima.icon('zoom_out'), + shortcut=QKeySequence(QKeySequence.ZoomOut), + triggered=self.zoom_out) + zoom_reset_action = create_action( + self, _("Zoom reset"), shortcut=QKeySequence("Ctrl+0"), + triggered=self.zoom_reset) + + # Docstring + writer = self.writer_docstring + self.docstring_action = create_action( + self, _("Generate docstring"), + shortcut=CONF.get_shortcut('editor', 'docstring'), + triggered=writer.write_docstring_at_first_line_of_function) + + # Document formatting + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '' + ) + self.format_action = create_action( + self, + _('Format file or selection with {0}').format( + formatter.capitalize()), + shortcut=CONF.get_shortcut('editor', 'autoformatting'), + triggered=self.format_document_or_range) + + self.format_action.setEnabled(False) + + # Build menu + self.menu = QMenu(self) + actions_1 = [self.run_cell_action, self.run_cell_and_advance_action, + self.re_run_last_cell_action, self.run_selection_action, + self.run_to_line_action, self.run_from_line_action, + self.gotodef_action, self.inspect_current_object_action, + None, self.undo_action, + self.redo_action, None, self.cut_action, + self.copy_action, self.paste_action, selectall_action] + actions_2 = [None, zoom_in_action, zoom_out_action, zoom_reset_action, + None, toggle_comment_action, self.docstring_action, + self.format_action] + if nbformat is not None: + nb_actions = [self.clear_all_output_action, + self.ipynb_convert_action, None] + actions = actions_1 + nb_actions + actions_2 + add_actions(self.menu, actions) + else: + actions = actions_1 + actions_2 + add_actions(self.menu, actions) + + # Read-only context-menu + self.readonly_menu = QMenu(self) + add_actions(self.readonly_menu, + (self.copy_action, None, selectall_action, + self.gotodef_action)) + + def keyReleaseEvent(self, event): + """Override Qt method.""" + self.sig_key_released.emit(event) + key = event.key() + direction_keys = {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down} + if key in direction_keys: + self.request_cursor_event() + + # Update decorations after releasing these keys because they don't + # trigger the emission of the valueChanged signal in + # verticalScrollBar. + # See https://bugreports.qt.io/browse/QTBUG-25365 + if key in {Qt.Key_Up, Qt.Key_Down}: + self.update_decorations_timer.start() + + # This necessary to run our Pygments highlighter again after the + # user generated text changes + if event.text(): + # Stop the active timer and start it again to not run it on + # every event + if self.timer_syntax_highlight.isActive(): + self.timer_syntax_highlight.stop() + + # Adjust interval to rehighlight according to the lines + # present in the file + total_lines = self.get_line_count() + if total_lines < 1000: + self.timer_syntax_highlight.setInterval(600) + elif total_lines < 2000: + self.timer_syntax_highlight.setInterval(800) + else: + self.timer_syntax_highlight.setInterval(1000) + self.timer_syntax_highlight.start() + + self._restore_editor_cursor_and_selections() + super(CodeEditor, self).keyReleaseEvent(event) + event.ignore() + + def event(self, event): + """Qt method override.""" + if event.type() == QEvent.ShortcutOverride: + event.ignore() + return False + else: + return super(CodeEditor, self).event(event) + + def _start_completion_timer(self): + """Helper to start timer for automatic completions or handle them.""" + if not self.automatic_completions: + return + + if self.automatic_completions_after_ms > 0: + self._timer_autocomplete.start( + self.automatic_completions_after_ms) + else: + self._handle_completions() + + def _handle_keypress_event(self, event): + """Handle keypress events.""" + TextEditBaseWidget.keyPressEvent(self, event) + + # Trigger the following actions only if the event generates + # a text change. + text = to_text_string(event.text()) + if text: + # The next three lines are a workaround for a quirk of + # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. + # See spyder-ide/spyder#12663 and + # https://bugreports.qt.io/browse/QTBUG-35861 + if (parse_version(QT_VERSION) < parse_version('5.15') + or os.name == 'nt' or sys.platform == 'darwin'): + cursor = self.textCursor() + cursor.setPosition(cursor.position()) + self.setTextCursor(cursor) + self.sig_text_was_inserted.emit() + + def keyPressEvent(self, event): + """Reimplement Qt method.""" + tab_pressed = False + if self.completions_hint_after_ms > 0: + self._completions_hint_idle = False + self._timer_completions_hint.start(self.completions_hint_after_ms) + else: + self._set_completions_hint_idle() + + # Send the signal to the editor's extension. + event.ignore() + self.sig_key_pressed.emit(event) + + self.kite_call_to_action.handle_key_press(event) + + key = event.key() + text = to_text_string(event.text()) + has_selection = self.has_selected_text() + ctrl = event.modifiers() & Qt.ControlModifier + shift = event.modifiers() & Qt.ShiftModifier + + if text: + self.__clear_occurrences() + + # Only ask for completions if there's some text generated + # as part of the event. Events such as pressing Crtl, + # Shift or Alt don't generate any text. + # Fixes spyder-ide/spyder#11021 + self._start_completion_timer() + + if event.modifiers() and self.is_completion_widget_visible(): + # Hide completion widget before passing event modifiers + # since the keypress could be then a shortcut + # See spyder-ide/spyder#14806 + self.completion_widget.hide() + + if key in {Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Down}: + self.hide_tooltip() + + if event.isAccepted(): + # The event was handled by one of the editor extension. + return + + if key in [Qt.Key_Control, Qt.Key_Shift, Qt.Key_Alt, + Qt.Key_Meta, Qt.KeypadModifier]: + # The user pressed only a modifier key. + if ctrl: + pos = self.mapFromGlobal(QCursor.pos()) + pos = self.calculate_real_position_from_global(pos) + if self._handle_goto_uri_event(pos): + event.accept() + return + + if self._handle_goto_definition_event(pos): + event.accept() + return + return + + # ---- Handle hard coded and builtin actions + operators = {'+', '-', '*', '**', '/', '//', '%', '@', '<<', '>>', + '&', '|', '^', '~', '<', '>', '<=', '>=', '==', '!='} + delimiters = {',', ':', ';', '@', '=', '->', '+=', '-=', '*=', '/=', + '//=', '%=', '@=', '&=', '|=', '^=', '>>=', '<<=', '**='} + + if text not in self.auto_completion_characters: + if text in operators or text in delimiters: + self.completion_widget.hide() + if key in (Qt.Key_Enter, Qt.Key_Return): + if not shift and not ctrl: + if (self.add_colons_enabled and self.is_python_like() and + self.autoinsert_colons()): + self.textCursor().beginEditBlock() + self.insert_text(':' + self.get_line_separator()) + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent() + else: + self.fix_indent() + self.textCursor().endEditBlock() + elif self.is_completion_widget_visible(): + self.select_completion_list() + else: + self.textCursor().beginEditBlock() + cur_indent = self.get_block_indentation( + self.textCursor().blockNumber()) + self._handle_keypress_event(event) + # Check if we're in a comment or a string at the + # current position + cmt_or_str_cursor = self.in_comment_or_string() + + # Check if the line start with a comment or string + cursor = self.textCursor() + cursor.setPosition(cursor.block().position(), + QTextCursor.KeepAnchor) + cmt_or_str_line_begin = self.in_comment_or_string( + cursor=cursor) + + # Check if we are in a comment or a string + cmt_or_str = cmt_or_str_cursor and cmt_or_str_line_begin + + if self.strip_trailing_spaces_on_modify: + self.fix_and_strip_indent( + comment_or_string=cmt_or_str, + cur_indent=cur_indent) + else: + self.fix_indent(comment_or_string=cmt_or_str, + cur_indent=cur_indent) + self.textCursor().endEditBlock() + elif key == Qt.Key_Insert and not shift and not ctrl: + self.setOverwriteMode(not self.overwriteMode()) + elif key == Qt.Key_Backspace and not shift and not ctrl: + if has_selection or not self.intelligent_backspace: + self._handle_keypress_event(event) + else: + leading_text = self.get_text('sol', 'cursor') + leading_length = len(leading_text) + trailing_spaces = leading_length - len(leading_text.rstrip()) + trailing_text = self.get_text('cursor', 'eol') + matches = ('()', '[]', '{}', '\'\'', '""') + if (not leading_text.strip() and + (leading_length > len(self.indent_chars))): + if leading_length % len(self.indent_chars) == 0: + self.unindent() + else: + self._handle_keypress_event(event) + elif trailing_spaces and not trailing_text.strip(): + self.remove_suffix(leading_text[-trailing_spaces:]) + elif (leading_text and trailing_text and + (leading_text[-1] + trailing_text[0] in matches)): + cursor = self.textCursor() + cursor.movePosition(QTextCursor.PreviousCharacter) + cursor.movePosition(QTextCursor.NextCharacter, + QTextCursor.KeepAnchor, 2) + cursor.removeSelectedText() + else: + self._handle_keypress_event(event) + elif key == Qt.Key_Home: + self.stdkey_home(shift, ctrl) + elif key == Qt.Key_End: + # See spyder-ide/spyder#495: on MacOS X, it is necessary to + # redefine this basic action which should have been implemented + # natively + self.stdkey_end(shift, ctrl) + elif (text in self.auto_completion_characters and + self.automatic_completions): + self.insert_text(text) + if text == ".": + if not self.in_comment_or_string(): + text = self.get_text('sol', 'cursor') + last_obj = getobj(text) + prev_char = text[-2] if len(text) > 1 else '' + if (prev_char in {')', ']', '}'} or + (last_obj and not last_obj.isdigit())): + # Completions should be triggered immediately when + # an autocompletion character is introduced. + self.do_completion(automatic=True) + else: + self.do_completion(automatic=True) + elif (text in self.signature_completion_characters and + not self.has_selected_text()): + self.insert_text(text) + self.request_signature() + elif (key == Qt.Key_Colon and not has_selection and + self.auto_unindent_enabled): + leading_text = self.get_text('sol', 'cursor') + if leading_text.lstrip() in ('else', 'finally'): + ind = lambda txt: len(txt) - len(txt.lstrip()) + prevtxt = (to_text_string(self.textCursor().block(). + previous().text())) + if self.language == 'Python': + prevtxt = prevtxt.rstrip() + if ind(leading_text) == ind(prevtxt): + self.unindent(force=True) + self._handle_keypress_event(event) + elif (key == Qt.Key_Space and not shift and not ctrl and not + has_selection and self.auto_unindent_enabled): + self.completion_widget.hide() + leading_text = self.get_text('sol', 'cursor') + if leading_text.lstrip() in ('elif', 'except'): + ind = lambda txt: len(txt)-len(txt.lstrip()) + prevtxt = (to_text_string(self.textCursor().block(). + previous().text())) + if self.language == 'Python': + prevtxt = prevtxt.rstrip() + if ind(leading_text) == ind(prevtxt): + self.unindent(force=True) + self._handle_keypress_event(event) + elif key == Qt.Key_Tab and not ctrl: + # Important note: can't be called with a QShortcut because + # of its singular role with respect to widget focus management + tab_pressed = True + if not has_selection and not self.tab_mode: + self.intelligent_tab() + else: + # indent the selected text + self.indent_or_replace() + elif key == Qt.Key_Backtab and not ctrl: + # Backtab, i.e. Shift+, could be treated as a QShortcut but + # there is no point since can't (see above) + tab_pressed = True + if not has_selection and not self.tab_mode: + self.intelligent_backtab() + else: + # indent the selected text + self.unindent() + event.accept() + elif not event.isAccepted(): + self._handle_keypress_event(event) + + self._last_key_pressed_text = text + self._last_pressed_key = key + if self.automatic_completions_after_ms == 0 and not tab_pressed: + self._handle_completions() + + if not event.modifiers(): + # Accept event to avoid it being handled by the parent. + # Modifiers should be passed to the parent because they + # could be shortcuts + event.accept() + + def _handle_completions(self): + """Handle on the fly completions after delay.""" + if not self.automatic_completions: + return + + cursor = self.textCursor() + pos = cursor.position() + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + key = self._last_pressed_key + if key is not None: + if key in [Qt.Key_Return, Qt.Key_Escape, + Qt.Key_Tab, Qt.Key_Backtab, Qt.Key_Space]: + self._last_pressed_key = None + return + + # Correctly handle completions when Backspace key is pressed. + # We should not show the widget if deleting a space before a word. + if key == Qt.Key_Backspace: + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.select(QTextCursor.WordUnderCursor) + prev_text = to_text_string(cursor.selectedText()) + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.setPosition(pos, QTextCursor.KeepAnchor) + prev_char = cursor.selectedText() + if prev_text == '' or prev_char in (u'\u2029', ' ', '\t'): + return + + # Text might be after a dot '.' + if text == '': + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + if text != '.': + text = '' + + # WordUnderCursor fails if the cursor is next to a right brace. + # If the returned text starts with it, we move to the left. + if text.startswith((')', ']', '}')): + cursor.setPosition(pos - 1, QTextCursor.MoveAnchor) + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + is_backspace = ( + self.is_completion_widget_visible() and key == Qt.Key_Backspace) + + if (len(text) >= self.automatic_completions_after_chars + and self._last_key_pressed_text or is_backspace): + # Perform completion on the fly + if not self.in_comment_or_string(): + # Variables can include numbers and underscores + if (text.isalpha() or text.isalnum() or '_' in text + or '.' in text): + self.do_completion(automatic=True) + self._last_key_pressed_text = '' + self._last_pressed_key = None + + def fix_and_strip_indent(self, *args, **kwargs): + """ + Automatically fix indent and strip previous automatic indent. + + args and kwargs are forwarded to self.fix_indent + """ + # Fix indent + cursor_before = self.textCursor().position() + # A change just occurred on the last line (return was pressed) + if cursor_before > 0: + self.last_change_position = cursor_before - 1 + self.fix_indent(*args, **kwargs) + cursor_after = self.textCursor().position() + # Remove previous spaces and update last_auto_indent + nspaces_removed = self.strip_trailing_spaces() + self.last_auto_indent = (cursor_before - nspaces_removed, + cursor_after - nspaces_removed) + + def run_pygments_highlighter(self): + """Run pygments highlighter.""" + if isinstance(self.highlighter, sh.PygmentsSH): + self.highlighter.make_charlist() + + def get_pattern_at(self, coordinates): + """ + Return key, text and cursor for pattern (if found at coordinates). + """ + return self.get_pattern_cursor_at(self.highlighter.patterns, + coordinates) + + def get_pattern_cursor_at(self, pattern, coordinates): + """ + Find pattern located at the line where the coordinate is located. + + This returns the actual match and the cursor that selects the text. + """ + cursor, key, text = None, None, None + break_loop = False + + # Check if the pattern is in line + line = self.get_line_at(coordinates) + + for match in pattern.finditer(line): + for key, value in list(match.groupdict().items()): + if value: + start, end = sh.get_span(match) + + # Get cursor selection if pattern found + cursor = self.cursorForPosition(coordinates) + cursor.movePosition(QTextCursor.StartOfBlock) + line_start_position = cursor.position() + + cursor.setPosition(line_start_position + start, + cursor.MoveAnchor) + start_rect = self.cursorRect(cursor) + cursor.setPosition(line_start_position + end, + cursor.MoveAnchor) + end_rect = self.cursorRect(cursor) + bounding_rect = start_rect.united(end_rect) + + # Check coordinates are located within the selection rect + if bounding_rect.contains(coordinates): + text = line[start:end] + cursor.setPosition(line_start_position + start, + cursor.KeepAnchor) + break_loop = True + break + + if break_loop: + break + + return key, text, cursor + + def _preprocess_file_uri(self, uri): + """Format uri to conform to absolute or relative file paths.""" + fname = uri.replace('file://', '') + if fname[-1] == '/': + fname = fname[:-1] + + # ^/ is used to denote the current project root + if fname.startswith("^/"): + if self.current_project_path is not None: + fname = osp.join(self.current_project_path, fname[2:]) + else: + fname = fname.replace("^/", "~/") + + if fname.startswith("~/"): + fname = osp.expanduser(fname) + + dirname = osp.dirname(osp.abspath(self.filename)) + if osp.isdir(dirname): + if not osp.isfile(fname): + # Maybe relative + fname = osp.join(dirname, fname) + + self.sig_file_uri_preprocessed.emit(fname) + + return fname + + def _handle_goto_definition_event(self, pos): + """Check if goto definition can be applied and apply highlight.""" + text = self.get_word_at(pos) + if text and not sourcecode.is_keyword(to_text_string(text)): + if not self.__cursor_changed: + QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) + self.__cursor_changed = True + cursor = self.cursorForPosition(pos) + cursor.select(QTextCursor.WordUnderCursor) + self.clear_extra_selections('ctrl_click') + self.highlight_selection( + 'ctrl_click', cursor, + foreground_color=self.ctrl_click_color, + underline_color=self.ctrl_click_color, + underline_style=QTextCharFormat.SingleUnderline) + return True + else: + return False + + def _handle_goto_uri_event(self, pos): + """Check if go to uri can be applied and apply highlight.""" + key, pattern_text, cursor = self.get_pattern_at(pos) + if key and pattern_text and cursor: + self._last_hover_pattern_key = key + self._last_hover_pattern_text = pattern_text + + color = self.ctrl_click_color + + if key in ['file']: + fname = self._preprocess_file_uri(pattern_text) + if not osp.isfile(fname): + color = QColor(SpyderPalette.COLOR_ERROR_2) + + self.clear_extra_selections('ctrl_click') + self.highlight_selection( + 'ctrl_click', cursor, + foreground_color=color, + underline_color=color, + underline_style=QTextCharFormat.SingleUnderline) + + if not self.__cursor_changed: + QApplication.setOverrideCursor( + QCursor(Qt.PointingHandCursor)) + self.__cursor_changed = True + + self.sig_uri_found.emit(pattern_text) + return True + else: + self._last_hover_pattern_key = key + self._last_hover_pattern_text = pattern_text + return False + + def go_to_uri_from_cursor(self, uri): + """Go to url from cursor and defined hover patterns.""" + key = self._last_hover_pattern_key + full_uri = uri + + if key in ['file']: + fname = self._preprocess_file_uri(uri) + + if osp.isfile(fname) and encoding.is_text_file(fname): + # Open in editor + self.go_to_definition.emit(fname, 0, 0) + else: + # Use external program + fname = file_uri(fname) + start_file(fname) + elif key in ['mail', 'url']: + if '@' in uri and not uri.startswith('mailto:'): + full_uri = 'mailto:' + uri + quri = QUrl(full_uri) + QDesktopServices.openUrl(quri) + elif key in ['issue']: + # Issue URI + repo_url = uri.replace('#', '/issues/') + if uri.startswith(('gh-', 'bb-', 'gl-')): + number = uri[3:] + remotes = get_git_remotes(self.filename) + remote = remotes.get('upstream', remotes.get('origin')) + if remote: + full_uri = remote_to_url(remote) + '/issues/' + number + else: + full_uri = None + elif uri.startswith('gh:') or ':' not in uri: + # Github + repo_and_issue = repo_url + if uri.startswith('gh:'): + repo_and_issue = repo_url[3:] + full_uri = 'https://github.com/' + repo_and_issue + elif uri.startswith('gl:'): + # Gitlab + full_uri = 'https://gitlab.com/' + repo_url[3:] + elif uri.startswith('bb:'): + # Bitbucket + full_uri = 'https://bitbucket.org/' + repo_url[3:] + + if full_uri: + quri = QUrl(full_uri) + QDesktopServices.openUrl(quri) + else: + QMessageBox.information( + self, + _('Information'), + _('This file is not part of a local repository or ' + 'upstream/origin remotes are not defined!'), + QMessageBox.Ok, + ) + self.hide_tooltip() + return full_uri + + def line_range(self, position): + """ + Get line range from position. + """ + if position is None: + return None + if position >= self.document().characterCount(): + return None + # Check if still on the line + cursor = self.textCursor() + cursor.setPosition(position) + line_range = (cursor.block().position(), + cursor.block().position() + + cursor.block().length() - 1) + return line_range + + def strip_trailing_spaces(self): + """ + Strip trailing spaces if needed. + + Remove trailing whitespace on leaving a non-string line containing it. + Return the number of removed spaces. + """ + if not running_under_pytest(): + if not self.hasFocus(): + # Avoid problem when using split editor + return 0 + # Update current position + current_position = self.textCursor().position() + last_position = self.last_position + self.last_position = current_position + + if self.skip_rstrip: + return 0 + + line_range = self.line_range(last_position) + if line_range is None: + # Doesn't apply + return 0 + + def pos_in_line(pos): + """Check if pos is in last line.""" + if pos is None: + return False + return line_range[0] <= pos <= line_range[1] + + if pos_in_line(current_position): + # Check if still on the line + return 0 + + # Check if end of line in string + cursor = self.textCursor() + cursor.setPosition(line_range[1]) + + if (not self.strip_trailing_spaces_on_modify + or self.in_string(cursor=cursor)): + if self.last_auto_indent is None: + return 0 + elif (self.last_auto_indent != + self.line_range(self.last_auto_indent[0])): + # line not empty + self.last_auto_indent = None + return 0 + line_range = self.last_auto_indent + self.last_auto_indent = None + elif not pos_in_line(self.last_change_position): + # Should process if pressed return or made a change on the line: + return 0 + + cursor.setPosition(line_range[0]) + cursor.setPosition(line_range[1], + QTextCursor.KeepAnchor) + # remove spaces on the right + text = cursor.selectedText() + strip = text.rstrip() + # I think all the characters we can strip are in a single QChar. + # Therefore there shouldn't be any length problems. + N_strip = qstring_length(text[len(strip):]) + + if N_strip > 0: + # Select text to remove + cursor.setPosition(line_range[1] - N_strip) + cursor.setPosition(line_range[1], + QTextCursor.KeepAnchor) + cursor.removeSelectedText() + # Correct last change position + self.last_change_position = line_range[1] + self.last_position = self.textCursor().position() + return N_strip + return 0 + + def move_line_up(self): + """Move up current line or selected text""" + self.__move_line_or_selection(after_current_line=False) + + def move_line_down(self): + """Move down current line or selected text""" + self.__move_line_or_selection(after_current_line=True) + + def __move_line_or_selection(self, after_current_line=True): + cursor = self.textCursor() + # Unfold any folded code block before moving lines up/down + folding_panel = self.panels.get('FoldingPanel') + fold_start_line = cursor.blockNumber() + 1 + block = cursor.block().next() + + if fold_start_line in folding_panel.folding_status: + fold_status = folding_panel.folding_status[fold_start_line] + if fold_status: + folding_panel.toggle_fold_trigger(block) + + if after_current_line: + # Unfold any folded region when moving lines down + fold_start_line = cursor.blockNumber() + 2 + block = cursor.block().next().next() + + if fold_start_line in folding_panel.folding_status: + fold_status = folding_panel.folding_status[fold_start_line] + if fold_status: + folding_panel.toggle_fold_trigger(block) + else: + # Unfold any folded region when moving lines up + block = cursor.block() + offset = 0 + if self.has_selected_text(): + ((selection_start, _), + (selection_end)) = self.get_selection_start_end() + if selection_end != selection_start: + offset = 1 + fold_start_line = block.blockNumber() - 1 - offset + + # Find the innermost code folding region for the current position + enclosing_regions = sorted(list( + folding_panel.current_tree[fold_start_line])) + + folding_status = folding_panel.folding_status + if len(enclosing_regions) > 0: + for region in enclosing_regions: + fold_start_line = region.begin + block = self.document().findBlockByNumber(fold_start_line) + if fold_start_line in folding_status: + fold_status = folding_status[fold_start_line] + if fold_status: + folding_panel.toggle_fold_trigger(block) + + self._TextEditBaseWidget__move_line_or_selection( + after_current_line=after_current_line) + + def mouseMoveEvent(self, event): + """Underline words when pressing """ + # Restart timer every time the mouse is moved + # This is needed to correctly handle hover hints with a delay + self._timer_mouse_moving.start() + + pos = event.pos() + self._last_point = pos + alt = event.modifiers() & Qt.AltModifier + ctrl = event.modifiers() & Qt.ControlModifier + + if alt: + self.sig_alt_mouse_moved.emit(event) + event.accept() + return + + if ctrl: + if self._handle_goto_uri_event(pos): + event.accept() + return + + if self.has_selected_text(): + TextEditBaseWidget.mouseMoveEvent(self, event) + return + + if self.go_to_definition_enabled and ctrl: + if self._handle_goto_definition_event(pos): + event.accept() + return + + if self.__cursor_changed: + self._restore_editor_cursor_and_selections() + else: + if (not self._should_display_hover(pos) + and not self.is_completion_widget_visible()): + self.hide_tooltip() + + TextEditBaseWidget.mouseMoveEvent(self, event) + + def setPlainText(self, txt): + """ + Extends setPlainText to emit the new_text_set signal. + + :param txt: The new text to set. + :param mime_type: Associated mimetype. Setting the mime will update the + pygments lexer. + :param encoding: text encoding + """ + super(CodeEditor, self).setPlainText(txt) + self.new_text_set.emit() + + def focusOutEvent(self, event): + """Extend Qt method""" + self.sig_focus_changed.emit() + self._restore_editor_cursor_and_selections() + super(CodeEditor, self).focusOutEvent(event) + + def focusInEvent(self, event): + formatting_enabled = getattr(self, 'formatting_enabled', False) + self.sig_refresh_formatting.emit(formatting_enabled) + super(CodeEditor, self).focusInEvent(event) + + def leaveEvent(self, event): + """Extend Qt method""" + self.sig_leave_out.emit() + self._restore_editor_cursor_and_selections() + TextEditBaseWidget.leaveEvent(self, event) + + def mousePressEvent(self, event): + """Override Qt method.""" + self.hide_tooltip() + self.kite_call_to_action.handle_mouse_press(event) + + ctrl = event.modifiers() & Qt.ControlModifier + alt = event.modifiers() & Qt.AltModifier + pos = event.pos() + self._mouse_left_button_pressed = event.button() == Qt.LeftButton + + if event.button() == Qt.LeftButton and ctrl: + TextEditBaseWidget.mousePressEvent(self, event) + cursor = self.cursorForPosition(pos) + uri = self._last_hover_pattern_text + if uri: + self.go_to_uri_from_cursor(uri) + else: + self.go_to_definition_from_cursor(cursor) + elif event.button() == Qt.LeftButton and alt: + self.sig_alt_left_mouse_pressed.emit(event) + else: + TextEditBaseWidget.mousePressEvent(self, event) + + def mouseReleaseEvent(self, event): + """Override Qt method.""" + if event.button() == Qt.LeftButton: + self._mouse_left_button_pressed = False + + self.request_cursor_event() + TextEditBaseWidget.mouseReleaseEvent(self, event) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + nonempty_selection = self.has_selected_text() + self.copy_action.setEnabled(nonempty_selection) + self.cut_action.setEnabled(nonempty_selection) + self.clear_all_output_action.setVisible(self.is_json() and + nbformat is not None) + self.ipynb_convert_action.setVisible(self.is_json() and + nbformat is not None) + self.run_cell_action.setVisible(self.is_python_or_ipython()) + self.run_cell_and_advance_action.setVisible(self.is_python_or_ipython()) + self.run_selection_action.setVisible(self.is_python_or_ipython()) + self.run_to_line_action.setVisible(self.is_python_or_ipython()) + self.run_from_line_action.setVisible(self.is_python_or_ipython()) + self.re_run_last_cell_action.setVisible(self.is_python_or_ipython()) + self.gotodef_action.setVisible(self.go_to_definition_enabled) + + formatter = CONF.get( + 'completions', + ('provider_configuration', 'lsp', 'values', 'formatting'), + '' + ) + self.format_action.setText(_( + 'Format file or selection with {0}').format( + formatter.capitalize())) + + # Check if a docstring is writable + writer = self.writer_docstring + writer.line_number_cursor = self.get_line_number_at(event.pos()) + result = writer.get_function_definition_from_first_line() + + if result: + self.docstring_action.setEnabled(True) + else: + self.docstring_action.setEnabled(False) + + # Code duplication go_to_definition_from_cursor and mouse_move_event + cursor = self.textCursor() + text = to_text_string(cursor.selectedText()) + if len(text) == 0: + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + + self.undo_action.setEnabled(self.document().isUndoAvailable()) + self.redo_action.setEnabled(self.document().isRedoAvailable()) + menu = self.menu + if self.isReadOnly(): + menu = self.readonly_menu + menu.popup(event.globalPos()) + event.accept() + + def _restore_editor_cursor_and_selections(self): + """Restore the cursor and extra selections of this code editor.""" + if self.__cursor_changed: + self.__cursor_changed = False + QApplication.restoreOverrideCursor() + self.clear_extra_selections('ctrl_click') + self._last_hover_pattern_key = None + self._last_hover_pattern_text = None + + #------ Drag and drop + def dragEnterEvent(self, event): + """ + Reimplemented Qt method. + + Inform Qt about the types of data that the widget accepts. + """ + logger.debug("dragEnterEvent was received") + all_urls = mimedata2url(event.mimeData()) + if all_urls: + # Let the parent widget handle this + logger.debug("Let the parent widget handle this dragEnterEvent") + event.ignore() + else: + logger.debug("Call TextEditBaseWidget dragEnterEvent method") + TextEditBaseWidget.dragEnterEvent(self, event) + + def dropEvent(self, event): + """ + Reimplemented Qt method. + + Unpack dropped data and handle it. + """ + logger.debug("dropEvent was received") + if mimedata2url(event.mimeData()): + logger.debug("Let the parent widget handle this") + event.ignore() + else: + logger.debug("Call TextEditBaseWidget dropEvent method") + TextEditBaseWidget.dropEvent(self, event) + + #------ Paint event + def paintEvent(self, event): + """Overrides paint event to update the list of visible blocks""" + self.update_visible_blocks(event) + TextEditBaseWidget.paintEvent(self, event) + self.painted.emit(event) + + def update_visible_blocks(self, event): + """Update the list of visible blocks/lines position""" + self.__visible_blocks[:] = [] + block = self.firstVisibleBlock() + blockNumber = block.blockNumber() + top = int(self.blockBoundingGeometry(block).translated( + self.contentOffset()).top()) + bottom = top + int(self.blockBoundingRect(block).height()) + ebottom_bottom = self.height() + + while block.isValid(): + visible = bottom <= ebottom_bottom + if not visible: + break + if block.isVisible(): + self.__visible_blocks.append((top, blockNumber+1, block)) + block = block.next() + top = bottom + bottom = top + int(self.blockBoundingRect(block).height()) + blockNumber = block.blockNumber() + + def _draw_editor_cell_divider(self): + """Draw a line on top of a define cell""" + if self.supported_cell_language: + cell_line_color = self.comment_color + painter = QPainter(self.viewport()) + pen = painter.pen() + pen.setStyle(Qt.SolidLine) + pen.setBrush(cell_line_color) + painter.setPen(pen) + + for top, line_number, block in self.visible_blocks: + if is_cell_header(block): + painter.drawLine(4, top, self.width(), top) + + @property + def visible_blocks(self): + """ + Returns the list of visible blocks. + + Each element in the list is a tuple made up of the line top position, + the line number (already 1 based), and the QTextBlock itself. + + :return: A list of tuple(top position, line number, block) + :rtype: List of tuple(int, int, QtGui.QTextBlock) + """ + return self.__visible_blocks + + def is_editor(self): + return True + + def popup_docstring(self, prev_text, prev_pos): + """Show the menu for generating docstring.""" + line_text = self.textCursor().block().text() + if line_text != prev_text: + return + + if prev_pos != self.textCursor().position(): + return + + writer = self.writer_docstring + if writer.get_function_definition_from_below_last_line(): + point = self.cursorRect().bottomRight() + point = self.calculate_real_position(point) + point = self.mapToGlobal(point) + + self.menu_docstring = QMenuOnlyForEnter(self) + self.docstring_action = create_action( + self, _("Generate docstring"), icon=ima.icon('TextFileIcon'), + triggered=writer.write_docstring) + self.menu_docstring.addAction(self.docstring_action) + self.menu_docstring.setActiveAction(self.docstring_action) + self.menu_docstring.popup(point) + + def delayed_popup_docstring(self): + """Show context menu for docstring. + + This method is called after typing '''. After typing ''', this function + waits 300ms. If there was no input for 300ms, show the context menu. + """ + line_text = self.textCursor().block().text() + pos = self.textCursor().position() + + timer = QTimer() + timer.singleShot(300, lambda: self.popup_docstring(line_text, pos)) + + def set_current_project_path(self, root_path=None): + """ + Set the current active project root path. + + Parameters + ---------- + root_path: str or None, optional + Path to current project root path. Default is None. + """ + self.current_project_path = root_path + + def count_leading_empty_lines(self, cell): + """Count the number of leading empty cells.""" + lines = cell.splitlines(keepends=True) + if not lines: + return 0 + for i, line in enumerate(lines): + if line and not line.isspace(): + return i + return len(lines) + + def ipython_to_python(self, code): + """Transform IPython code to python code.""" + tm = TransformerManager() + number_empty_lines = self.count_leading_empty_lines(code) + try: + code = tm.transform_cell(code) + except SyntaxError: + return code + return '\n' * number_empty_lines + code + + def is_letter_or_number(self, char): + """ + Returns whether the specified unicode character is a letter or a + number. + """ + cat = category(char) + return cat.startswith('L') or cat.startswith('N') + + +# ============================================================================= +# Editor + Class browser test +# ============================================================================= +class TestWidget(QSplitter): + def __init__(self, parent): + QSplitter.__init__(self, parent) + self.editor = CodeEditor(self) + self.editor.setup_editor(linenumbers=True, markers=True, tab_mode=False, + font=QFont("Courier New", 10), + show_blanks=True, color_scheme='Zenburn') + self.addWidget(self.editor) + self.setWindowIcon(ima.icon('spyder')) + + def load(self, filename): + self.editor.set_text_from_file(filename) + self.setWindowTitle("%s - %s (%s)" % (_("Editor"), + osp.basename(filename), + osp.dirname(filename))) + self.editor.hide_tooltip() + + +def test(fname): + from spyder.utils.qthelpers import qapplication + app = qapplication(test_time=5) + win = TestWidget(None) + win.show() + win.load(fname) + win.resize(900, 700) + sys.exit(app.exec_()) + + +if __name__ == '__main__': + if len(sys.argv) > 1: + fname = sys.argv[1] + else: + fname = __file__ + test(fname) diff --git a/spyder/plugins/editor/widgets/editor.py b/spyder/plugins/editor/widgets/editor.py index c4e02ae0c0a..5b72d994f92 100644 --- a/spyder/plugins/editor/widgets/editor.py +++ b/spyder/plugins/editor/widgets/editor.py @@ -1,3611 +1,3611 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Editor Widget""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import logging -import os -import os.path as osp -import sys -import unicodedata - -# Third party imports -import qstylizer.style -from qtpy.compat import getsavefilename -from qtpy.QtCore import (QByteArray, QFileInfo, QPoint, QSize, Qt, QTimer, - Signal, Slot) -from qtpy.QtGui import QFont, QTextCursor -from qtpy.QtWidgets import (QAction, QApplication, QFileDialog, QHBoxLayout, - QLabel, QMainWindow, QMessageBox, QMenu, - QSplitter, QVBoxLayout, QWidget, QListWidget, - QListWidgetItem, QSizePolicy, QToolBar) - -# Local imports -from spyder.api.panel import Panel -from spyder.config.base import _, running_under_pytest -from spyder.config.manager import CONF -from spyder.config.utils import (get_edit_filetypes, get_edit_filters, - get_filter, is_kde_desktop, is_anaconda) -from spyder.plugins.editor.utils.autosave import AutosaveForStack -from spyder.plugins.editor.utils.editor import get_file_language -from spyder.plugins.editor.utils.switcher import EditorSwitcherManager -from spyder.plugins.editor.widgets import codeeditor -from spyder.plugins.editor.widgets.editorstack_helpers import ( - ThreadManager, FileInfo, StackHistory) -from spyder.plugins.editor.widgets.status import (CursorPositionStatus, - EncodingStatus, EOLStatus, - ReadWriteStatus, VCSStatus) -from spyder.plugins.explorer.widgets.explorer import ( - show_in_external_file_explorer) -from spyder.plugins.explorer.widgets.utils import fixpath -from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget -from spyder.plugins.outlineexplorer.editor import OutlineExplorerProxyEditor -from spyder.plugins.outlineexplorer.api import cell_name -from spyder.py3compat import qbytearray_to_str, to_text_string -from spyder.utils import encoding, sourcecode, syntaxhighlighters -from spyder.utils.icon_manager import ima -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import (add_actions, create_action, - create_toolbutton, MENU_SEPARATOR, - mimedata2url, set_menu_icons, - create_waitspinner) -from spyder.utils.stylesheet import ( - APP_STYLESHEET, APP_TOOLBAR_STYLESHEET, PANES_TABBAR_STYLESHEET) -from spyder.widgets.findreplace import FindReplace -from spyder.widgets.tabs import BaseTabs - - -logger = logging.getLogger(__name__) - - -class TabSwitcherWidget(QListWidget): - """Show tabs in mru order and change between them.""" - - def __init__(self, parent, stack_history, tabs): - QListWidget.__init__(self, parent) - self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) - - self.editor = parent - self.stack_history = stack_history - self.tabs = tabs - - self.setSelectionMode(QListWidget.SingleSelection) - self.itemActivated.connect(self.item_selected) - - self.id_list = [] - self.load_data() - size = CONF.get('main', 'completion/size') - self.resize(*size) - self.set_dialog_position() - self.setCurrentRow(0) - - CONF.config_shortcut(lambda: self.select_row(-1), context='Editor', - name='Go to previous file', parent=self) - CONF.config_shortcut(lambda: self.select_row(1), context='Editor', - name='Go to next file', parent=self) - - def load_data(self): - """Fill ListWidget with the tabs texts. - - Add elements in inverse order of stack_history. - """ - for index in reversed(self.stack_history): - text = self.tabs.tabText(index) - text = text.replace('&', '') - item = QListWidgetItem(ima.icon('TextFileIcon'), text) - self.addItem(item) - - def item_selected(self, item=None): - """Change to the selected document and hide this widget.""" - if item is None: - item = self.currentItem() - - # stack history is in inverse order - try: - index = self.stack_history[-(self.currentRow()+1)] - except IndexError: - pass - else: - self.editor.set_stack_index(index) - self.editor.current_changed(index) - self.hide() - - def select_row(self, steps): - """Move selected row a number of steps. - - Iterates in a cyclic behaviour. - """ - row = (self.currentRow() + steps) % self.count() - self.setCurrentRow(row) - - def set_dialog_position(self): - """Positions the tab switcher in the top-center of the editor.""" - left = int(self.editor.geometry().width()/2 - self.width()/2) - top = int(self.editor.tabs.tabBar().geometry().height() + - self.editor.fname_label.geometry().height()) - - self.move(self.editor.mapToGlobal(QPoint(left, top))) - - def keyReleaseEvent(self, event): - """Reimplement Qt method. - - Handle "most recent used" tab behavior, - When ctrl is released and tab_switcher is visible, tab will be changed. - """ - if self.isVisible(): - qsc = CONF.get_shortcut(context='Editor', name='Go to next file') - - for key in qsc.split('+'): - key = key.lower() - if ((key == 'ctrl' and event.key() == Qt.Key_Control) or - (key == 'alt' and event.key() == Qt.Key_Alt)): - self.item_selected() - event.accept() - - def keyPressEvent(self, event): - """Reimplement Qt method to allow cyclic behavior.""" - if event.key() == Qt.Key_Down: - self.select_row(1) - elif event.key() == Qt.Key_Up: - self.select_row(-1) - - def focusOutEvent(self, event): - """Reimplement Qt method to close the widget when loosing focus.""" - event.ignore() - if sys.platform == "darwin": - if event.reason() != Qt.ActiveWindowFocusReason: - self.close() - else: - self.close() - - -class EditorStack(QWidget): - reset_statusbar = Signal() - readonly_changed = Signal(bool) - encoding_changed = Signal(str) - sig_editor_cursor_position_changed = Signal(int, int) - sig_refresh_eol_chars = Signal(str) - sig_refresh_formatting = Signal(bool) - starting_long_process = Signal(str) - ending_long_process = Signal(str) - redirect_stdio = Signal(bool) - exec_in_extconsole = Signal(str, bool) - run_cell_in_ipyclient = Signal(str, object, str, bool, bool) - debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) - update_plugin_title = Signal() - editor_focus_changed = Signal() - zoom_in = Signal() - zoom_out = Signal() - zoom_reset = Signal() - sig_open_file = Signal(dict) - sig_close_file = Signal(str, str) - file_saved = Signal(str, str, str) - file_renamed_in_data = Signal(str, str, str) - opened_files_list_changed = Signal() - active_languages_stats = Signal(set) - todo_results_changed = Signal() - update_code_analysis_actions = Signal() - refresh_file_dependent_actions = Signal() - refresh_save_all_action = Signal() - sig_breakpoints_saved = Signal() - text_changed_at = Signal(str, int) - current_file_changed = Signal(str, int, int, int) - plugin_load = Signal((str,), ()) - edit_goto = Signal(str, int, str) - sig_split_vertically = Signal() - sig_split_horizontally = Signal() - sig_new_file = Signal((str,), ()) - sig_save_as = Signal() - sig_prev_edit_pos = Signal() - sig_prev_cursor = Signal() - sig_next_cursor = Signal() - sig_prev_warning = Signal() - sig_next_warning = Signal() - sig_go_to_definition = Signal(str, int, int) - sig_perform_completion_request = Signal(str, str, dict) - sig_option_changed = Signal(str, object) # config option needs changing - sig_save_bookmark = Signal(int) - sig_load_bookmark = Signal(int) - sig_save_bookmarks = Signal(str, str) - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Dictionary required by the Help pane to render a docstring. - - Examples - -------- - >>> help_data = { - 'obj_text': str, - 'name': str, - 'argspec': str, - 'note': str, - 'docstring': str, - 'force_refresh': bool, - 'path': str, - } - - See Also - -------- - :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help - """ - - def __init__(self, parent, actions): - QWidget.__init__(self, parent) - - self.setAttribute(Qt.WA_DeleteOnClose) - - self.threadmanager = ThreadManager(self) - self.new_window = False - self.horsplit_action = None - self.versplit_action = None - self.close_action = None - self.__get_split_actions() - - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - - self.menu = None - self.switcher_dlg = None - self.switcher_manager = None - self.tabs = None - self.tabs_switcher = None - - self.stack_history = StackHistory(self) - - # External panels - self.external_panels = [] - - self.setup_editorstack(parent, layout) - - self.find_widget = None - - self.data = [] - - switcher_action = create_action( - self, - _("File switcher..."), - icon=ima.icon('filelist'), - triggered=self.open_switcher_dlg) - symbolfinder_action = create_action( - self, - _("Find symbols in file..."), - icon=ima.icon('symbol_find'), - triggered=self.open_symbolfinder_dlg) - copy_to_cb_action = create_action(self, _("Copy path to clipboard"), - icon=ima.icon('editcopy'), - triggered=lambda: - QApplication.clipboard().setText(self.get_current_filename())) - close_right = create_action(self, _("Close all to the right"), - triggered=self.close_all_right) - close_all_but_this = create_action(self, _("Close all but this"), - triggered=self.close_all_but_this) - - sort_tabs = create_action(self, _("Sort tabs alphabetically"), - triggered=self.sort_file_tabs_alphabetically) - - if sys.platform == 'darwin': - text = _("Show in Finder") - else: - text = _("Show in external file explorer") - external_fileexp_action = create_action( - self, text, - triggered=self.show_in_external_file_explorer, - shortcut=CONF.get_shortcut(context="Editor", - name="show in external file explorer"), - context=Qt.WidgetShortcut) - - self.menu_actions = actions + [external_fileexp_action, - None, switcher_action, - symbolfinder_action, - copy_to_cb_action, None, close_right, - close_all_but_this, sort_tabs] - self.outlineexplorer = None - self.is_closable = False - self.new_action = None - self.open_action = None - self.save_action = None - self.revert_action = None - self.tempfile_path = None - self.title = _("Editor") - self.todolist_enabled = True - self.is_analysis_done = False - self.linenumbers_enabled = True - self.blanks_enabled = False - self.scrollpastend_enabled = False - self.edgeline_enabled = True - self.edgeline_columns = (79,) - self.close_parentheses_enabled = True - self.close_quotes_enabled = True - self.add_colons_enabled = True - self.auto_unindent_enabled = True - self.indent_chars = " "*4 - self.tab_stop_width_spaces = 4 - self.show_class_func_dropdown = False - self.help_enabled = False - self.default_font = None - self.wrap_enabled = False - self.tabmode_enabled = False - self.stripmode_enabled = False - self.intelligent_backspace_enabled = True - self.automatic_completions_enabled = True - self.automatic_completion_chars = 3 - self.automatic_completion_ms = 300 - self.completions_hint_enabled = True - self.completions_hint_after_ms = 500 - self.hover_hints_enabled = True - self.format_on_save = False - self.code_snippets_enabled = True - self.code_folding_enabled = True - self.underline_errors_enabled = False - self.highlight_current_line_enabled = False - self.highlight_current_cell_enabled = False - self.occurrence_highlighting_enabled = True - self.occurrence_highlighting_timeout = 1500 - self.checkeolchars_enabled = True - self.always_remove_trailing_spaces = False - self.add_newline = False - self.remove_trailing_newlines = False - self.convert_eol_on_save = False - self.convert_eol_on_save_to = 'LF' - self.focus_to_editor = True - self.run_cell_copy = False - self.create_new_file_if_empty = True - self.indent_guides = False - ccs = 'spyder/dark' - if ccs not in syntaxhighlighters.COLOR_SCHEME_NAMES: - ccs = syntaxhighlighters.COLOR_SCHEME_NAMES[0] - self.color_scheme = ccs - self.__file_status_flag = False - - # Real-time code analysis - self.analysis_timer = QTimer(self) - self.analysis_timer.setSingleShot(True) - self.analysis_timer.setInterval(1000) - self.analysis_timer.timeout.connect(self.analyze_script) - - # Update filename label - self.editor_focus_changed.connect(self.update_fname_label) - - # Accepting drops - self.setAcceptDrops(True) - - # Local shortcuts - self.shortcuts = self.create_shortcuts() - - # For opening last closed tabs - self.last_closed_files = [] - - # Reference to save msgbox and avoid memory to be freed. - self.msgbox = None - - # File types and filters used by the Save As dialog - self.edit_filetypes = None - self.edit_filters = None - - # For testing - self.save_dialog_on_tests = not running_under_pytest() - - # Autusave component - self.autosave = AutosaveForStack(self) - - self.last_cell_call = None - - @Slot() - def show_in_external_file_explorer(self, fnames=None): - """Show file in external file explorer""" - if fnames is None or isinstance(fnames, bool): - fnames = self.get_current_filename() - try: - show_in_external_file_explorer(fnames) - except FileNotFoundError as error: - file = str(error).split("'")[1] - if "xdg-open" in file: - msg_title = _("Warning") - msg = _("Spyder can't show this file in the external file " - "explorer because the xdg-utils package is " - "not available on your system.") - QMessageBox.information(self, msg_title, msg, - QMessageBox.Ok) - - def create_shortcuts(self): - """Create local shortcuts""" - # --- Configurable shortcuts - inspect = CONF.config_shortcut( - self.inspect_current_object, - context='Editor', - name='Inspect current object', - parent=self) - - set_breakpoint = CONF.config_shortcut( - self.set_or_clear_breakpoint, - context='Editor', - name='Breakpoint', - parent=self) - - set_cond_breakpoint = CONF.config_shortcut( - self.set_or_edit_conditional_breakpoint, - context='Editor', - name='Conditional breakpoint', - parent=self) - - gotoline = CONF.config_shortcut( - self.go_to_line, - context='Editor', - name='Go to line', - parent=self) - - tab = CONF.config_shortcut( - lambda: self.tab_navigation_mru(forward=False), - context='Editor', - name='Go to previous file', - parent=self) - - tabshift = CONF.config_shortcut( - self.tab_navigation_mru, - context='Editor', - name='Go to next file', - parent=self) - - prevtab = CONF.config_shortcut( - lambda: self.tabs.tab_navigate(-1), - context='Editor', - name='Cycle to previous file', - parent=self) - - nexttab = CONF.config_shortcut( - lambda: self.tabs.tab_navigate(1), - context='Editor', - name='Cycle to next file', - parent=self) - - run_selection = CONF.config_shortcut( - self.run_selection, - context='Editor', - name='Run selection', - parent=self) - - run_to_line = CONF.config_shortcut( - self.run_to_line, - context='Editor', - name='Run to line', - parent=self) - - run_from_line = CONF.config_shortcut( - self.run_from_line, - context='Editor', - name='Run from line', - parent=self) - - new_file = CONF.config_shortcut( - lambda: self.sig_new_file[()].emit(), - context='Editor', - name='New file', - parent=self) - - open_file = CONF.config_shortcut( - lambda: self.plugin_load[()].emit(), - context='Editor', - name='Open file', - parent=self) - - save_file = CONF.config_shortcut( - self.save, - context='Editor', - name='Save file', - parent=self) - - save_all = CONF.config_shortcut( - self.save_all, - context='Editor', - name='Save all', - parent=self) - - save_as = CONF.config_shortcut( - lambda: self.sig_save_as.emit(), - context='Editor', - name='Save As', - parent=self) - - close_all = CONF.config_shortcut( - self.close_all_files, - context='Editor', - name='Close all', - parent=self) - - prev_edit_pos = CONF.config_shortcut( - lambda: self.sig_prev_edit_pos.emit(), - context="Editor", - name="Last edit location", - parent=self) - - prev_cursor = CONF.config_shortcut( - lambda: self.sig_prev_cursor.emit(), - context="Editor", - name="Previous cursor position", - parent=self) - - next_cursor = CONF.config_shortcut( - lambda: self.sig_next_cursor.emit(), - context="Editor", - name="Next cursor position", - parent=self) - - zoom_in_1 = CONF.config_shortcut( - lambda: self.zoom_in.emit(), - context="Editor", - name="zoom in 1", - parent=self) - - zoom_in_2 = CONF.config_shortcut( - lambda: self.zoom_in.emit(), - context="Editor", - name="zoom in 2", - parent=self) - - zoom_out = CONF.config_shortcut( - lambda: self.zoom_out.emit(), - context="Editor", - name="zoom out", - parent=self) - - zoom_reset = CONF.config_shortcut( - lambda: self.zoom_reset.emit(), - context="Editor", - name="zoom reset", - parent=self) - - close_file_1 = CONF.config_shortcut( - self.close_file, - context="Editor", - name="close file 1", - parent=self) - - close_file_2 = CONF.config_shortcut( - self.close_file, - context="Editor", - name="close file 2", - parent=self) - - run_cell = CONF.config_shortcut( - self.run_cell, - context="Editor", - name="run cell", - parent=self) - - debug_cell = CONF.config_shortcut( - self.debug_cell, - context="Editor", - name="debug cell", - parent=self) - - run_cell_and_advance = CONF.config_shortcut( - self.run_cell_and_advance, - context="Editor", - name="run cell and advance", - parent=self) - - go_to_next_cell = CONF.config_shortcut( - self.advance_cell, - context="Editor", - name="go to next cell", - parent=self) - - go_to_previous_cell = CONF.config_shortcut( - lambda: self.advance_cell(reverse=True), - context="Editor", - name="go to previous cell", - parent=self) - - re_run_last_cell = CONF.config_shortcut( - self.re_run_last_cell, - context="Editor", - name="re-run last cell", - parent=self) - - prev_warning = CONF.config_shortcut( - lambda: self.sig_prev_warning.emit(), - context="Editor", - name="Previous warning", - parent=self) - - next_warning = CONF.config_shortcut( - lambda: self.sig_next_warning.emit(), - context="Editor", - name="Next warning", - parent=self) - - split_vertically = CONF.config_shortcut( - lambda: self.sig_split_vertically.emit(), - context="Editor", - name="split vertically", - parent=self) - - split_horizontally = CONF.config_shortcut( - lambda: self.sig_split_horizontally.emit(), - context="Editor", - name="split horizontally", - parent=self) - - close_split = CONF.config_shortcut( - self.close_split, - context="Editor", - name="close split panel", - parent=self) - - external_fileexp = CONF.config_shortcut( - self.show_in_external_file_explorer, - context="Editor", - name="show in external file explorer", - parent=self) - - # Return configurable ones - return [inspect, set_breakpoint, set_cond_breakpoint, gotoline, tab, - tabshift, run_selection, run_to_line, run_from_line, new_file, - open_file, save_file, save_all, save_as, close_all, - prev_edit_pos, prev_cursor, next_cursor, zoom_in_1, zoom_in_2, - zoom_out, zoom_reset, close_file_1, close_file_2, run_cell, - debug_cell, run_cell_and_advance, - go_to_next_cell, go_to_previous_cell, re_run_last_cell, - prev_warning, next_warning, split_vertically, - split_horizontally, close_split, - prevtab, nexttab, external_fileexp] - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - def setup_editorstack(self, parent, layout): - """Setup editorstack's layout""" - layout.setSpacing(0) - - # Create filename label, spinner and the toolbar that contains them - self.create_top_widgets() - - # Add top toolbar - layout.addWidget(self.top_toolbar) - - # Tabbar - menu_btn = create_toolbutton(self, icon=ima.icon('tooloptions'), - tip=_('Options')) - menu_btn.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) - self.menu = QMenu(self) - menu_btn.setMenu(self.menu) - menu_btn.setPopupMode(menu_btn.InstantPopup) - self.menu.aboutToShow.connect(self.__setup_menu) - - corner_widgets = {Qt.TopRightCorner: [menu_btn]} - self.tabs = BaseTabs(self, menu=self.menu, menu_use_tooltips=True, - corner_widgets=corner_widgets) - self.tabs.set_close_function(self.close_file) - self.tabs.tabBar().tabMoved.connect(self.move_editorstack_data) - self.tabs.setMovable(True) - - self.stack_history.refresh() - - if hasattr(self.tabs, 'setDocumentMode') \ - and not sys.platform == 'darwin': - # Don't set document mode to true on OSX because it generates - # a crash when the editor is detached from the main window - # Fixes spyder-ide/spyder#561. - self.tabs.setDocumentMode(True) - self.tabs.currentChanged.connect(self.current_changed) - - tab_container = QWidget() - tab_container.setObjectName('tab-container') - tab_layout = QHBoxLayout(tab_container) - tab_layout.setContentsMargins(0, 0, 0, 0) - tab_layout.addWidget(self.tabs) - layout.addWidget(tab_container) - - # Show/hide icons in plugin menus for Mac - if sys.platform == 'darwin': - self.menu.aboutToHide.connect( - lambda menu=self.menu: - set_menu_icons(menu, False)) - - def create_top_widgets(self): - # Filename label - self.fname_label = QLabel() - - # Spacer - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - - # Spinner - self.spinner = create_waitspinner(size=16, parent=self.fname_label) - - # Add widgets to toolbar - self.top_toolbar = QToolBar(self) - self.top_toolbar.addWidget(self.fname_label) - self.top_toolbar.addWidget(spacer) - self.top_toolbar.addWidget(self.spinner) - - # Set toolbar style - css = qstylizer.style.StyleSheet() - css.QToolBar.setValues( - margin='0px', - padding='4px', - borderBottom=f'1px solid {QStylePalette.COLOR_BACKGROUND_4}' - ) - self.top_toolbar.setStyleSheet(css.toString()) - - def hide_tooltip(self): - """Hide any open tooltips.""" - for finfo in self.data: - finfo.editor.hide_tooltip() - - @Slot() - def update_fname_label(self): - """Update file name label.""" - filename = to_text_string(self.get_current_filename()) - if len(filename) > 100: - shorten_filename = u'...' + filename[-100:] - else: - shorten_filename = filename - self.fname_label.setText(shorten_filename) - - def add_corner_widgets_to_tabbar(self, widgets): - self.tabs.add_corner_widgets(widgets) - - @Slot() - def close_split(self): - """Closes the editorstack if it is not the last one opened.""" - if self.is_closable: - self.close() - - def closeEvent(self, event): - """Overrides QWidget closeEvent().""" - self.threadmanager.close_all_threads() - self.analysis_timer.timeout.disconnect(self.analyze_script) - - # Remove editor references from the outline explorer settings - if self.outlineexplorer is not None: - for finfo in self.data: - self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) - - for finfo in self.data: - if not finfo.editor.is_cloned: - finfo.editor.notify_close() - QWidget.closeEvent(self, event) - - def clone_editor_from(self, other_finfo, set_current): - fname = other_finfo.filename - enc = other_finfo.encoding - new = other_finfo.newly_created - finfo = self.create_new_editor(fname, enc, "", - set_current=set_current, new=new, - cloned_from=other_finfo.editor) - finfo.set_todo_results(other_finfo.todo_results) - return finfo.editor - - def clone_from(self, other): - """Clone EditorStack from other instance""" - for other_finfo in other.data: - self.clone_editor_from(other_finfo, set_current=True) - self.set_stack_index(other.get_stack_index()) - - @Slot() - @Slot(str) - def open_switcher_dlg(self, initial_text=''): - """Open file list management dialog box""" - if not self.tabs.count(): - return - if self.switcher_dlg is not None and self.switcher_dlg.isVisible(): - self.switcher_dlg.hide() - self.switcher_dlg.clear() - return - if self.switcher_dlg is None: - from spyder.widgets.switcher import Switcher - self.switcher_dlg = Switcher(self) - self.switcher_manager = EditorSwitcherManager( - self.get_plugin(), - self.switcher_dlg, - lambda: self.get_current_editor(), - lambda: self, - section=self.get_plugin_title()) - - if isinstance(initial_text, bool): - initial_text = '' - - self.switcher_dlg.set_search_text(initial_text) - self.switcher_dlg.setup() - self.switcher_dlg.show() - # Note: the +1 pixel on the top makes it look better - delta_top = (self.tabs.tabBar().geometry().height() + - self.fname_label.geometry().height() + 1) - self.switcher_dlg.set_position(delta_top) - - @Slot() - def open_symbolfinder_dlg(self): - self.open_switcher_dlg(initial_text='@') - - def get_plugin(self): - """Get the plugin of the parent widget.""" - # Needed for the editor stack to use its own switcher instance. - # See spyder-ide/spyder#10684. - return self.parent().plugin - - def get_plugin_title(self): - """Get the plugin title of the parent widget.""" - # Needed for the editor stack to use its own switcher instance. - # See spyder-ide/spyder#9469. - return self.get_plugin().get_plugin_title() - - def go_to_line(self, line=None): - """Go to line dialog""" - if line is not None: - # When this method is called from the flileswitcher, a line - # number is specified, so there is no need for the dialog. - self.get_current_editor().go_to_line(line) - else: - if self.data: - self.get_current_editor().exec_gotolinedialog() - - def set_or_clear_breakpoint(self): - """Set/clear breakpoint""" - if self.data: - editor = self.get_current_editor() - editor.debugger.toogle_breakpoint() - - def set_or_edit_conditional_breakpoint(self): - """Set conditional breakpoint""" - if self.data: - editor = self.get_current_editor() - editor.debugger.toogle_breakpoint(edit_condition=True) - - def set_bookmark(self, slot_num): - """Bookmark current position to given slot.""" - if self.data: - editor = self.get_current_editor() - editor.add_bookmark(slot_num) - - def inspect_current_object(self, pos=None): - """Inspect current object in the Help plugin""" - editor = self.get_current_editor() - editor.sig_display_object_info.connect(self.display_help) - cursor = None - offset = editor.get_position('cursor') - if pos: - cursor = editor.get_last_hover_cursor() - if cursor: - offset = cursor.position() - else: - return - - line, col = editor.get_cursor_line_column(cursor) - editor.request_hover(line, col, offset, - show_hint=False, clicked=bool(pos)) - - @Slot(str, bool) - def display_help(self, help_text, clicked): - editor = self.get_current_editor() - if clicked: - name = editor.get_last_hover_word() - else: - name = editor.get_current_word(help_req=True) - - try: - editor.sig_display_object_info.disconnect(self.display_help) - except TypeError: - # Needed to prevent an error after some time in idle. - # See spyder-ide/spyder#11228 - pass - - self.send_to_help(name, help_text, force=True) - - # ------ Editor Widget Settings - def set_closable(self, state): - """Parent widget must handle the closable state""" - self.is_closable = state - - def set_io_actions(self, new_action, open_action, - save_action, revert_action): - self.new_action = new_action - self.open_action = open_action - self.save_action = save_action - self.revert_action = revert_action - - def set_find_widget(self, find_widget): - self.find_widget = find_widget - - def set_outlineexplorer(self, outlineexplorer): - self.outlineexplorer = outlineexplorer - - def add_outlineexplorer_button(self, editor_plugin): - oe_btn = create_toolbutton(editor_plugin) - oe_btn.setDefaultAction(self.outlineexplorer.visibility_action) - self.add_corner_widgets_to_tabbar([5, oe_btn]) - - def set_tempfile_path(self, path): - self.tempfile_path = path - - def set_title(self, text): - self.title = text - - def set_classfunc_dropdown_visible(self, state): - self.show_class_func_dropdown = state - if self.data: - for finfo in self.data: - if finfo.editor.is_python_like(): - finfo.editor.classfuncdropdown.setVisible(state) - - def __update_editor_margins(self, editor): - editor.linenumberarea.setup_margins( - linenumbers=self.linenumbers_enabled, markers=self.has_markers()) - - def has_markers(self): - """Return True if this editorstack has a marker margin for TODOs or - code analysis""" - return self.todolist_enabled - - def set_todolist_enabled(self, state, current_finfo=None): - # CONF.get(self.CONF_SECTION, 'todo_list') - self.todolist_enabled = state - if self.data: - for finfo in self.data: - self.__update_editor_margins(finfo.editor) - finfo.cleanup_todo_results() - if state and current_finfo is not None: - if current_finfo is not finfo: - finfo.run_todo_finder() - - def set_linenumbers_enabled(self, state, current_finfo=None): - # CONF.get(self.CONF_SECTION, 'line_numbers') - self.linenumbers_enabled = state - if self.data: - for finfo in self.data: - self.__update_editor_margins(finfo.editor) - - def set_blanks_enabled(self, state): - self.blanks_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_blanks_enabled(state) - - def set_scrollpastend_enabled(self, state): - self.scrollpastend_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_scrollpastend_enabled(state) - - def set_edgeline_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'edge_line') - self.edgeline_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.edge_line.set_enabled(state) - - def set_edgeline_columns(self, columns): - # CONF.get(self.CONF_SECTION, 'edge_line_column') - self.edgeline_columns = columns - if self.data: - for finfo in self.data: - finfo.editor.edge_line.set_columns(columns) - - def set_indent_guides(self, state): - self.indent_guides = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_identation_guides(state) - - def set_close_parentheses_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'close_parentheses') - self.close_parentheses_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_close_parentheses_enabled(state) - - def set_close_quotes_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'close_quotes') - self.close_quotes_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_close_quotes_enabled(state) - - def set_add_colons_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'add_colons') - self.add_colons_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_add_colons_enabled(state) - - def set_auto_unindent_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'auto_unindent') - self.auto_unindent_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_auto_unindent_enabled(state) - - def set_indent_chars(self, indent_chars): - # CONF.get(self.CONF_SECTION, 'indent_chars') - indent_chars = indent_chars[1:-1] # removing the leading/ending '*' - self.indent_chars = indent_chars - if self.data: - for finfo in self.data: - finfo.editor.set_indent_chars(indent_chars) - - def set_tab_stop_width_spaces(self, tab_stop_width_spaces): - # CONF.get(self.CONF_SECTION, 'tab_stop_width') - self.tab_stop_width_spaces = tab_stop_width_spaces - if self.data: - for finfo in self.data: - finfo.editor.tab_stop_width_spaces = tab_stop_width_spaces - finfo.editor.update_tab_stop_width_spaces() - - def set_help_enabled(self, state): - self.help_enabled = state - - def set_default_font(self, font, color_scheme=None): - self.default_font = font - if color_scheme is not None: - self.color_scheme = color_scheme - if self.data: - for finfo in self.data: - finfo.editor.set_font(font, color_scheme) - - def set_color_scheme(self, color_scheme): - self.color_scheme = color_scheme - if self.data: - for finfo in self.data: - finfo.editor.set_color_scheme(color_scheme) - - def set_wrap_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'wrap') - self.wrap_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_wrap_mode(state) - - def set_tabmode_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'tab_always_indent') - self.tabmode_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_tab_mode(state) - - def set_stripmode_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'strip_trailing_spaces_on_modify') - self.stripmode_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_strip_mode(state) - - def set_intelligent_backspace_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'intelligent_backspace') - self.intelligent_backspace_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_intelligent_backspace(state) - - def set_code_snippets_enabled(self, state): - self.code_snippets_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_code_snippets(state) - - def set_code_folding_enabled(self, state): - self.code_folding_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_code_folding(state) - - def set_automatic_completions_enabled(self, state): - self.automatic_completions_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_automatic_completions(state) - - def set_automatic_completions_after_chars(self, chars): - self.automatic_completion_chars = chars - if self.data: - for finfo in self.data: - finfo.editor.set_automatic_completions_after_chars(chars) - - def set_automatic_completions_after_ms(self, ms): - self.automatic_completion_ms = ms - if self.data: - for finfo in self.data: - finfo.editor.set_automatic_completions_after_ms(ms) - - def set_completions_hint_enabled(self, state): - self.completions_hint_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_completions_hint(state) - - def set_completions_hint_after_ms(self, ms): - self.completions_hint_after_ms = ms - if self.data: - for finfo in self.data: - finfo.editor.set_completions_hint_after_ms(ms) - - def set_hover_hints_enabled(self, state): - self.hover_hints_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_hover_hints(state) - - def set_format_on_save(self, state): - self.format_on_save = state - if self.data: - for finfo in self.data: - finfo.editor.toggle_format_on_save(state) - - def set_occurrence_highlighting_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'occurrence_highlighting') - self.occurrence_highlighting_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_occurrence_highlighting(state) - - def set_occurrence_highlighting_timeout(self, timeout): - # CONF.get(self.CONF_SECTION, 'occurrence_highlighting/timeout') - self.occurrence_highlighting_timeout = timeout - if self.data: - for finfo in self.data: - finfo.editor.set_occurrence_timeout(timeout) - - def set_underline_errors_enabled(self, state): - self.underline_errors_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_underline_errors_enabled(state) - - def set_highlight_current_line_enabled(self, state): - self.highlight_current_line_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_highlight_current_line(state) - - def set_highlight_current_cell_enabled(self, state): - self.highlight_current_cell_enabled = state - if self.data: - for finfo in self.data: - finfo.editor.set_highlight_current_cell(state) - - def set_checkeolchars_enabled(self, state): - # CONF.get(self.CONF_SECTION, 'check_eol_chars') - self.checkeolchars_enabled = state - - def set_always_remove_trailing_spaces(self, state): - # CONF.get(self.CONF_SECTION, 'always_remove_trailing_spaces') - self.always_remove_trailing_spaces = state - if self.data: - for finfo in self.data: - finfo.editor.set_remove_trailing_spaces(state) - - def set_add_newline(self, state): - self.add_newline = state - if self.data: - for finfo in self.data: - finfo.editor.set_add_newline(state) - - def set_remove_trailing_newlines(self, state): - self.remove_trailing_newlines = state - if self.data: - for finfo in self.data: - finfo.editor.set_remove_trailing_newlines(state) - - def set_convert_eol_on_save(self, state): - """If `state` is `True`, saving files will convert line endings.""" - # CONF.get(self.CONF_SECTION, 'convert_eol_on_save') - self.convert_eol_on_save = state - - def set_convert_eol_on_save_to(self, state): - """`state` can be one of ('LF', 'CRLF', 'CR')""" - # CONF.get(self.CONF_SECTION, 'convert_eol_on_save_to') - self.convert_eol_on_save_to = state - - def set_focus_to_editor(self, state): - self.focus_to_editor = state - - def set_run_cell_copy(self, state): - """If `state` is ``True``, code cells will be copied to the console.""" - self.run_cell_copy = state - - def set_current_project_path(self, root_path=None): - """ - Set the current active project root path. - - Parameters - ---------- - root_path: str or None, optional - Path to current project root path. Default is None. - """ - for finfo in self.data: - finfo.editor.set_current_project_path(root_path) - - # ------ Stacked widget management - def get_stack_index(self): - return self.tabs.currentIndex() - - def get_current_finfo(self): - if self.data: - return self.data[self.get_stack_index()] - - def get_current_editor(self): - return self.tabs.currentWidget() - - def get_stack_count(self): - return self.tabs.count() - - def set_stack_index(self, index, instance=None): - if instance == self or instance == None: - self.tabs.setCurrentIndex(index) - - def set_tabbar_visible(self, state): - self.tabs.tabBar().setVisible(state) - - def remove_from_data(self, index): - self.tabs.blockSignals(True) - self.tabs.removeTab(index) - self.data.pop(index) - self.tabs.blockSignals(False) - self.update_actions() - - def __modified_readonly_title(self, title, is_modified, is_readonly): - if is_modified is not None and is_modified: - title += "*" - if is_readonly is not None and is_readonly: - title = "(%s)" % title - return title - - def get_tab_text(self, index, is_modified=None, is_readonly=None): - """Return tab title.""" - files_path_list = [finfo.filename for finfo in self.data] - fname = self.data[index].filename - fname = sourcecode.disambiguate_fname(files_path_list, fname) - return self.__modified_readonly_title(fname, - is_modified, is_readonly) - - def get_tab_tip(self, filename, is_modified=None, is_readonly=None): - """Return tab menu title""" - text = u"%s — %s" - text = self.__modified_readonly_title(text, - is_modified, is_readonly) - if self.tempfile_path is not None\ - and filename == encoding.to_unicode_from_fs(self.tempfile_path): - temp_file_str = to_text_string(_("Temporary file")) - return text % (temp_file_str, self.tempfile_path) - else: - return text % (osp.basename(filename), osp.dirname(filename)) - - def add_to_data(self, finfo, set_current, add_where='end'): - finfo.editor.oe_proxy = None - index = 0 if add_where == 'start' else len(self.data) - self.data.insert(index, finfo) - index = self.data.index(finfo) - editor = finfo.editor - self.tabs.insertTab(index, editor, self.get_tab_text(index)) - self.set_stack_title(index, False) - if set_current: - self.set_stack_index(index) - self.current_changed(index) - self.update_actions() - - def __repopulate_stack(self): - self.tabs.blockSignals(True) - self.tabs.clear() - for finfo in self.data: - if finfo.newly_created: - is_modified = True - else: - is_modified = None - index = self.data.index(finfo) - tab_text = self.get_tab_text(index, is_modified) - tab_tip = self.get_tab_tip(finfo.filename) - index = self.tabs.addTab(finfo.editor, tab_text) - self.tabs.setTabToolTip(index, tab_tip) - self.tabs.blockSignals(False) - - def rename_in_data(self, original_filename, new_filename): - index = self.has_filename(original_filename) - if index is None: - return - finfo = self.data[index] - - # Send close request to LSP - finfo.editor.notify_close() - - # Set new filename - finfo.filename = new_filename - finfo.editor.filename = new_filename - - # File type has changed! - original_ext = osp.splitext(original_filename)[1] - new_ext = osp.splitext(new_filename)[1] - if original_ext != new_ext: - # Set file language and re-run highlighter - txt = to_text_string(finfo.editor.get_text_with_eol()) - language = get_file_language(new_filename, txt) - finfo.editor.set_language(language, new_filename) - finfo.editor.run_pygments_highlighter() - - # If the user renamed the file to a different language, we - # need to emit sig_open_file to see if we can start a - # language server for it. - options = { - 'language': language, - 'filename': new_filename, - 'codeeditor': finfo.editor - } - self.sig_open_file.emit(options) - - # Update panels - finfo.editor.set_debug_panel( - show_debug_panel=True, language=language) - finfo.editor.cleanup_code_analysis() - finfo.editor.cleanup_folding() - else: - # If there's no language change, we simply need to request a - # document_did_open for the new file. - finfo.editor.document_did_open() - - set_new_index = index == self.get_stack_index() - current_fname = self.get_current_filename() - finfo.editor.filename = new_filename - new_index = self.data.index(finfo) - self.__repopulate_stack() - if set_new_index: - self.set_stack_index(new_index) - else: - # Fixes spyder-ide/spyder#1287. - self.set_current_filename(current_fname) - if self.outlineexplorer is not None: - self.outlineexplorer.file_renamed( - finfo.editor.oe_proxy, finfo.filename) - return new_index - - def set_stack_title(self, index, is_modified): - finfo = self.data[index] - fname = finfo.filename - is_modified = (is_modified or finfo.newly_created) and not finfo.default - is_readonly = finfo.editor.isReadOnly() - tab_text = self.get_tab_text(index, is_modified, is_readonly) - tab_tip = self.get_tab_tip(fname, is_modified, is_readonly) - - # Only update tab text if have changed, otherwise an unwanted scrolling - # will happen when changing tabs. See spyder-ide/spyder#1170. - if tab_text != self.tabs.tabText(index): - self.tabs.setTabText(index, tab_text) - self.tabs.setTabToolTip(index, tab_tip) - - # ------ Context menu - def __setup_menu(self): - """Setup tab context menu before showing it""" - self.menu.clear() - if self.data: - actions = self.menu_actions - else: - actions = (self.new_action, self.open_action) - self.setFocus() # --> Editor.__get_focus_editortabwidget - add_actions(self.menu, list(actions) + self.__get_split_actions()) - self.close_action.setEnabled(self.is_closable) - - if sys.platform == 'darwin': - set_menu_icons(self.menu, True) - - # ------ Hor/Ver splitting - def __get_split_actions(self): - if self.parent() is not None: - plugin = self.parent().plugin - else: - plugin = None - - # New window - if plugin is not None: - self.new_window_action = create_action( - self, _("New window"), - icon=ima.icon('newwindow'), - tip=_("Create a new editor window"), - triggered=plugin.create_new_window) - - # Splitting - self.versplit_action = create_action( - self, - _("Split vertically"), - icon=ima.icon('versplit'), - tip=_("Split vertically this editor window"), - triggered=lambda: self.sig_split_vertically.emit(), - shortcut=CONF.get_shortcut(context='Editor', - name='split vertically'), - context=Qt.WidgetShortcut) - - self.horsplit_action = create_action( - self, - _("Split horizontally"), - icon=ima.icon('horsplit'), - tip=_("Split horizontally this editor window"), - triggered=lambda: self.sig_split_horizontally.emit(), - shortcut=CONF.get_shortcut(context='Editor', - name='split horizontally'), - context=Qt.WidgetShortcut) - - self.close_action = create_action( - self, - _("Close this panel"), - icon=ima.icon('close_panel'), - triggered=self.close_split, - shortcut=CONF.get_shortcut(context='Editor', - name='close split panel'), - context=Qt.WidgetShortcut) - - # Regular actions - actions = [MENU_SEPARATOR, self.versplit_action, - self.horsplit_action, self.close_action] - - if self.new_window: - window = self.window() - close_window_action = create_action( - self, _("Close window"), - icon=ima.icon('close_pane'), - triggered=window.close) - actions += [MENU_SEPARATOR, self.new_window_action, - close_window_action] - elif plugin is not None: - if plugin._undocked_window is not None: - actions += [MENU_SEPARATOR, plugin._dock_action] - else: - actions += [MENU_SEPARATOR, self.new_window_action, - plugin._lock_unlock_action, - plugin._undock_action, - plugin._close_plugin_action] - - return actions - - def reset_orientation(self): - self.horsplit_action.setEnabled(True) - self.versplit_action.setEnabled(True) - - def set_orientation(self, orientation): - self.horsplit_action.setEnabled(orientation == Qt.Horizontal) - self.versplit_action.setEnabled(orientation == Qt.Vertical) - - def update_actions(self): - state = self.get_stack_count() > 0 - self.horsplit_action.setEnabled(state) - self.versplit_action.setEnabled(state) - - # ------ Accessors - def get_current_filename(self): - if self.data: - return self.data[self.get_stack_index()].filename - - def get_current_language(self): - if self.data: - return self.data[self.get_stack_index()].editor.language - - def get_filenames(self): - """ - Return a list with the names of all the files currently opened in - the editorstack. - """ - return [finfo.filename for finfo in self.data] - - def has_filename(self, filename): - """Return the self.data index position for the filename. - - Args: - filename: Name of the file to search for in self.data. - - Returns: - The self.data index for the filename. Returns None - if the filename is not found in self.data. - """ - data_filenames = self.get_filenames() - try: - # Try finding without calling the slow realpath - return data_filenames.index(filename) - except ValueError: - # See note about OSError on set_current_filename - # Fixes spyder-ide/spyder#17685 - try: - filename = fixpath(filename) - except OSError: - return None - - for index, editor_filename in enumerate(data_filenames): - if filename == fixpath(editor_filename): - return index - return None - - def set_current_filename(self, filename, focus=True): - """Set current filename and return the associated editor instance.""" - # FileNotFoundError: This is necessary to catch an error on Windows - # for files in a directory junction pointing to a symlink whose target - # is on a network drive that is unavailable at startup. - # Fixes spyder-ide/spyder#15714 - # OSError: This is necessary to catch an error on Windows when Spyder - # was closed with a file in a shared folder on a different computer on - # the network, and is started again when that folder is not available. - # Fixes spyder-ide/spyder#17685 - try: - index = self.has_filename(filename) - except (FileNotFoundError, OSError): - index = None - - if index is not None: - if focus: - self.set_stack_index(index) - editor = self.data[index].editor - if focus: - editor.setFocus() - else: - self.stack_history.remove_and_append(index) - - return editor - - def is_file_opened(self, filename=None): - """Return if filename is in the editor stack. - - Args: - filename: Name of the file to search for. If filename is None, - then checks if any file is open. - - Returns: - True: If filename is None and a file is open. - False: If filename is None and no files are open. - None: If filename is not None and the file isn't found. - integer: Index of file name in editor stack. - """ - if filename is None: - # Is there any file opened? - return len(self.data) > 0 - else: - return self.has_filename(filename) - - def get_index_from_filename(self, filename): - """ - Return the position index of a file in the tab bar of the editorstack - from its name. - """ - filenames = [d.filename for d in self.data] - return filenames.index(filename) - - @Slot(int, int) - def move_editorstack_data(self, start, end): - """Reorder editorstack.data so it is synchronized with the tab bar when - tabs are moved.""" - if start < 0 or end < 0: - return - else: - steps = abs(end - start) - direction = (end-start) // steps # +1 for right, -1 for left - - data = self.data - self.blockSignals(True) - - for i in range(start, end, direction): - data[i], data[i+direction] = data[i+direction], data[i] - - self.blockSignals(False) - self.refresh() - - # ------ Close file, tabwidget... - def close_file(self, index=None, force=False): - """Close file (index=None -> close current file) - Keep current file index unchanged (if current file - that is being closed)""" - current_index = self.get_stack_index() - count = self.get_stack_count() - - if index is None: - if count > 0: - index = current_index - else: - self.find_widget.set_editor(None) - return - - new_index = None - if count > 1: - if current_index == index: - new_index = self._get_previous_file_index() - else: - new_index = current_index - - can_close_file = self.parent().plugin.can_close_file( - self.data[index].filename) if self.parent() else True - is_ok = (force or self.save_if_changed(cancelable=True, index=index) - and can_close_file) - if is_ok: - finfo = self.data[index] - self.threadmanager.close_threads(finfo) - # Removing editor reference from outline explorer settings: - if self.outlineexplorer is not None: - self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) - - filename = self.data[index].filename - self.remove_from_data(index) - finfo.editor.notify_close() - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.sig_close_file.emit(str(id(self)), filename) - - self.opened_files_list_changed.emit() - self.update_code_analysis_actions.emit() - self.refresh_file_dependent_actions.emit() - self.update_plugin_title.emit() - - editor = self.get_current_editor() - if editor: - editor.setFocus() - - if new_index is not None: - if index < new_index: - new_index -= 1 - self.set_stack_index(new_index) - - self.add_last_closed_file(finfo.filename) - - if finfo.filename in self.autosave.file_hashes: - del self.autosave.file_hashes[finfo.filename] - - if self.get_stack_count() == 0 and self.create_new_file_if_empty: - self.sig_new_file[()].emit() - self.update_fname_label() - return False - self.__modify_stack_title() - return is_ok - - def register_completion_capabilities(self, capabilities, language): - """ - Register completion server capabilities across all editors. - - Parameters - ---------- - capabilities: dict - Capabilities supported by a language server. - language: str - Programming language for the language server (it has to be - in small caps). - """ - for index in range(self.get_stack_count()): - editor = self.tabs.widget(index) - if editor.language.lower() == language: - editor.register_completion_capabilities(capabilities) - - def start_completion_services(self, language): - """Notify language server availability to code editors.""" - for index in range(self.get_stack_count()): - editor = self.tabs.widget(index) - if editor.language.lower() == language: - editor.start_completion_services() - - def stop_completion_services(self, language): - """Notify language server unavailability to code editors.""" - for index in range(self.get_stack_count()): - editor = self.tabs.widget(index) - if editor.language.lower() == language: - editor.stop_completion_services() - - def close_all_files(self): - """Close all opened scripts""" - while self.close_file(): - pass - - def close_all_right(self): - """ Close all files opened to the right """ - num = self.get_stack_index() - n = self.get_stack_count() - for __ in range(num, n-1): - self.close_file(num+1) - - def close_all_but_this(self): - """Close all files but the current one""" - self.close_all_right() - for __ in range(0, self.get_stack_count() - 1): - self.close_file(0) - - def sort_file_tabs_alphabetically(self): - """Sort open tabs alphabetically.""" - while self.sorted() is False: - for i in range(0, self.tabs.tabBar().count()): - if(self.tabs.tabBar().tabText(i) > - self.tabs.tabBar().tabText(i + 1)): - self.tabs.tabBar().moveTab(i, i + 1) - - def sorted(self): - """Utility function for sort_file_tabs_alphabetically().""" - for i in range(0, self.tabs.tabBar().count() - 1): - if (self.tabs.tabBar().tabText(i) > - self.tabs.tabBar().tabText(i + 1)): - return False - return True - - def add_last_closed_file(self, fname): - """Add to last closed file list.""" - if fname in self.last_closed_files: - self.last_closed_files.remove(fname) - self.last_closed_files.insert(0, fname) - if len(self.last_closed_files) > 10: - self.last_closed_files.pop(-1) - - def get_last_closed_files(self): - return self.last_closed_files - - def set_last_closed_files(self, fnames): - self.last_closed_files = fnames - - # ------ Save - def save_if_changed(self, cancelable=False, index=None): - """Ask user to save file if modified. - - Args: - cancelable: Show Cancel button. - index: File to check for modification. - - Returns: - False when save() fails or is cancelled. - True when save() is successful, there are no modifications, - or user selects No or NoToAll. - - This function controls the message box prompt for saving - changed files. The actual save is performed in save() for - each index processed. This function also removes autosave files - corresponding to files the user chooses not to save. - """ - if index is None: - indexes = list(range(self.get_stack_count())) - else: - indexes = [index] - buttons = QMessageBox.Yes | QMessageBox.No - if cancelable: - buttons |= QMessageBox.Cancel - unsaved_nb = 0 - for index in indexes: - if self.data[index].editor.document().isModified(): - unsaved_nb += 1 - if not unsaved_nb: - # No file to save - return True - if unsaved_nb > 1: - buttons |= int(QMessageBox.YesToAll | QMessageBox.NoToAll) - yes_all = no_all = False - for index in indexes: - self.set_stack_index(index) - finfo = self.data[index] - if finfo.filename == self.tempfile_path or yes_all: - if not self.save(index): - return False - elif no_all: - self.autosave.remove_autosave_file(finfo) - elif (finfo.editor.document().isModified() and - self.save_dialog_on_tests): - - self.msgbox = QMessageBox( - QMessageBox.Question, - self.title, - _("%s has been modified." - "
Do you want to save changes?" - ) % osp.basename(finfo.filename), - buttons, - parent=self) - - answer = self.msgbox.exec_() - if answer == QMessageBox.Yes: - if not self.save(index): - return False - elif answer == QMessageBox.No: - self.autosave.remove_autosave_file(finfo.filename) - elif answer == QMessageBox.YesToAll: - if not self.save(index): - return False - yes_all = True - elif answer == QMessageBox.NoToAll: - self.autosave.remove_autosave_file(finfo.filename) - no_all = True - elif answer == QMessageBox.Cancel: - return False - return True - - def compute_hash(self, fileinfo): - """Compute hash of contents of editor. - - Args: - fileinfo: FileInfo object associated to editor whose hash needs - to be computed. - - Returns: - int: computed hash. - """ - txt = to_text_string(fileinfo.editor.get_text_with_eol()) - return hash(txt) - - def _write_to_file(self, fileinfo, filename): - """Low-level function for writing text of editor to file. - - Args: - fileinfo: FileInfo object associated to editor to be saved - filename: str with filename to save to - - This is a low-level function that only saves the text to file in the - correct encoding without doing any error handling. - """ - txt = to_text_string(fileinfo.editor.get_text_with_eol()) - fileinfo.encoding = encoding.write(txt, filename, fileinfo.encoding) - - def save(self, index=None, force=False, save_new_files=True): - """Write text of editor to a file. - - Args: - index: self.data index to save. If None, defaults to - currentIndex(). - force: Force save regardless of file state. - - Returns: - True upon successful save or when file doesn't need to be saved. - False if save failed. - - If the text isn't modified and it's not newly created, then the save - is aborted. If the file hasn't been saved before, then save_as() - is invoked. Otherwise, the file is written using the file name - currently in self.data. This function doesn't change the file name. - """ - if index is None: - # Save the currently edited file - if not self.get_stack_count(): - return - index = self.get_stack_index() - - finfo = self.data[index] - if not (finfo.editor.document().isModified() or - finfo.newly_created) and not force: - return True - if not osp.isfile(finfo.filename) and not force: - # File has not been saved yet - if save_new_files: - return self.save_as(index=index) - # The file doesn't need to be saved - return True - - # The following options (`always_remove_trailing_spaces`, - # `remove_trailing_newlines` and `add_newline`) also depend on the - # `format_on_save` value. - # See spyder-ide/spyder#17716 - if self.always_remove_trailing_spaces and not self.format_on_save: - self.remove_trailing_spaces(index) - if self.remove_trailing_newlines and not self.format_on_save: - self.trim_trailing_newlines(index) - if self.add_newline and not self.format_on_save: - self.add_newline_to_file(index) - - if self.convert_eol_on_save: - # hack to account for the fact that the config file saves - # CR/LF/CRLF while set_os_eol_chars wants the os.name value. - osname_lookup = {'LF': 'posix', 'CRLF': 'nt', 'CR': 'mac'} - osname = osname_lookup[self.convert_eol_on_save_to] - self.set_os_eol_chars(osname=osname) - - try: - if self.format_on_save and finfo.editor.formatting_enabled: - # Wait for document autoformat and then save - - # Waiting for the autoformat to complete is needed - # when the file is going to be closed after saving. - # See spyder-ide/spyder#17836 - format_eventloop = finfo.editor.format_eventloop - format_timer = finfo.editor.format_timer - format_timer.setSingleShot(True) - format_timer.timeout.connect(format_eventloop.quit) - - finfo.editor.sig_stop_operation_in_progress.connect( - lambda: self._save_file(finfo)) - finfo.editor.sig_stop_operation_in_progress.connect( - format_timer.stop) - finfo.editor.sig_stop_operation_in_progress.connect( - format_eventloop.quit) - - format_timer.start(10000) - finfo.editor.format_document() - format_eventloop.exec_() - else: - self._save_file(finfo) - return True - except EnvironmentError as error: - self.msgbox = QMessageBox( - QMessageBox.Critical, - _("Save Error"), - _("Unable to save file '%s'" - "

Error message:
%s" - ) % (osp.basename(finfo.filename), - str(error)), - parent=self) - self.msgbox.exec_() - return False - - def _save_file(self, finfo): - index = self.data.index(finfo) - self._write_to_file(finfo, finfo.filename) - file_hash = self.compute_hash(finfo) - self.autosave.file_hashes[finfo.filename] = file_hash - self.autosave.remove_autosave_file(finfo.filename) - finfo.newly_created = False - self.encoding_changed.emit(finfo.encoding) - finfo.lastmodified = QFileInfo(finfo.filename).lastModified() - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - # The filename is passed instead of an index in case the tabs - # have been rearranged. See spyder-ide/spyder#5703. - self.file_saved.emit(str(id(self)), - finfo.filename, finfo.filename) - - finfo.editor.document().setModified(False) - self.modification_changed(index=index) - self.analyze_script(index=index) - - finfo.editor.notify_save() - - def file_saved_in_other_editorstack(self, original_filename, filename): - """ - File was just saved in another editorstack, let's synchronize! - This avoids file being automatically reloaded. - - The original filename is passed instead of an index in case the tabs - on the editor stacks were moved and are now in a different order - see - spyder-ide/spyder#5703. - Filename is passed in case file was just saved as another name. - """ - index = self.has_filename(original_filename) - if index is None: - return - finfo = self.data[index] - finfo.newly_created = False - finfo.filename = to_text_string(filename) - finfo.lastmodified = QFileInfo(finfo.filename).lastModified() - - def select_savename(self, original_filename): - """Select a name to save a file. - - Args: - original_filename: Used in the dialog to display the current file - path and name. - - Returns: - Normalized path for the selected file name or None if no name was - selected. - """ - if self.edit_filetypes is None: - self.edit_filetypes = get_edit_filetypes() - if self.edit_filters is None: - self.edit_filters = get_edit_filters() - - # Don't use filters on KDE to not make the dialog incredible - # slow - # Fixes spyder-ide/spyder#4156. - if is_kde_desktop() and not is_anaconda(): - filters = '' - selectedfilter = '' - else: - filters = self.edit_filters - selectedfilter = get_filter(self.edit_filetypes, - osp.splitext(original_filename)[1]) - - self.redirect_stdio.emit(False) - filename, _selfilter = getsavefilename(self, _("Save file"), - original_filename, - filters=filters, - selectedfilter=selectedfilter, - options=QFileDialog.HideNameFilterDetails) - self.redirect_stdio.emit(True) - if filename: - return osp.normpath(filename) - return None - - def save_as(self, index=None): - """Save file as... - - Args: - index: self.data index for the file to save. - - Returns: - False if no file name was selected or if save() was unsuccessful. - True is save() was successful. - - Gets the new file name from select_savename(). If no name is chosen, - then the save_as() aborts. Otherwise, the current stack is checked - to see if the selected name already exists and, if so, then the tab - with that name is closed. - - The current stack (self.data) and current tabs are updated with the - new name and other file info. The text is written with the new - name using save() and the name change is propagated to the other stacks - via the file_renamed_in_data signal. - """ - if index is None: - # Save the currently edited file - index = self.get_stack_index() - finfo = self.data[index] - original_newly_created = finfo.newly_created - # The next line is necessary to avoid checking if the file exists - # While running __check_file_status - # See spyder-ide/spyder#3678 and spyder-ide/spyder#3026. - finfo.newly_created = True - original_filename = finfo.filename - filename = self.select_savename(original_filename) - if filename: - ao_index = self.has_filename(filename) - # Note: ao_index == index --> saving an untitled file - if ao_index is not None and ao_index != index: - if not self.close_file(ao_index): - return - if ao_index < index: - index -= 1 - - new_index = self.rename_in_data(original_filename, - new_filename=filename) - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.file_renamed_in_data.emit(str(id(self)), - original_filename, filename) - - ok = self.save(index=new_index, force=True) - self.refresh(new_index) - self.set_stack_index(new_index) - return ok - else: - finfo.newly_created = original_newly_created - return False - - def save_copy_as(self, index=None): - """Save copy of file as... - - Args: - index: self.data index for the file to save. - - Returns: - False if no file name was selected or if save() was unsuccessful. - True is save() was successful. - - Gets the new file name from select_savename(). If no name is chosen, - then the save_copy_as() aborts. Otherwise, the current stack is - checked to see if the selected name already exists and, if so, then the - tab with that name is closed. - - Unlike save_as(), this calls write() directly instead of using save(). - The current file and tab aren't changed at all. The copied file is - opened in a new tab. - """ - if index is None: - # Save the currently edited file - index = self.get_stack_index() - finfo = self.data[index] - original_filename = finfo.filename - filename = self.select_savename(original_filename) - if filename: - ao_index = self.has_filename(filename) - # Note: ao_index == index --> saving an untitled file - if ao_index is not None and ao_index != index: - if not self.close_file(ao_index): - return - if ao_index < index: - index -= 1 - try: - self._write_to_file(finfo, filename) - # open created copy file - self.plugin_load.emit(filename) - return True - except EnvironmentError as error: - self.msgbox = QMessageBox( - QMessageBox.Critical, - _("Save Error"), - _("Unable to save file '%s'" - "

Error message:
%s" - ) % (osp.basename(finfo.filename), - str(error)), - parent=self) - self.msgbox.exec_() - else: - return False - - def save_all(self, save_new_files=True): - """Save all opened files. - - Iterate through self.data and call save() on any modified files. - """ - all_saved = True - for index in range(self.get_stack_count()): - if self.data[index].editor.document().isModified(): - all_saved &= self.save(index, save_new_files=save_new_files) - return all_saved - - #------ Update UI - def start_stop_analysis_timer(self): - self.is_analysis_done = False - self.analysis_timer.stop() - self.analysis_timer.start() - - def analyze_script(self, index=None): - """Analyze current script for TODOs.""" - if self.is_analysis_done: - return - if index is None: - index = self.get_stack_index() - if self.data and len(self.data) > index: - finfo = self.data[index] - if self.todolist_enabled: - finfo.run_todo_finder() - self.is_analysis_done = True - - def set_todo_results(self, filename, todo_results): - """Synchronize todo results between editorstacks""" - index = self.has_filename(filename) - if index is None: - return - self.data[index].set_todo_results(todo_results) - - def get_todo_results(self): - if self.data: - return self.data[self.get_stack_index()].todo_results - - def current_changed(self, index): - """Stack index has changed""" - editor = self.get_current_editor() - if index != -1: - editor.setFocus() - logger.debug("Set focus to: %s" % editor.filename) - else: - self.reset_statusbar.emit() - self.opened_files_list_changed.emit() - - self.stack_history.refresh() - self.stack_history.remove_and_append(index) - - # Needed to avoid an error generated after moving/renaming - # files outside Spyder while in debug mode. - # See spyder-ide/spyder#8749. - try: - logger.debug("Current changed: %d - %s" % - (index, self.data[index].editor.filename)) - except IndexError: - pass - - self.update_plugin_title.emit() - # Make sure that any replace happens in the editor on top - # See spyder-ide/spyder#9688. - self.find_widget.set_editor(editor, refresh=False) - - if editor is not None: - # Needed in order to handle the close of files open in a directory - # that has been renamed. See spyder-ide/spyder#5157. - try: - line, col = editor.get_cursor_line_column() - self.current_file_changed.emit(self.data[index].filename, - editor.get_position('cursor'), - line, col) - except IndexError: - pass - - def _get_previous_file_index(self): - """Return the penultimate element of the stack history.""" - try: - return self.stack_history[-2] - except IndexError: - return None - - def tab_navigation_mru(self, forward=True): - """ - Tab navigation with "most recently used" behaviour. - - It's fired when pressing 'go to previous file' or 'go to next file' - shortcuts. - - forward: - True: move to next file - False: move to previous file - """ - self.tabs_switcher = TabSwitcherWidget(self, self.stack_history, - self.tabs) - self.tabs_switcher.show() - self.tabs_switcher.select_row(1 if forward else -1) - self.tabs_switcher.setFocus() - - def focus_changed(self): - """Editor focus has changed""" - fwidget = QApplication.focusWidget() - for finfo in self.data: - if fwidget is finfo.editor: - if finfo.editor.operation_in_progress: - self.spinner.start() - else: - self.spinner.stop() - self.refresh() - self.editor_focus_changed.emit() - - def _refresh_outlineexplorer(self, index=None, update=True, clear=False): - """Refresh outline explorer panel""" - oe = self.outlineexplorer - if oe is None: - return - if index is None: - index = self.get_stack_index() - if self.data and len(self.data) > index: - finfo = self.data[index] - oe.setEnabled(True) - oe.set_current_editor(finfo.editor.oe_proxy, - update=update, clear=clear) - if index != self.get_stack_index(): - # The last file added to the outline explorer is not the - # currently focused one in the editor stack. Therefore, - # we need to force a refresh of the outline explorer to set - # the current editor to the currently focused one in the - # editor stack. See spyder-ide/spyder#8015. - self._refresh_outlineexplorer(update=False) - return - self._sync_outlineexplorer_file_order() - - def _sync_outlineexplorer_file_order(self): - """ - Order the root file items of the outline explorer as in the tabbar - of the current EditorStack. - """ - if self.outlineexplorer is not None: - self.outlineexplorer.treewidget.set_editor_ids_order( - [finfo.editor.get_document_id() for finfo in self.data]) - - def __refresh_statusbar(self, index): - """Refreshing statusbar widgets""" - if self.data and len(self.data) > index: - finfo = self.data[index] - self.encoding_changed.emit(finfo.encoding) - # Refresh cursor position status: - line, index = finfo.editor.get_cursor_line_column() - self.sig_editor_cursor_position_changed.emit(line, index) - - def __refresh_readonly(self, index): - if self.data and len(self.data) > index: - finfo = self.data[index] - read_only = not QFileInfo(finfo.filename).isWritable() - if not osp.isfile(finfo.filename): - # This is an 'untitledX.py' file (newly created) - read_only = False - elif os.name == 'nt': - try: - # Try to open the file to see if its permissions allow - # to write on it - # Fixes spyder-ide/spyder#10657 - fd = os.open(finfo.filename, os.O_RDWR) - os.close(fd) - except (IOError, OSError): - read_only = True - finfo.editor.setReadOnly(read_only) - self.readonly_changed.emit(read_only) - - def __check_file_status(self, index): - """Check if file has been changed in any way outside Spyder: - 1. removed, moved or renamed outside Spyder - 2. modified outside Spyder""" - if self.__file_status_flag: - # Avoid infinite loop: when the QMessageBox.question pops, it - # gets focus and then give it back to the CodeEditor instance, - # triggering a refresh cycle which calls this method - return - self.__file_status_flag = True - - if len(self.data) <= index: - index = self.get_stack_index() - - finfo = self.data[index] - name = osp.basename(finfo.filename) - - if finfo.newly_created: - # File was just created (not yet saved): do nothing - # (do not return because of the clean-up at the end of the method) - pass - - elif not osp.isfile(finfo.filename): - # File doesn't exist (removed, moved or offline): - self.msgbox = QMessageBox( - QMessageBox.Warning, - self.title, - _("%s is unavailable " - "(this file may have been removed, moved " - "or renamed outside Spyder)." - "
Do you want to close it?") % name, - QMessageBox.Yes | QMessageBox.No, - self) - answer = self.msgbox.exec_() - if answer == QMessageBox.Yes: - self.close_file(index) - else: - finfo.newly_created = True - finfo.editor.document().setModified(True) - self.modification_changed(index=index) - - else: - # Else, testing if it has been modified elsewhere: - lastm = QFileInfo(finfo.filename).lastModified() - if to_text_string(lastm.toString()) \ - != to_text_string(finfo.lastmodified.toString()): - if finfo.editor.document().isModified(): - self.msgbox = QMessageBox( - QMessageBox.Question, - self.title, - _("%s has been modified outside Spyder." - "
Do you want to reload it and lose all " - "your changes?") % name, - QMessageBox.Yes | QMessageBox.No, - self) - answer = self.msgbox.exec_() - if answer == QMessageBox.Yes: - self.reload(index) - else: - finfo.lastmodified = lastm - else: - self.reload(index) - - # Finally, resetting temporary flag: - self.__file_status_flag = False - - def __modify_stack_title(self): - for index, finfo in enumerate(self.data): - state = finfo.editor.document().isModified() - self.set_stack_title(index, state) - - def refresh(self, index=None): - """Refresh tabwidget""" - if index is None: - index = self.get_stack_index() - # Set current editor - if self.get_stack_count(): - index = self.get_stack_index() - finfo = self.data[index] - editor = finfo.editor - editor.setFocus() - self._refresh_outlineexplorer(index, update=False) - self.update_code_analysis_actions.emit() - self.__refresh_statusbar(index) - self.__refresh_readonly(index) - self.__check_file_status(index) - self.__modify_stack_title() - self.update_plugin_title.emit() - else: - editor = None - # Update the modification-state-dependent parameters - self.modification_changed() - # Update FindReplace binding - self.find_widget.set_editor(editor, refresh=False) - - def modification_changed(self, state=None, index=None, editor_id=None): - """ - Current editor's modification state has changed - --> change tab title depending on new modification state - --> enable/disable save/save all actions - """ - if editor_id is not None: - for index, _finfo in enumerate(self.data): - if id(_finfo.editor) == editor_id: - break - # This must be done before refreshing save/save all actions: - # (otherwise Save/Save all actions will always be enabled) - self.opened_files_list_changed.emit() - # -- - if index is None: - index = self.get_stack_index() - if index == -1: - return - finfo = self.data[index] - if state is None: - state = finfo.editor.document().isModified() or finfo.newly_created - self.set_stack_title(index, state) - # Toggle save/save all actions state - self.save_action.setEnabled(state) - self.refresh_save_all_action.emit() - # Refreshing eol mode - eol_chars = finfo.editor.get_line_separator() - self.refresh_eol_chars(eol_chars) - self.stack_history.refresh() - - def refresh_eol_chars(self, eol_chars): - os_name = sourcecode.get_os_name_from_eol_chars(eol_chars) - self.sig_refresh_eol_chars.emit(os_name) - - # ------ Load, reload - def reload(self, index): - """Reload file from disk.""" - finfo = self.data[index] - logger.debug("Reloading {}".format(finfo.filename)) - - txt, finfo.encoding = encoding.read(finfo.filename) - finfo.lastmodified = QFileInfo(finfo.filename).lastModified() - position = finfo.editor.get_position('cursor') - finfo.editor.set_text(txt) - finfo.editor.document().setModified(False) - self.autosave.file_hashes[finfo.filename] = hash(txt) - finfo.editor.set_cursor_position(position) - - #XXX CodeEditor-only: re-scan the whole text to rebuild outline - # explorer data from scratch (could be optimized because - # rehighlighting text means searching for all syntax coloring - # patterns instead of only searching for class/def patterns which - # would be sufficient for outline explorer data. - finfo.editor.rehighlight() - - def revert(self): - """Revert file from disk.""" - index = self.get_stack_index() - finfo = self.data[index] - logger.debug("Reverting {}".format(finfo.filename)) - - filename = finfo.filename - if finfo.editor.document().isModified(): - self.msgbox = QMessageBox( - QMessageBox.Warning, - self.title, - _("All changes to %s will be lost." - "
Do you want to revert file from disk?" - ) % osp.basename(filename), - QMessageBox.Yes | QMessageBox.No, - self) - answer = self.msgbox.exec_() - if answer != QMessageBox.Yes: - return - self.reload(index) - - def create_new_editor(self, fname, enc, txt, set_current, new=False, - cloned_from=None, add_where='end'): - """ - Create a new editor instance - Returns finfo object (instead of editor as in previous releases) - """ - editor = codeeditor.CodeEditor(self) - editor.go_to_definition.connect( - lambda fname, line, column: self.sig_go_to_definition.emit( - fname, line, column)) - - finfo = FileInfo(fname, enc, editor, new, self.threadmanager) - - self.add_to_data(finfo, set_current, add_where) - finfo.sig_send_to_help.connect(self.send_to_help) - finfo.sig_show_object_info.connect(self.inspect_current_object) - finfo.todo_results_changed.connect( - lambda: self.todo_results_changed.emit()) - finfo.edit_goto.connect(lambda fname, lineno, name: - self.edit_goto.emit(fname, lineno, name)) - finfo.sig_save_bookmarks.connect(lambda s1, s2: - self.sig_save_bookmarks.emit(s1, s2)) - editor.sig_run_selection.connect(self.run_selection) - editor.sig_run_to_line.connect(self.run_to_line) - editor.sig_run_from_line.connect(self.run_from_line) - editor.sig_run_cell.connect(self.run_cell) - editor.sig_debug_cell.connect(self.debug_cell) - editor.sig_run_cell_and_advance.connect(self.run_cell_and_advance) - editor.sig_re_run_last_cell.connect(self.re_run_last_cell) - editor.sig_new_file.connect(self.sig_new_file) - editor.sig_breakpoints_saved.connect(self.sig_breakpoints_saved) - editor.sig_process_code_analysis.connect( - lambda: self.update_code_analysis_actions.emit()) - editor.sig_refresh_formatting.connect(self.sig_refresh_formatting) - language = get_file_language(fname, txt) - editor.setup_editor( - linenumbers=self.linenumbers_enabled, - show_blanks=self.blanks_enabled, - underline_errors=self.underline_errors_enabled, - scroll_past_end=self.scrollpastend_enabled, - edge_line=self.edgeline_enabled, - edge_line_columns=self.edgeline_columns, - language=language, - markers=self.has_markers(), - font=self.default_font, - color_scheme=self.color_scheme, - wrap=self.wrap_enabled, - tab_mode=self.tabmode_enabled, - strip_mode=self.stripmode_enabled, - intelligent_backspace=self.intelligent_backspace_enabled, - automatic_completions=self.automatic_completions_enabled, - automatic_completions_after_chars=self.automatic_completion_chars, - automatic_completions_after_ms=self.automatic_completion_ms, - code_snippets=self.code_snippets_enabled, - completions_hint=self.completions_hint_enabled, - completions_hint_after_ms=self.completions_hint_after_ms, - hover_hints=self.hover_hints_enabled, - highlight_current_line=self.highlight_current_line_enabled, - highlight_current_cell=self.highlight_current_cell_enabled, - occurrence_highlighting=self.occurrence_highlighting_enabled, - occurrence_timeout=self.occurrence_highlighting_timeout, - close_parentheses=self.close_parentheses_enabled, - close_quotes=self.close_quotes_enabled, - add_colons=self.add_colons_enabled, - auto_unindent=self.auto_unindent_enabled, - indent_chars=self.indent_chars, - tab_stop_width_spaces=self.tab_stop_width_spaces, - cloned_from=cloned_from, - filename=fname, - show_class_func_dropdown=self.show_class_func_dropdown, - indent_guides=self.indent_guides, - folding=self.code_folding_enabled, - remove_trailing_spaces=self.always_remove_trailing_spaces, - remove_trailing_newlines=self.remove_trailing_newlines, - add_newline=self.add_newline, - format_on_save=self.format_on_save - ) - if cloned_from is None: - editor.set_text(txt) - editor.document().setModified(False) - finfo.text_changed_at.connect( - lambda fname, position: - self.text_changed_at.emit(fname, position)) - editor.sig_cursor_position_changed.connect( - self.editor_cursor_position_changed) - editor.textChanged.connect(self.start_stop_analysis_timer) - - # Register external panels - for panel_class, args, kwargs, position in self.external_panels: - self.register_panel( - panel_class, *args, position=position, **kwargs) - - def perform_completion_request(lang, method, params): - self.sig_perform_completion_request.emit(lang, method, params) - - editor.sig_perform_completion_request.connect( - perform_completion_request) - editor.sig_start_operation_in_progress.connect(self.spinner.start) - editor.sig_stop_operation_in_progress.connect(self.spinner.stop) - editor.modificationChanged.connect( - lambda state: self.modification_changed( - state, editor_id=id(editor))) - editor.focus_in.connect(self.focus_changed) - editor.zoom_in.connect(lambda: self.zoom_in.emit()) - editor.zoom_out.connect(lambda: self.zoom_out.emit()) - editor.zoom_reset.connect(lambda: self.zoom_reset.emit()) - editor.sig_eol_chars_changed.connect( - lambda eol_chars: self.refresh_eol_chars(eol_chars)) - editor.sig_next_cursor.connect(self.sig_next_cursor) - editor.sig_prev_cursor.connect(self.sig_prev_cursor) - - self.find_widget.set_editor(editor) - - self.refresh_file_dependent_actions.emit() - self.modification_changed(index=self.data.index(finfo)) - - # To update the outline explorer. - editor.oe_proxy = OutlineExplorerProxyEditor(editor, editor.filename) - if self.outlineexplorer is not None: - self.outlineexplorer.register_editor(editor.oe_proxy) - - # Needs to reset the highlighting on startup in case the PygmentsSH - # is in use - editor.run_pygments_highlighter() - options = { - 'language': editor.language, - 'filename': editor.filename, - 'codeeditor': editor - } - self.sig_open_file.emit(options) - if self.get_stack_index() == 0: - self.current_changed(0) - - return finfo - - def editor_cursor_position_changed(self, line, index): - """Cursor position of one of the editor in the stack has changed""" - self.sig_editor_cursor_position_changed.emit(line, index) - - @Slot(str, str, bool) - def send_to_help(self, name, signature, force=False): - """qstr1: obj_text, qstr2: argpspec, qstr3: note, qstr4: doc_text""" - if not force and not self.help_enabled: - return - - editor = self.get_current_editor() - language = editor.language.lower() - signature = to_text_string(signature) - signature = unicodedata.normalize("NFKD", signature) - parts = signature.split('\n\n') - definition = parts[0] - documentation = '\n\n'.join(parts[1:]) - args = '' - - if '(' in definition and language == 'python': - args = definition[definition.find('('):] - else: - documentation = signature - - doc = { - 'obj_text': '', - 'name': name, - 'argspec': args, - 'note': '', - 'docstring': documentation, - 'force_refresh': force, - 'path': editor.filename - } - self.sig_help_requested.emit(doc) - - def new(self, filename, encoding, text, default_content=False, - empty=False): - """ - Create new filename with *encoding* and *text* - """ - finfo = self.create_new_editor(filename, encoding, text, - set_current=False, new=True) - finfo.editor.set_cursor_position('eof') - if not empty: - finfo.editor.insert_text(os.linesep) - if default_content: - finfo.default = True - finfo.editor.document().setModified(False) - return finfo - - def load(self, filename, set_current=True, add_where='end', - processevents=True): - """ - Load filename, create an editor instance and return it - - This also sets the hash of the loaded file in the autosave component. - - *Warning* This is loading file, creating editor but not executing - the source code analysis -- the analysis must be done by the editor - plugin (in case multiple editorstack instances are handled) - """ - filename = osp.abspath(to_text_string(filename)) - if processevents: - self.starting_long_process.emit(_("Loading %s...") % filename) - text, enc = encoding.read(filename) - self.autosave.file_hashes[filename] = hash(text) - finfo = self.create_new_editor(filename, enc, text, set_current, - add_where=add_where) - index = self.data.index(finfo) - if processevents: - self.ending_long_process.emit("") - if self.isVisible() and self.checkeolchars_enabled \ - and sourcecode.has_mixed_eol_chars(text): - name = osp.basename(filename) - self.msgbox = QMessageBox( - QMessageBox.Warning, - self.title, - _("%s contains mixed end-of-line " - "characters.
Spyder will fix this " - "automatically.") % name, - QMessageBox.Ok, - self) - self.msgbox.exec_() - self.set_os_eol_chars(index) - self.is_analysis_done = False - self.analyze_script(index) - finfo.editor.set_sync_symbols_and_folding_timeout() - return finfo - - def set_os_eol_chars(self, index=None, osname=None): - """ - Sets the EOL character(s) based on the operating system. - - If `osname` is None, then the default line endings for the current - operating system will be used. - - `osname` can be one of: 'posix', 'nt', 'mac'. - """ - if osname is None: - if os.name == 'nt': - osname = 'nt' - elif sys.platform == 'darwin': - osname = 'mac' - else: - osname = 'posix' - - if index is None: - index = self.get_stack_index() - - finfo = self.data[index] - eol_chars = sourcecode.get_eol_chars_from_os_name(osname) - logger.debug(f"Set OS eol chars {eol_chars} for file {finfo.filename}") - finfo.editor.set_eol_chars(eol_chars=eol_chars) - finfo.editor.document().setModified(True) - - def remove_trailing_spaces(self, index=None): - """Remove trailing spaces""" - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Remove trailing spaces for file {finfo.filename}") - finfo.editor.trim_trailing_spaces() - - def trim_trailing_newlines(self, index=None): - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Trim trailing new lines for file {finfo.filename}") - finfo.editor.trim_trailing_newlines() - - def add_newline_to_file(self, index=None): - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Add new line to file {finfo.filename}") - finfo.editor.add_newline_to_file() - - def fix_indentation(self, index=None): - """Replace tab characters by spaces""" - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Fix indentation for file {finfo.filename}") - finfo.editor.fix_indentation() - - def format_document_or_selection(self, index=None): - if index is None: - index = self.get_stack_index() - finfo = self.data[index] - logger.debug(f"Run formatting in file {finfo.filename}") - finfo.editor.format_document_or_range() - - # ------ Run - def _run_lines_cursor(self, direction): - """ Select and run all lines from cursor in given direction""" - editor = self.get_current_editor() - - # Move cursor to start of line then move to beginning or end of - # document with KeepAnchor - cursor = editor.textCursor() - cursor.movePosition(QTextCursor.StartOfLine) - - if direction == 'up': - cursor.movePosition(QTextCursor.Start, QTextCursor.KeepAnchor) - elif direction == 'down': - cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - - code_text = editor.get_selection_as_executable_code(cursor) - if code_text: - self.exec_in_extconsole.emit(code_text.rstrip(), - self.focus_to_editor) - - def run_to_line(self): - """ - Run all lines from the beginning up to, but not including, current - line. - """ - self._run_lines_cursor(direction='up') - - def run_from_line(self): - """ - Run all lines from and including the current line to the end of - the document. - """ - self._run_lines_cursor(direction='down') - - def run_selection(self): - """ - Run selected text or current line in console. - - If some text is selected, then execute that text in console. - - If no text is selected, then execute current line, unless current line - is empty. Then, advance cursor to next line. If cursor is on last line - and that line is not empty, then add a new blank line and move the - cursor there. If cursor is on last line and that line is empty, then do - not move cursor. - """ - text = self.get_current_editor().get_selection_as_executable_code() - if text: - self.exec_in_extconsole.emit(text.rstrip(), self.focus_to_editor) - return - editor = self.get_current_editor() - line = editor.get_current_line() - text = line.lstrip() - if text: - self.exec_in_extconsole.emit(text, self.focus_to_editor) - if editor.is_cursor_on_last_line() and text: - editor.append(editor.get_line_separator()) - editor.move_cursor_to_next('line', 'down') - - def run_cell(self, debug=False): - """Run current cell.""" - text, block = self.get_current_editor().get_cell_as_executable_code() - finfo = self.get_current_finfo() - editor = self.get_current_editor() - name = cell_name(block) - filename = finfo.filename - - self._run_cell_text(text, editor, (filename, name), debug) - - def debug_cell(self): - """Debug current cell.""" - self.run_cell(debug=True) - - def run_cell_and_advance(self): - """Run current cell and advance to the next one""" - self.run_cell() - self.advance_cell() - - def advance_cell(self, reverse=False): - """Advance to the next cell. - - reverse = True --> go to previous cell. - """ - if not reverse: - move_func = self.get_current_editor().go_to_next_cell - else: - move_func = self.get_current_editor().go_to_previous_cell - - move_func() - - def re_run_last_cell(self): - """Run the previous cell again.""" - if self.last_cell_call is None: - return - filename, cell_name = self.last_cell_call - index = self.has_filename(filename) - if index is None: - return - editor = self.data[index].editor - - try: - text = editor.get_cell_code(cell_name) - except RuntimeError: - return - - self._run_cell_text(text, editor, (filename, cell_name)) - - def _run_cell_text(self, text, editor, cell_id, debug=False): - """Run cell code in the console. - - Cell code is run in the console by copying it to the console if - `self.run_cell_copy` is ``True`` otherwise by using the `run_cell` - function. - - Parameters - ---------- - text : str - The code in the cell as a string. - line : int - The starting line number of the cell in the file. - """ - (filename, cell_name) = cell_id - if editor.is_python_or_ipython(): - args = (text, cell_name, filename, self.run_cell_copy, - self.focus_to_editor) - if debug: - self.debug_cell_in_ipyclient.emit(*args) - else: - self.run_cell_in_ipyclient.emit(*args) - - # ------ Drag and drop - def dragEnterEvent(self, event): - """ - Reimplemented Qt method. - - Inform Qt about the types of data that the widget accepts. - """ - logger.debug("dragEnterEvent was received") - source = event.mimeData() - # The second check is necessary on Windows, where source.hasUrls() - # can return True but source.urls() is [] - # The third check is needed since a file could be dropped from - # compressed files. In Windows mimedata2url(source) returns None - # Fixes spyder-ide/spyder#5218. - has_urls = source.hasUrls() - has_text = source.hasText() - urls = source.urls() - all_urls = mimedata2url(source) - logger.debug("Drag event source has_urls: {}".format(has_urls)) - logger.debug("Drag event source urls: {}".format(urls)) - logger.debug("Drag event source all_urls: {}".format(all_urls)) - logger.debug("Drag event source has_text: {}".format(has_text)) - if has_urls and urls and all_urls: - text = [encoding.is_text_file(url) for url in all_urls] - logger.debug("Accept proposed action?: {}".format(any(text))) - if any(text): - event.acceptProposedAction() - else: - event.ignore() - elif source.hasText(): - event.acceptProposedAction() - elif os.name == 'nt': - # This covers cases like dragging from compressed files, - # which can be opened by the Editor if they are plain - # text, but doesn't come with url info. - # Fixes spyder-ide/spyder#2032. - logger.debug("Accept proposed action on Windows") - event.acceptProposedAction() - else: - logger.debug("Ignore drag event") - event.ignore() - - def dropEvent(self, event): - """ - Reimplement Qt method. - - Unpack dropped data and handle it. - """ - logger.debug("dropEvent was received") - source = event.mimeData() - # The second check is necessary when mimedata2url(source) - # returns None. - # Fixes spyder-ide/spyder#7742. - if source.hasUrls() and mimedata2url(source): - files = mimedata2url(source) - files = [f for f in files if encoding.is_text_file(f)] - files = set(files or []) - for fname in files: - self.plugin_load.emit(fname) - elif source.hasText(): - editor = self.get_current_editor() - if editor is not None: - editor.insert_text(source.text()) - else: - event.ignore() - event.acceptProposedAction() - - def register_panel(self, panel_class, *args, - position=Panel.Position.LEFT, **kwargs): - """Register a panel in all codeeditors.""" - if (panel_class, args, kwargs, position) not in self.external_panels: - self.external_panels.append((panel_class, args, kwargs, position)) - for finfo in self.data: - cur_panel = finfo.editor.panels.register( - panel_class(*args, **kwargs), position=position) - if not cur_panel.isVisible(): - cur_panel.setVisible(True) - - -class EditorSplitter(QSplitter): - """QSplitter for editor windows.""" - - def __init__(self, parent, plugin, menu_actions, first=False, - register_editorstack_cb=None, unregister_editorstack_cb=None): - """Create a splitter for dividing an editor window into panels. - - Adds a new EditorStack instance to this splitter. If it's not - the first splitter, clones the current EditorStack from the plugin. - - Args: - parent: Parent widget. - plugin: Plugin this widget belongs to. - menu_actions: QActions to include from the parent. - first: Boolean if this is the first splitter in the editor. - register_editorstack_cb: Callback to register the EditorStack. - Defaults to plugin.register_editorstack() to - register the EditorStack with the Editor plugin. - unregister_editorstack_cb: Callback to unregister the EditorStack. - Defaults to plugin.unregister_editorstack() to - unregister the EditorStack with the Editor plugin. - """ - - QSplitter.__init__(self, parent) - self.setAttribute(Qt.WA_DeleteOnClose) - self.setChildrenCollapsible(False) - - self.toolbar_list = None - self.menu_list = None - - self.plugin = plugin - - if register_editorstack_cb is None: - register_editorstack_cb = self.plugin.register_editorstack - self.register_editorstack_cb = register_editorstack_cb - if unregister_editorstack_cb is None: - unregister_editorstack_cb = self.plugin.unregister_editorstack - self.unregister_editorstack_cb = unregister_editorstack_cb - - self.menu_actions = menu_actions - self.editorstack = EditorStack(self, menu_actions) - self.register_editorstack_cb(self.editorstack) - if not first: - self.plugin.clone_editorstack(editorstack=self.editorstack) - self.editorstack.destroyed.connect(lambda: self.editorstack_closed()) - self.editorstack.sig_split_vertically.connect( - lambda: self.split(orientation=Qt.Vertical)) - self.editorstack.sig_split_horizontally.connect( - lambda: self.split(orientation=Qt.Horizontal)) - self.addWidget(self.editorstack) - - if not running_under_pytest(): - self.editorstack.set_color_scheme(plugin.get_color_scheme()) - - self.setStyleSheet(self._stylesheet) - - def closeEvent(self, event): - """Override QWidget closeEvent(). - - This event handler is called with the given event when Qt - receives a window close request from a top-level widget. - """ - QSplitter.closeEvent(self, event) - - def __give_focus_to_remaining_editor(self): - focus_widget = self.plugin.get_focus_widget() - if focus_widget is not None: - focus_widget.setFocus() - - def editorstack_closed(self): - try: - logger.debug("method 'editorstack_closed':") - logger.debug(" self : %r" % self) - self.unregister_editorstack_cb(self.editorstack) - self.editorstack = None - close_splitter = self.count() == 1 - if close_splitter: - # editorstack just closed was the last widget in this QSplitter - self.close() - return - self.__give_focus_to_remaining_editor() - except (RuntimeError, AttributeError): - # editorsplitter has been destroyed (happens when closing a - # EditorMainWindow instance) - return - - def editorsplitter_closed(self): - logger.debug("method 'editorsplitter_closed':") - logger.debug(" self : %r" % self) - try: - close_splitter = self.count() == 1 and self.editorstack is None - except RuntimeError: - # editorsplitter has been destroyed (happens when closing a - # EditorMainWindow instance) - return - if close_splitter: - # editorsplitter just closed was the last widget in this QSplitter - self.close() - return - elif self.count() == 2 and self.editorstack: - # back to the initial state: a single editorstack instance, - # as a single widget in this QSplitter: orientation may be changed - self.editorstack.reset_orientation() - self.__give_focus_to_remaining_editor() - - def split(self, orientation=Qt.Vertical): - """Create and attach a new EditorSplitter to the current EditorSplitter. - - The new EditorSplitter widget will contain an EditorStack that - is a clone of the current EditorStack. - - A single EditorSplitter instance can be split multiple times, but the - orientation will be the same for all the direct splits. If one of - the child splits is split, then that split can have a different - orientation. - """ - self.setOrientation(orientation) - self.editorstack.set_orientation(orientation) - editorsplitter = EditorSplitter(self.parent(), self.plugin, - self.menu_actions, - register_editorstack_cb=self.register_editorstack_cb, - unregister_editorstack_cb=self.unregister_editorstack_cb) - self.addWidget(editorsplitter) - editorsplitter.destroyed.connect(self.editorsplitter_closed) - current_editor = editorsplitter.editorstack.get_current_editor() - if current_editor is not None: - current_editor.setFocus() - - def iter_editorstacks(self): - """Return the editor stacks for this splitter and every first child. - - Note: If a splitter contains more than one splitter as a direct - child, only the first child's editor stack is included. - - Returns: - List of tuples containing (EditorStack instance, orientation). - """ - editorstacks = [(self.widget(0), self.orientation())] - if self.count() > 1: - editorsplitter = self.widget(1) - editorstacks += editorsplitter.iter_editorstacks() - return editorstacks - - def get_layout_settings(self): - """Return the layout state for this splitter and its children. - - Record the current state, including file names and current line - numbers, of the splitter panels. - - Returns: - A dictionary containing keys {hexstate, sizes, splitsettings}. - hexstate: String of saveState() for self. - sizes: List for size() for self. - splitsettings: List of tuples of the form - (orientation, cfname, clines) for each EditorSplitter - and its EditorStack. - orientation: orientation() for the editor - splitter (which may be a child of self). - cfname: EditorStack current file name. - clines: Current line number for each file in the - EditorStack. - """ - splitsettings = [] - for editorstack, orientation in self.iter_editorstacks(): - clines = [] - cfname = '' - # XXX - this overrides value from the loop to always be False? - orientation = False - if hasattr(editorstack, 'data'): - clines = [finfo.editor.get_cursor_line_number() - for finfo in editorstack.data] - cfname = editorstack.get_current_filename() - splitsettings.append((orientation == Qt.Vertical, cfname, clines)) - return dict(hexstate=qbytearray_to_str(self.saveState()), - sizes=self.sizes(), splitsettings=splitsettings) - - def set_layout_settings(self, settings, dont_goto=None): - """Restore layout state for the splitter panels. - - Apply the settings to restore a saved layout within the editor. If - the splitsettings key doesn't exist, then return without restoring - any settings. - - The current EditorSplitter (self) calls split() for each element - in split_settings, thus recreating the splitter panels from the saved - state. split() also clones the editorstack, which is then - iterated over to restore the saved line numbers on each file. - - The size and positioning of each splitter panel is restored from - hexstate. - - Args: - settings: A dictionary with keys {hexstate, sizes, orientation} - that define the layout for the EditorSplitter panels. - dont_goto: Defaults to None, which positions the cursor to the - end of the editor. If there's a value, positions the - cursor on the saved line number for each editor. - """ - splitsettings = settings.get('splitsettings') - if splitsettings is None: - return - splitter = self - editor = None - for i, (is_vertical, cfname, clines) in enumerate(splitsettings): - if i > 0: - splitter.split(Qt.Vertical if is_vertical else Qt.Horizontal) - splitter = splitter.widget(1) - editorstack = splitter.widget(0) - for j, finfo in enumerate(editorstack.data): - editor = finfo.editor - # TODO: go_to_line is not working properly (the line it jumps - # to is not the corresponding to that file). This will be fixed - # in a future PR (which will fix spyder-ide/spyder#3857). - if dont_goto is not None: - # Skip go to line for first file because is already there. - pass - else: - try: - editor.go_to_line(clines[j]) - except IndexError: - pass - hexstate = settings.get('hexstate') - if hexstate is not None: - self.restoreState( QByteArray().fromHex( - str(hexstate).encode('utf-8')) ) - sizes = settings.get('sizes') - if sizes is not None: - self.setSizes(sizes) - if editor is not None: - editor.clearFocus() - editor.setFocus() - - @property - def _stylesheet(self): - css = qstylizer.style.StyleSheet() - css.QSplitter.setValues( - background=QStylePalette.COLOR_BACKGROUND_1 - ) - return css.toString() - - -class EditorWidget(QSplitter): - CONF_SECTION = 'editor' - - def __init__(self, parent, plugin, menu_actions): - QSplitter.__init__(self, parent) - self.setAttribute(Qt.WA_DeleteOnClose) - - statusbar = parent.statusBar() # Create a status bar - self.vcs_status = VCSStatus(self) - self.cursorpos_status = CursorPositionStatus(self) - self.encoding_status = EncodingStatus(self) - self.eol_status = EOLStatus(self) - self.readwrite_status = ReadWriteStatus(self) - - statusbar.insertPermanentWidget(0, self.readwrite_status) - statusbar.insertPermanentWidget(0, self.eol_status) - statusbar.insertPermanentWidget(0, self.encoding_status) - statusbar.insertPermanentWidget(0, self.cursorpos_status) - statusbar.insertPermanentWidget(0, self.vcs_status) - - self.editorstacks = [] - - self.plugin = plugin - - self.find_widget = FindReplace(self, enable_replace=True) - self.plugin.register_widget_shortcuts(self.find_widget) - self.find_widget.hide() - - # TODO: Check this initialization once the editor is migrated to the - # new API - self.outlineexplorer = OutlineExplorerWidget( - 'outline_explorer', - plugin, - self, - context=f'editor_window_{str(id(self))}' - ) - self.outlineexplorer.edit_goto.connect( - lambda filenames, goto, word: - plugin.load(filenames=filenames, goto=goto, word=word, - editorwindow=self.parent())) - - editor_widgets = QWidget(self) - editor_layout = QVBoxLayout() - editor_layout.setContentsMargins(0, 0, 0, 0) - editor_widgets.setLayout(editor_layout) - editorsplitter = EditorSplitter(self, plugin, menu_actions, - register_editorstack_cb=self.register_editorstack, - unregister_editorstack_cb=self.unregister_editorstack) - self.editorsplitter = editorsplitter - editor_layout.addWidget(editorsplitter) - editor_layout.addWidget(self.find_widget) - - splitter = QSplitter(self) - splitter.setContentsMargins(0, 0, 0, 0) - splitter.addWidget(editor_widgets) - splitter.addWidget(self.outlineexplorer) - splitter.setStretchFactor(0, 5) - splitter.setStretchFactor(1, 1) - - def register_editorstack(self, editorstack): - self.editorstacks.append(editorstack) - logger.debug("EditorWidget.register_editorstack: %r" % editorstack) - self.__print_editorstacks() - self.plugin.last_focused_editorstack[self.parent()] = editorstack - editorstack.set_closable(len(self.editorstacks) > 1) - editorstack.set_outlineexplorer(self.outlineexplorer) - editorstack.set_find_widget(self.find_widget) - editorstack.reset_statusbar.connect(self.readwrite_status.hide) - editorstack.reset_statusbar.connect(self.encoding_status.hide) - editorstack.reset_statusbar.connect(self.cursorpos_status.hide) - editorstack.readonly_changed.connect( - self.readwrite_status.update_readonly) - editorstack.encoding_changed.connect( - self.encoding_status.update_encoding) - editorstack.sig_editor_cursor_position_changed.connect( - self.cursorpos_status.update_cursor_position) - editorstack.sig_refresh_eol_chars.connect(self.eol_status.update_eol) - self.plugin.register_editorstack(editorstack) - - def __print_editorstacks(self): - logger.debug("%d editorstack(s) in editorwidget:" % - len(self.editorstacks)) - for edst in self.editorstacks: - logger.debug(" %r" % edst) - - def unregister_editorstack(self, editorstack): - logger.debug("EditorWidget.unregister_editorstack: %r" % editorstack) - self.plugin.unregister_editorstack(editorstack) - self.editorstacks.pop(self.editorstacks.index(editorstack)) - self.__print_editorstacks() - - -class EditorMainWindow(QMainWindow): - def __init__( - self, plugin, menu_actions, toolbar_list, menu_list, parent=None): - # Parent needs to be `None` if the the created widget is meant to be - # independent. See spyder-ide/spyder#17803 - QMainWindow.__init__(self, parent) - self.setAttribute(Qt.WA_DeleteOnClose) - - self.plugin = plugin - self.window_size = None - - self.editorwidget = EditorWidget(self, plugin, menu_actions) - self.setCentralWidget(self.editorwidget) - - # Setting interface theme - self.setStyleSheet(str(APP_STYLESHEET)) - - # Give focus to current editor to update/show all status bar widgets - editorstack = self.editorwidget.editorsplitter.editorstack - editor = editorstack.get_current_editor() - if editor is not None: - editor.setFocus() - - self.setWindowTitle("Spyder - %s" % plugin.windowTitle()) - self.setWindowIcon(plugin.windowIcon()) - - if toolbar_list: - self.toolbars = [] - for title, object_name, actions in toolbar_list: - toolbar = self.addToolBar(title) - toolbar.setObjectName(object_name) - toolbar.setStyleSheet(str(APP_TOOLBAR_STYLESHEET)) - toolbar.setMovable(False) - add_actions(toolbar, actions) - self.toolbars.append(toolbar) - if menu_list: - quit_action = create_action(self, _("Close window"), - icon=ima.icon("close_pane"), - tip=_("Close this window"), - triggered=self.close) - self.menus = [] - for index, (title, actions) in enumerate(menu_list): - menu = self.menuBar().addMenu(title) - if index == 0: - # File menu - add_actions(menu, actions+[None, quit_action]) - else: - add_actions(menu, actions) - self.menus.append(menu) - - def get_toolbars(self): - """Get the toolbars.""" - return self.toolbars - - def add_toolbars_to_menu(self, menu_title, actions): - """Add toolbars to a menu.""" - # Six is the position of the view menu in menus list - # that you can find in plugins/editor.py setup_other_windows. - view_menu = self.menus[6] - view_menu.setObjectName('checkbox-padding') - if actions == self.toolbars and view_menu: - toolbars = [] - for toolbar in self.toolbars: - action = toolbar.toggleViewAction() - toolbars.append(action) - add_actions(view_menu, toolbars) - - def load_toolbars(self): - """Loads the last visible toolbars from the .ini file.""" - toolbars_names = CONF.get('main', 'last_visible_toolbars', default=[]) - if toolbars_names: - dic = {} - for toolbar in self.toolbars: - dic[toolbar.objectName()] = toolbar - toolbar.toggleViewAction().setChecked(False) - toolbar.setVisible(False) - for name in toolbars_names: - if name in dic: - dic[name].toggleViewAction().setChecked(True) - dic[name].setVisible(True) - - def resizeEvent(self, event): - """Reimplement Qt method""" - if not self.isMaximized() and not self.isFullScreen(): - self.window_size = self.size() - QMainWindow.resizeEvent(self, event) - - def closeEvent(self, event): - """Reimplement Qt method""" - if self.plugin._undocked_window is not None: - self.plugin.dockwidget.setWidget(self.plugin) - self.plugin.dockwidget.setVisible(True) - self.plugin.switch_to_plugin() - QMainWindow.closeEvent(self, event) - if self.plugin._undocked_window is not None: - self.plugin._undocked_window = None - - def get_layout_settings(self): - """Return layout state""" - splitsettings = self.editorwidget.editorsplitter.get_layout_settings() - return dict(size=(self.window_size.width(), self.window_size.height()), - pos=(self.pos().x(), self.pos().y()), - is_maximized=self.isMaximized(), - is_fullscreen=self.isFullScreen(), - hexstate=qbytearray_to_str(self.saveState()), - splitsettings=splitsettings) - - def set_layout_settings(self, settings): - """Restore layout state""" - size = settings.get('size') - if size is not None: - self.resize( QSize(*size) ) - self.window_size = self.size() - pos = settings.get('pos') - if pos is not None: - self.move( QPoint(*pos) ) - hexstate = settings.get('hexstate') - if hexstate is not None: - self.restoreState( QByteArray().fromHex( - str(hexstate).encode('utf-8')) ) - if settings.get('is_maximized'): - self.setWindowState(Qt.WindowMaximized) - if settings.get('is_fullscreen'): - self.setWindowState(Qt.WindowFullScreen) - splitsettings = settings.get('splitsettings') - if splitsettings is not None: - self.editorwidget.editorsplitter.set_layout_settings(splitsettings) - - -class EditorPluginExample(QSplitter): - def __init__(self): - QSplitter.__init__(self) - - self._dock_action = None - self._undock_action = None - self._close_plugin_action = None - self._undocked_window = None - self._lock_unlock_action = None - menu_actions = [] - - self.editorstacks = [] - self.editorwindows = [] - - self.last_focused_editorstack = {} # fake - - self.find_widget = FindReplace(self, enable_replace=True) - self.outlineexplorer = OutlineExplorerWidget(None, self, self) - self.outlineexplorer.edit_goto.connect(self.go_to_file) - self.editor_splitter = EditorSplitter(self, self, menu_actions, - first=True) - - editor_widgets = QWidget(self) - editor_layout = QVBoxLayout() - editor_layout.setContentsMargins(0, 0, 0, 0) - editor_widgets.setLayout(editor_layout) - editor_layout.addWidget(self.editor_splitter) - editor_layout.addWidget(self.find_widget) - - self.setContentsMargins(0, 0, 0, 0) - self.addWidget(editor_widgets) - self.addWidget(self.outlineexplorer) - - self.setStretchFactor(0, 5) - self.setStretchFactor(1, 1) - - self.menu_actions = menu_actions - self.toolbar_list = None - self.menu_list = None - self.setup_window([], []) - - def go_to_file(self, fname, lineno, text='', start_column=None): - editorstack = self.editorstacks[0] - editorstack.set_current_filename(to_text_string(fname)) - editor = editorstack.get_current_editor() - editor.go_to_line(lineno, word=text, start_column=start_column) - - def closeEvent(self, event): - for win in self.editorwindows[:]: - win.close() - logger.debug("%d: %r" % (len(self.editorwindows), self.editorwindows)) - logger.debug("%d: %r" % (len(self.editorstacks), self.editorstacks)) - event.accept() - - def load(self, fname): - QApplication.processEvents() - editorstack = self.editorstacks[0] - editorstack.load(fname) - editorstack.analyze_script() - - def register_editorstack(self, editorstack): - logger.debug("FakePlugin.register_editorstack: %r" % editorstack) - self.editorstacks.append(editorstack) - if self.isAncestorOf(editorstack): - # editorstack is a child of the Editor plugin - editorstack.set_closable(len(self.editorstacks) > 1) - editorstack.set_outlineexplorer(self.outlineexplorer) - editorstack.set_find_widget(self.find_widget) - oe_btn = create_toolbutton(self) - editorstack.add_corner_widgets_to_tabbar([5, oe_btn]) - - action = QAction(self) - editorstack.set_io_actions(action, action, action, action) - font = QFont("Courier New") - font.setPointSize(10) - editorstack.set_default_font(font, color_scheme='Spyder') - - editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) - editorstack.file_saved.connect(self.file_saved_in_editorstack) - editorstack.file_renamed_in_data.connect( - self.file_renamed_in_data_in_editorstack) - editorstack.plugin_load.connect(self.load) - - def unregister_editorstack(self, editorstack): - logger.debug("FakePlugin.unregister_editorstack: %r" % editorstack) - self.editorstacks.pop(self.editorstacks.index(editorstack)) - - def clone_editorstack(self, editorstack): - editorstack.clone_from(self.editorstacks[0]) - - def setup_window(self, toolbar_list, menu_list): - self.toolbar_list = toolbar_list - self.menu_list = menu_list - - def create_new_window(self): - window = EditorMainWindow(self, self.menu_actions, - self.toolbar_list, self.menu_list, - show_fullpath=False, show_all_files=False, - group_cells=True, show_comments=True, - sort_files_alphabetically=False) - window.resize(self.size()) - window.show() - self.register_editorwindow(window) - window.destroyed.connect(lambda: self.unregister_editorwindow(window)) - - def register_editorwindow(self, window): - logger.debug("register_editorwindowQObject*: %r" % window) - self.editorwindows.append(window) - - def unregister_editorwindow(self, window): - logger.debug("unregister_editorwindow: %r" % window) - self.editorwindows.pop(self.editorwindows.index(window)) - - def get_focus_widget(self): - pass - - @Slot(str, str) - def close_file_in_all_editorstacks(self, editorstack_id_str, filename): - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.blockSignals(True) - index = editorstack.get_index_from_filename(filename) - editorstack.close_file(index, force=True) - editorstack.blockSignals(False) - - # This method is never called in this plugin example. It's here only - # to show how to use the file_saved signal (see above). - @Slot(str, str, str) - def file_saved_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was saved in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.file_saved_in_other_editorstack(original_filename, - filename) - - # This method is never called in this plugin example. It's here only - # to show how to use the file_saved signal (see above). - @Slot(str, str, str) - def file_renamed_in_data_in_editorstack(self, editorstack_id_str, - original_filename, filename): - """A file was renamed in data in editorstack, this notifies others""" - for editorstack in self.editorstacks: - if str(id(editorstack)) != editorstack_id_str: - editorstack.rename_in_data(original_filename, filename) - - def register_widget_shortcuts(self, widget): - """Fake!""" - pass - - def get_color_scheme(self): - pass - - -def test(): - from spyder.utils.qthelpers import qapplication - from spyder.config.base import get_module_path - - spyder_dir = get_module_path('spyder') - app = qapplication(test_time=8) - - test = EditorPluginExample() - test.resize(900, 700) - test.show() - - import time - t0 = time.time() - test.load(osp.join(spyder_dir, "widgets", "collectionseditor.py")) - test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", - "editor.py")) - test.load(osp.join(spyder_dir, "plugins", "explorer", "widgets", - 'explorer.py')) - test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", - "codeeditor.py")) - print("Elapsed time: %.3f s" % (time.time()-t0)) # spyder: test-skip - - sys.exit(app.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Editor Widget""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import logging +import os +import os.path as osp +import sys +import unicodedata + +# Third party imports +import qstylizer.style +from qtpy.compat import getsavefilename +from qtpy.QtCore import (QByteArray, QFileInfo, QPoint, QSize, Qt, QTimer, + Signal, Slot) +from qtpy.QtGui import QFont, QTextCursor +from qtpy.QtWidgets import (QAction, QApplication, QFileDialog, QHBoxLayout, + QLabel, QMainWindow, QMessageBox, QMenu, + QSplitter, QVBoxLayout, QWidget, QListWidget, + QListWidgetItem, QSizePolicy, QToolBar) + +# Local imports +from spyder.api.panel import Panel +from spyder.config.base import _, running_under_pytest +from spyder.config.manager import CONF +from spyder.config.utils import (get_edit_filetypes, get_edit_filters, + get_filter, is_kde_desktop, is_anaconda) +from spyder.plugins.editor.utils.autosave import AutosaveForStack +from spyder.plugins.editor.utils.editor import get_file_language +from spyder.plugins.editor.utils.switcher import EditorSwitcherManager +from spyder.plugins.editor.widgets import codeeditor +from spyder.plugins.editor.widgets.editorstack_helpers import ( + ThreadManager, FileInfo, StackHistory) +from spyder.plugins.editor.widgets.status import (CursorPositionStatus, + EncodingStatus, EOLStatus, + ReadWriteStatus, VCSStatus) +from spyder.plugins.explorer.widgets.explorer import ( + show_in_external_file_explorer) +from spyder.plugins.explorer.widgets.utils import fixpath +from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget +from spyder.plugins.outlineexplorer.editor import OutlineExplorerProxyEditor +from spyder.plugins.outlineexplorer.api import cell_name +from spyder.py3compat import qbytearray_to_str, to_text_string +from spyder.utils import encoding, sourcecode, syntaxhighlighters +from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import (add_actions, create_action, + create_toolbutton, MENU_SEPARATOR, + mimedata2url, set_menu_icons, + create_waitspinner) +from spyder.utils.stylesheet import ( + APP_STYLESHEET, APP_TOOLBAR_STYLESHEET, PANES_TABBAR_STYLESHEET) +from spyder.widgets.findreplace import FindReplace +from spyder.widgets.tabs import BaseTabs + + +logger = logging.getLogger(__name__) + + +class TabSwitcherWidget(QListWidget): + """Show tabs in mru order and change between them.""" + + def __init__(self, parent, stack_history, tabs): + QListWidget.__init__(self, parent) + self.setWindowFlags(Qt.FramelessWindowHint | Qt.Dialog) + + self.editor = parent + self.stack_history = stack_history + self.tabs = tabs + + self.setSelectionMode(QListWidget.SingleSelection) + self.itemActivated.connect(self.item_selected) + + self.id_list = [] + self.load_data() + size = CONF.get('main', 'completion/size') + self.resize(*size) + self.set_dialog_position() + self.setCurrentRow(0) + + CONF.config_shortcut(lambda: self.select_row(-1), context='Editor', + name='Go to previous file', parent=self) + CONF.config_shortcut(lambda: self.select_row(1), context='Editor', + name='Go to next file', parent=self) + + def load_data(self): + """Fill ListWidget with the tabs texts. + + Add elements in inverse order of stack_history. + """ + for index in reversed(self.stack_history): + text = self.tabs.tabText(index) + text = text.replace('&', '') + item = QListWidgetItem(ima.icon('TextFileIcon'), text) + self.addItem(item) + + def item_selected(self, item=None): + """Change to the selected document and hide this widget.""" + if item is None: + item = self.currentItem() + + # stack history is in inverse order + try: + index = self.stack_history[-(self.currentRow()+1)] + except IndexError: + pass + else: + self.editor.set_stack_index(index) + self.editor.current_changed(index) + self.hide() + + def select_row(self, steps): + """Move selected row a number of steps. + + Iterates in a cyclic behaviour. + """ + row = (self.currentRow() + steps) % self.count() + self.setCurrentRow(row) + + def set_dialog_position(self): + """Positions the tab switcher in the top-center of the editor.""" + left = int(self.editor.geometry().width()/2 - self.width()/2) + top = int(self.editor.tabs.tabBar().geometry().height() + + self.editor.fname_label.geometry().height()) + + self.move(self.editor.mapToGlobal(QPoint(left, top))) + + def keyReleaseEvent(self, event): + """Reimplement Qt method. + + Handle "most recent used" tab behavior, + When ctrl is released and tab_switcher is visible, tab will be changed. + """ + if self.isVisible(): + qsc = CONF.get_shortcut(context='Editor', name='Go to next file') + + for key in qsc.split('+'): + key = key.lower() + if ((key == 'ctrl' and event.key() == Qt.Key_Control) or + (key == 'alt' and event.key() == Qt.Key_Alt)): + self.item_selected() + event.accept() + + def keyPressEvent(self, event): + """Reimplement Qt method to allow cyclic behavior.""" + if event.key() == Qt.Key_Down: + self.select_row(1) + elif event.key() == Qt.Key_Up: + self.select_row(-1) + + def focusOutEvent(self, event): + """Reimplement Qt method to close the widget when loosing focus.""" + event.ignore() + if sys.platform == "darwin": + if event.reason() != Qt.ActiveWindowFocusReason: + self.close() + else: + self.close() + + +class EditorStack(QWidget): + reset_statusbar = Signal() + readonly_changed = Signal(bool) + encoding_changed = Signal(str) + sig_editor_cursor_position_changed = Signal(int, int) + sig_refresh_eol_chars = Signal(str) + sig_refresh_formatting = Signal(bool) + starting_long_process = Signal(str) + ending_long_process = Signal(str) + redirect_stdio = Signal(bool) + exec_in_extconsole = Signal(str, bool) + run_cell_in_ipyclient = Signal(str, object, str, bool, bool) + debug_cell_in_ipyclient = Signal(str, object, str, bool, bool) + update_plugin_title = Signal() + editor_focus_changed = Signal() + zoom_in = Signal() + zoom_out = Signal() + zoom_reset = Signal() + sig_open_file = Signal(dict) + sig_close_file = Signal(str, str) + file_saved = Signal(str, str, str) + file_renamed_in_data = Signal(str, str, str) + opened_files_list_changed = Signal() + active_languages_stats = Signal(set) + todo_results_changed = Signal() + update_code_analysis_actions = Signal() + refresh_file_dependent_actions = Signal() + refresh_save_all_action = Signal() + sig_breakpoints_saved = Signal() + text_changed_at = Signal(str, int) + current_file_changed = Signal(str, int, int, int) + plugin_load = Signal((str,), ()) + edit_goto = Signal(str, int, str) + sig_split_vertically = Signal() + sig_split_horizontally = Signal() + sig_new_file = Signal((str,), ()) + sig_save_as = Signal() + sig_prev_edit_pos = Signal() + sig_prev_cursor = Signal() + sig_next_cursor = Signal() + sig_prev_warning = Signal() + sig_next_warning = Signal() + sig_go_to_definition = Signal(str, int, int) + sig_perform_completion_request = Signal(str, str, dict) + sig_option_changed = Signal(str, object) # config option needs changing + sig_save_bookmark = Signal(int) + sig_load_bookmark = Signal(int) + sig_save_bookmarks = Signal(str, str) + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Dictionary required by the Help pane to render a docstring. + + Examples + -------- + >>> help_data = { + 'obj_text': str, + 'name': str, + 'argspec': str, + 'note': str, + 'docstring': str, + 'force_refresh': bool, + 'path': str, + } + + See Also + -------- + :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help + """ + + def __init__(self, parent, actions): + QWidget.__init__(self, parent) + + self.setAttribute(Qt.WA_DeleteOnClose) + + self.threadmanager = ThreadManager(self) + self.new_window = False + self.horsplit_action = None + self.versplit_action = None + self.close_action = None + self.__get_split_actions() + + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + + self.menu = None + self.switcher_dlg = None + self.switcher_manager = None + self.tabs = None + self.tabs_switcher = None + + self.stack_history = StackHistory(self) + + # External panels + self.external_panels = [] + + self.setup_editorstack(parent, layout) + + self.find_widget = None + + self.data = [] + + switcher_action = create_action( + self, + _("File switcher..."), + icon=ima.icon('filelist'), + triggered=self.open_switcher_dlg) + symbolfinder_action = create_action( + self, + _("Find symbols in file..."), + icon=ima.icon('symbol_find'), + triggered=self.open_symbolfinder_dlg) + copy_to_cb_action = create_action(self, _("Copy path to clipboard"), + icon=ima.icon('editcopy'), + triggered=lambda: + QApplication.clipboard().setText(self.get_current_filename())) + close_right = create_action(self, _("Close all to the right"), + triggered=self.close_all_right) + close_all_but_this = create_action(self, _("Close all but this"), + triggered=self.close_all_but_this) + + sort_tabs = create_action(self, _("Sort tabs alphabetically"), + triggered=self.sort_file_tabs_alphabetically) + + if sys.platform == 'darwin': + text = _("Show in Finder") + else: + text = _("Show in external file explorer") + external_fileexp_action = create_action( + self, text, + triggered=self.show_in_external_file_explorer, + shortcut=CONF.get_shortcut(context="Editor", + name="show in external file explorer"), + context=Qt.WidgetShortcut) + + self.menu_actions = actions + [external_fileexp_action, + None, switcher_action, + symbolfinder_action, + copy_to_cb_action, None, close_right, + close_all_but_this, sort_tabs] + self.outlineexplorer = None + self.is_closable = False + self.new_action = None + self.open_action = None + self.save_action = None + self.revert_action = None + self.tempfile_path = None + self.title = _("Editor") + self.todolist_enabled = True + self.is_analysis_done = False + self.linenumbers_enabled = True + self.blanks_enabled = False + self.scrollpastend_enabled = False + self.edgeline_enabled = True + self.edgeline_columns = (79,) + self.close_parentheses_enabled = True + self.close_quotes_enabled = True + self.add_colons_enabled = True + self.auto_unindent_enabled = True + self.indent_chars = " "*4 + self.tab_stop_width_spaces = 4 + self.show_class_func_dropdown = False + self.help_enabled = False + self.default_font = None + self.wrap_enabled = False + self.tabmode_enabled = False + self.stripmode_enabled = False + self.intelligent_backspace_enabled = True + self.automatic_completions_enabled = True + self.automatic_completion_chars = 3 + self.automatic_completion_ms = 300 + self.completions_hint_enabled = True + self.completions_hint_after_ms = 500 + self.hover_hints_enabled = True + self.format_on_save = False + self.code_snippets_enabled = True + self.code_folding_enabled = True + self.underline_errors_enabled = False + self.highlight_current_line_enabled = False + self.highlight_current_cell_enabled = False + self.occurrence_highlighting_enabled = True + self.occurrence_highlighting_timeout = 1500 + self.checkeolchars_enabled = True + self.always_remove_trailing_spaces = False + self.add_newline = False + self.remove_trailing_newlines = False + self.convert_eol_on_save = False + self.convert_eol_on_save_to = 'LF' + self.focus_to_editor = True + self.run_cell_copy = False + self.create_new_file_if_empty = True + self.indent_guides = False + ccs = 'spyder/dark' + if ccs not in syntaxhighlighters.COLOR_SCHEME_NAMES: + ccs = syntaxhighlighters.COLOR_SCHEME_NAMES[0] + self.color_scheme = ccs + self.__file_status_flag = False + + # Real-time code analysis + self.analysis_timer = QTimer(self) + self.analysis_timer.setSingleShot(True) + self.analysis_timer.setInterval(1000) + self.analysis_timer.timeout.connect(self.analyze_script) + + # Update filename label + self.editor_focus_changed.connect(self.update_fname_label) + + # Accepting drops + self.setAcceptDrops(True) + + # Local shortcuts + self.shortcuts = self.create_shortcuts() + + # For opening last closed tabs + self.last_closed_files = [] + + # Reference to save msgbox and avoid memory to be freed. + self.msgbox = None + + # File types and filters used by the Save As dialog + self.edit_filetypes = None + self.edit_filters = None + + # For testing + self.save_dialog_on_tests = not running_under_pytest() + + # Autusave component + self.autosave = AutosaveForStack(self) + + self.last_cell_call = None + + @Slot() + def show_in_external_file_explorer(self, fnames=None): + """Show file in external file explorer""" + if fnames is None or isinstance(fnames, bool): + fnames = self.get_current_filename() + try: + show_in_external_file_explorer(fnames) + except FileNotFoundError as error: + file = str(error).split("'")[1] + if "xdg-open" in file: + msg_title = _("Warning") + msg = _("Spyder can't show this file in the external file " + "explorer because the xdg-utils package is " + "not available on your system.") + QMessageBox.information(self, msg_title, msg, + QMessageBox.Ok) + + def create_shortcuts(self): + """Create local shortcuts""" + # --- Configurable shortcuts + inspect = CONF.config_shortcut( + self.inspect_current_object, + context='Editor', + name='Inspect current object', + parent=self) + + set_breakpoint = CONF.config_shortcut( + self.set_or_clear_breakpoint, + context='Editor', + name='Breakpoint', + parent=self) + + set_cond_breakpoint = CONF.config_shortcut( + self.set_or_edit_conditional_breakpoint, + context='Editor', + name='Conditional breakpoint', + parent=self) + + gotoline = CONF.config_shortcut( + self.go_to_line, + context='Editor', + name='Go to line', + parent=self) + + tab = CONF.config_shortcut( + lambda: self.tab_navigation_mru(forward=False), + context='Editor', + name='Go to previous file', + parent=self) + + tabshift = CONF.config_shortcut( + self.tab_navigation_mru, + context='Editor', + name='Go to next file', + parent=self) + + prevtab = CONF.config_shortcut( + lambda: self.tabs.tab_navigate(-1), + context='Editor', + name='Cycle to previous file', + parent=self) + + nexttab = CONF.config_shortcut( + lambda: self.tabs.tab_navigate(1), + context='Editor', + name='Cycle to next file', + parent=self) + + run_selection = CONF.config_shortcut( + self.run_selection, + context='Editor', + name='Run selection', + parent=self) + + run_to_line = CONF.config_shortcut( + self.run_to_line, + context='Editor', + name='Run to line', + parent=self) + + run_from_line = CONF.config_shortcut( + self.run_from_line, + context='Editor', + name='Run from line', + parent=self) + + new_file = CONF.config_shortcut( + lambda: self.sig_new_file[()].emit(), + context='Editor', + name='New file', + parent=self) + + open_file = CONF.config_shortcut( + lambda: self.plugin_load[()].emit(), + context='Editor', + name='Open file', + parent=self) + + save_file = CONF.config_shortcut( + self.save, + context='Editor', + name='Save file', + parent=self) + + save_all = CONF.config_shortcut( + self.save_all, + context='Editor', + name='Save all', + parent=self) + + save_as = CONF.config_shortcut( + lambda: self.sig_save_as.emit(), + context='Editor', + name='Save As', + parent=self) + + close_all = CONF.config_shortcut( + self.close_all_files, + context='Editor', + name='Close all', + parent=self) + + prev_edit_pos = CONF.config_shortcut( + lambda: self.sig_prev_edit_pos.emit(), + context="Editor", + name="Last edit location", + parent=self) + + prev_cursor = CONF.config_shortcut( + lambda: self.sig_prev_cursor.emit(), + context="Editor", + name="Previous cursor position", + parent=self) + + next_cursor = CONF.config_shortcut( + lambda: self.sig_next_cursor.emit(), + context="Editor", + name="Next cursor position", + parent=self) + + zoom_in_1 = CONF.config_shortcut( + lambda: self.zoom_in.emit(), + context="Editor", + name="zoom in 1", + parent=self) + + zoom_in_2 = CONF.config_shortcut( + lambda: self.zoom_in.emit(), + context="Editor", + name="zoom in 2", + parent=self) + + zoom_out = CONF.config_shortcut( + lambda: self.zoom_out.emit(), + context="Editor", + name="zoom out", + parent=self) + + zoom_reset = CONF.config_shortcut( + lambda: self.zoom_reset.emit(), + context="Editor", + name="zoom reset", + parent=self) + + close_file_1 = CONF.config_shortcut( + self.close_file, + context="Editor", + name="close file 1", + parent=self) + + close_file_2 = CONF.config_shortcut( + self.close_file, + context="Editor", + name="close file 2", + parent=self) + + run_cell = CONF.config_shortcut( + self.run_cell, + context="Editor", + name="run cell", + parent=self) + + debug_cell = CONF.config_shortcut( + self.debug_cell, + context="Editor", + name="debug cell", + parent=self) + + run_cell_and_advance = CONF.config_shortcut( + self.run_cell_and_advance, + context="Editor", + name="run cell and advance", + parent=self) + + go_to_next_cell = CONF.config_shortcut( + self.advance_cell, + context="Editor", + name="go to next cell", + parent=self) + + go_to_previous_cell = CONF.config_shortcut( + lambda: self.advance_cell(reverse=True), + context="Editor", + name="go to previous cell", + parent=self) + + re_run_last_cell = CONF.config_shortcut( + self.re_run_last_cell, + context="Editor", + name="re-run last cell", + parent=self) + + prev_warning = CONF.config_shortcut( + lambda: self.sig_prev_warning.emit(), + context="Editor", + name="Previous warning", + parent=self) + + next_warning = CONF.config_shortcut( + lambda: self.sig_next_warning.emit(), + context="Editor", + name="Next warning", + parent=self) + + split_vertically = CONF.config_shortcut( + lambda: self.sig_split_vertically.emit(), + context="Editor", + name="split vertically", + parent=self) + + split_horizontally = CONF.config_shortcut( + lambda: self.sig_split_horizontally.emit(), + context="Editor", + name="split horizontally", + parent=self) + + close_split = CONF.config_shortcut( + self.close_split, + context="Editor", + name="close split panel", + parent=self) + + external_fileexp = CONF.config_shortcut( + self.show_in_external_file_explorer, + context="Editor", + name="show in external file explorer", + parent=self) + + # Return configurable ones + return [inspect, set_breakpoint, set_cond_breakpoint, gotoline, tab, + tabshift, run_selection, run_to_line, run_from_line, new_file, + open_file, save_file, save_all, save_as, close_all, + prev_edit_pos, prev_cursor, next_cursor, zoom_in_1, zoom_in_2, + zoom_out, zoom_reset, close_file_1, close_file_2, run_cell, + debug_cell, run_cell_and_advance, + go_to_next_cell, go_to_previous_cell, re_run_last_cell, + prev_warning, next_warning, split_vertically, + split_horizontally, close_split, + prevtab, nexttab, external_fileexp] + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + def setup_editorstack(self, parent, layout): + """Setup editorstack's layout""" + layout.setSpacing(0) + + # Create filename label, spinner and the toolbar that contains them + self.create_top_widgets() + + # Add top toolbar + layout.addWidget(self.top_toolbar) + + # Tabbar + menu_btn = create_toolbutton(self, icon=ima.icon('tooloptions'), + tip=_('Options')) + menu_btn.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) + self.menu = QMenu(self) + menu_btn.setMenu(self.menu) + menu_btn.setPopupMode(menu_btn.InstantPopup) + self.menu.aboutToShow.connect(self.__setup_menu) + + corner_widgets = {Qt.TopRightCorner: [menu_btn]} + self.tabs = BaseTabs(self, menu=self.menu, menu_use_tooltips=True, + corner_widgets=corner_widgets) + self.tabs.set_close_function(self.close_file) + self.tabs.tabBar().tabMoved.connect(self.move_editorstack_data) + self.tabs.setMovable(True) + + self.stack_history.refresh() + + if hasattr(self.tabs, 'setDocumentMode') \ + and not sys.platform == 'darwin': + # Don't set document mode to true on OSX because it generates + # a crash when the editor is detached from the main window + # Fixes spyder-ide/spyder#561. + self.tabs.setDocumentMode(True) + self.tabs.currentChanged.connect(self.current_changed) + + tab_container = QWidget() + tab_container.setObjectName('tab-container') + tab_layout = QHBoxLayout(tab_container) + tab_layout.setContentsMargins(0, 0, 0, 0) + tab_layout.addWidget(self.tabs) + layout.addWidget(tab_container) + + # Show/hide icons in plugin menus for Mac + if sys.platform == 'darwin': + self.menu.aboutToHide.connect( + lambda menu=self.menu: + set_menu_icons(menu, False)) + + def create_top_widgets(self): + # Filename label + self.fname_label = QLabel() + + # Spacer + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + # Spinner + self.spinner = create_waitspinner(size=16, parent=self.fname_label) + + # Add widgets to toolbar + self.top_toolbar = QToolBar(self) + self.top_toolbar.addWidget(self.fname_label) + self.top_toolbar.addWidget(spacer) + self.top_toolbar.addWidget(self.spinner) + + # Set toolbar style + css = qstylizer.style.StyleSheet() + css.QToolBar.setValues( + margin='0px', + padding='4px', + borderBottom=f'1px solid {QStylePalette.COLOR_BACKGROUND_4}' + ) + self.top_toolbar.setStyleSheet(css.toString()) + + def hide_tooltip(self): + """Hide any open tooltips.""" + for finfo in self.data: + finfo.editor.hide_tooltip() + + @Slot() + def update_fname_label(self): + """Update file name label.""" + filename = to_text_string(self.get_current_filename()) + if len(filename) > 100: + shorten_filename = u'...' + filename[-100:] + else: + shorten_filename = filename + self.fname_label.setText(shorten_filename) + + def add_corner_widgets_to_tabbar(self, widgets): + self.tabs.add_corner_widgets(widgets) + + @Slot() + def close_split(self): + """Closes the editorstack if it is not the last one opened.""" + if self.is_closable: + self.close() + + def closeEvent(self, event): + """Overrides QWidget closeEvent().""" + self.threadmanager.close_all_threads() + self.analysis_timer.timeout.disconnect(self.analyze_script) + + # Remove editor references from the outline explorer settings + if self.outlineexplorer is not None: + for finfo in self.data: + self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) + + for finfo in self.data: + if not finfo.editor.is_cloned: + finfo.editor.notify_close() + QWidget.closeEvent(self, event) + + def clone_editor_from(self, other_finfo, set_current): + fname = other_finfo.filename + enc = other_finfo.encoding + new = other_finfo.newly_created + finfo = self.create_new_editor(fname, enc, "", + set_current=set_current, new=new, + cloned_from=other_finfo.editor) + finfo.set_todo_results(other_finfo.todo_results) + return finfo.editor + + def clone_from(self, other): + """Clone EditorStack from other instance""" + for other_finfo in other.data: + self.clone_editor_from(other_finfo, set_current=True) + self.set_stack_index(other.get_stack_index()) + + @Slot() + @Slot(str) + def open_switcher_dlg(self, initial_text=''): + """Open file list management dialog box""" + if not self.tabs.count(): + return + if self.switcher_dlg is not None and self.switcher_dlg.isVisible(): + self.switcher_dlg.hide() + self.switcher_dlg.clear() + return + if self.switcher_dlg is None: + from spyder.widgets.switcher import Switcher + self.switcher_dlg = Switcher(self) + self.switcher_manager = EditorSwitcherManager( + self.get_plugin(), + self.switcher_dlg, + lambda: self.get_current_editor(), + lambda: self, + section=self.get_plugin_title()) + + if isinstance(initial_text, bool): + initial_text = '' + + self.switcher_dlg.set_search_text(initial_text) + self.switcher_dlg.setup() + self.switcher_dlg.show() + # Note: the +1 pixel on the top makes it look better + delta_top = (self.tabs.tabBar().geometry().height() + + self.fname_label.geometry().height() + 1) + self.switcher_dlg.set_position(delta_top) + + @Slot() + def open_symbolfinder_dlg(self): + self.open_switcher_dlg(initial_text='@') + + def get_plugin(self): + """Get the plugin of the parent widget.""" + # Needed for the editor stack to use its own switcher instance. + # See spyder-ide/spyder#10684. + return self.parent().plugin + + def get_plugin_title(self): + """Get the plugin title of the parent widget.""" + # Needed for the editor stack to use its own switcher instance. + # See spyder-ide/spyder#9469. + return self.get_plugin().get_plugin_title() + + def go_to_line(self, line=None): + """Go to line dialog""" + if line is not None: + # When this method is called from the flileswitcher, a line + # number is specified, so there is no need for the dialog. + self.get_current_editor().go_to_line(line) + else: + if self.data: + self.get_current_editor().exec_gotolinedialog() + + def set_or_clear_breakpoint(self): + """Set/clear breakpoint""" + if self.data: + editor = self.get_current_editor() + editor.debugger.toogle_breakpoint() + + def set_or_edit_conditional_breakpoint(self): + """Set conditional breakpoint""" + if self.data: + editor = self.get_current_editor() + editor.debugger.toogle_breakpoint(edit_condition=True) + + def set_bookmark(self, slot_num): + """Bookmark current position to given slot.""" + if self.data: + editor = self.get_current_editor() + editor.add_bookmark(slot_num) + + def inspect_current_object(self, pos=None): + """Inspect current object in the Help plugin""" + editor = self.get_current_editor() + editor.sig_display_object_info.connect(self.display_help) + cursor = None + offset = editor.get_position('cursor') + if pos: + cursor = editor.get_last_hover_cursor() + if cursor: + offset = cursor.position() + else: + return + + line, col = editor.get_cursor_line_column(cursor) + editor.request_hover(line, col, offset, + show_hint=False, clicked=bool(pos)) + + @Slot(str, bool) + def display_help(self, help_text, clicked): + editor = self.get_current_editor() + if clicked: + name = editor.get_last_hover_word() + else: + name = editor.get_current_word(help_req=True) + + try: + editor.sig_display_object_info.disconnect(self.display_help) + except TypeError: + # Needed to prevent an error after some time in idle. + # See spyder-ide/spyder#11228 + pass + + self.send_to_help(name, help_text, force=True) + + # ------ Editor Widget Settings + def set_closable(self, state): + """Parent widget must handle the closable state""" + self.is_closable = state + + def set_io_actions(self, new_action, open_action, + save_action, revert_action): + self.new_action = new_action + self.open_action = open_action + self.save_action = save_action + self.revert_action = revert_action + + def set_find_widget(self, find_widget): + self.find_widget = find_widget + + def set_outlineexplorer(self, outlineexplorer): + self.outlineexplorer = outlineexplorer + + def add_outlineexplorer_button(self, editor_plugin): + oe_btn = create_toolbutton(editor_plugin) + oe_btn.setDefaultAction(self.outlineexplorer.visibility_action) + self.add_corner_widgets_to_tabbar([5, oe_btn]) + + def set_tempfile_path(self, path): + self.tempfile_path = path + + def set_title(self, text): + self.title = text + + def set_classfunc_dropdown_visible(self, state): + self.show_class_func_dropdown = state + if self.data: + for finfo in self.data: + if finfo.editor.is_python_like(): + finfo.editor.classfuncdropdown.setVisible(state) + + def __update_editor_margins(self, editor): + editor.linenumberarea.setup_margins( + linenumbers=self.linenumbers_enabled, markers=self.has_markers()) + + def has_markers(self): + """Return True if this editorstack has a marker margin for TODOs or + code analysis""" + return self.todolist_enabled + + def set_todolist_enabled(self, state, current_finfo=None): + # CONF.get(self.CONF_SECTION, 'todo_list') + self.todolist_enabled = state + if self.data: + for finfo in self.data: + self.__update_editor_margins(finfo.editor) + finfo.cleanup_todo_results() + if state and current_finfo is not None: + if current_finfo is not finfo: + finfo.run_todo_finder() + + def set_linenumbers_enabled(self, state, current_finfo=None): + # CONF.get(self.CONF_SECTION, 'line_numbers') + self.linenumbers_enabled = state + if self.data: + for finfo in self.data: + self.__update_editor_margins(finfo.editor) + + def set_blanks_enabled(self, state): + self.blanks_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_blanks_enabled(state) + + def set_scrollpastend_enabled(self, state): + self.scrollpastend_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_scrollpastend_enabled(state) + + def set_edgeline_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'edge_line') + self.edgeline_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.edge_line.set_enabled(state) + + def set_edgeline_columns(self, columns): + # CONF.get(self.CONF_SECTION, 'edge_line_column') + self.edgeline_columns = columns + if self.data: + for finfo in self.data: + finfo.editor.edge_line.set_columns(columns) + + def set_indent_guides(self, state): + self.indent_guides = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_identation_guides(state) + + def set_close_parentheses_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'close_parentheses') + self.close_parentheses_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_close_parentheses_enabled(state) + + def set_close_quotes_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'close_quotes') + self.close_quotes_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_close_quotes_enabled(state) + + def set_add_colons_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'add_colons') + self.add_colons_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_add_colons_enabled(state) + + def set_auto_unindent_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'auto_unindent') + self.auto_unindent_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_auto_unindent_enabled(state) + + def set_indent_chars(self, indent_chars): + # CONF.get(self.CONF_SECTION, 'indent_chars') + indent_chars = indent_chars[1:-1] # removing the leading/ending '*' + self.indent_chars = indent_chars + if self.data: + for finfo in self.data: + finfo.editor.set_indent_chars(indent_chars) + + def set_tab_stop_width_spaces(self, tab_stop_width_spaces): + # CONF.get(self.CONF_SECTION, 'tab_stop_width') + self.tab_stop_width_spaces = tab_stop_width_spaces + if self.data: + for finfo in self.data: + finfo.editor.tab_stop_width_spaces = tab_stop_width_spaces + finfo.editor.update_tab_stop_width_spaces() + + def set_help_enabled(self, state): + self.help_enabled = state + + def set_default_font(self, font, color_scheme=None): + self.default_font = font + if color_scheme is not None: + self.color_scheme = color_scheme + if self.data: + for finfo in self.data: + finfo.editor.set_font(font, color_scheme) + + def set_color_scheme(self, color_scheme): + self.color_scheme = color_scheme + if self.data: + for finfo in self.data: + finfo.editor.set_color_scheme(color_scheme) + + def set_wrap_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'wrap') + self.wrap_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_wrap_mode(state) + + def set_tabmode_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'tab_always_indent') + self.tabmode_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_tab_mode(state) + + def set_stripmode_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'strip_trailing_spaces_on_modify') + self.stripmode_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_strip_mode(state) + + def set_intelligent_backspace_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'intelligent_backspace') + self.intelligent_backspace_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_intelligent_backspace(state) + + def set_code_snippets_enabled(self, state): + self.code_snippets_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_code_snippets(state) + + def set_code_folding_enabled(self, state): + self.code_folding_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_code_folding(state) + + def set_automatic_completions_enabled(self, state): + self.automatic_completions_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_automatic_completions(state) + + def set_automatic_completions_after_chars(self, chars): + self.automatic_completion_chars = chars + if self.data: + for finfo in self.data: + finfo.editor.set_automatic_completions_after_chars(chars) + + def set_automatic_completions_after_ms(self, ms): + self.automatic_completion_ms = ms + if self.data: + for finfo in self.data: + finfo.editor.set_automatic_completions_after_ms(ms) + + def set_completions_hint_enabled(self, state): + self.completions_hint_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_completions_hint(state) + + def set_completions_hint_after_ms(self, ms): + self.completions_hint_after_ms = ms + if self.data: + for finfo in self.data: + finfo.editor.set_completions_hint_after_ms(ms) + + def set_hover_hints_enabled(self, state): + self.hover_hints_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_hover_hints(state) + + def set_format_on_save(self, state): + self.format_on_save = state + if self.data: + for finfo in self.data: + finfo.editor.toggle_format_on_save(state) + + def set_occurrence_highlighting_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'occurrence_highlighting') + self.occurrence_highlighting_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_occurrence_highlighting(state) + + def set_occurrence_highlighting_timeout(self, timeout): + # CONF.get(self.CONF_SECTION, 'occurrence_highlighting/timeout') + self.occurrence_highlighting_timeout = timeout + if self.data: + for finfo in self.data: + finfo.editor.set_occurrence_timeout(timeout) + + def set_underline_errors_enabled(self, state): + self.underline_errors_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_underline_errors_enabled(state) + + def set_highlight_current_line_enabled(self, state): + self.highlight_current_line_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_highlight_current_line(state) + + def set_highlight_current_cell_enabled(self, state): + self.highlight_current_cell_enabled = state + if self.data: + for finfo in self.data: + finfo.editor.set_highlight_current_cell(state) + + def set_checkeolchars_enabled(self, state): + # CONF.get(self.CONF_SECTION, 'check_eol_chars') + self.checkeolchars_enabled = state + + def set_always_remove_trailing_spaces(self, state): + # CONF.get(self.CONF_SECTION, 'always_remove_trailing_spaces') + self.always_remove_trailing_spaces = state + if self.data: + for finfo in self.data: + finfo.editor.set_remove_trailing_spaces(state) + + def set_add_newline(self, state): + self.add_newline = state + if self.data: + for finfo in self.data: + finfo.editor.set_add_newline(state) + + def set_remove_trailing_newlines(self, state): + self.remove_trailing_newlines = state + if self.data: + for finfo in self.data: + finfo.editor.set_remove_trailing_newlines(state) + + def set_convert_eol_on_save(self, state): + """If `state` is `True`, saving files will convert line endings.""" + # CONF.get(self.CONF_SECTION, 'convert_eol_on_save') + self.convert_eol_on_save = state + + def set_convert_eol_on_save_to(self, state): + """`state` can be one of ('LF', 'CRLF', 'CR')""" + # CONF.get(self.CONF_SECTION, 'convert_eol_on_save_to') + self.convert_eol_on_save_to = state + + def set_focus_to_editor(self, state): + self.focus_to_editor = state + + def set_run_cell_copy(self, state): + """If `state` is ``True``, code cells will be copied to the console.""" + self.run_cell_copy = state + + def set_current_project_path(self, root_path=None): + """ + Set the current active project root path. + + Parameters + ---------- + root_path: str or None, optional + Path to current project root path. Default is None. + """ + for finfo in self.data: + finfo.editor.set_current_project_path(root_path) + + # ------ Stacked widget management + def get_stack_index(self): + return self.tabs.currentIndex() + + def get_current_finfo(self): + if self.data: + return self.data[self.get_stack_index()] + + def get_current_editor(self): + return self.tabs.currentWidget() + + def get_stack_count(self): + return self.tabs.count() + + def set_stack_index(self, index, instance=None): + if instance == self or instance == None: + self.tabs.setCurrentIndex(index) + + def set_tabbar_visible(self, state): + self.tabs.tabBar().setVisible(state) + + def remove_from_data(self, index): + self.tabs.blockSignals(True) + self.tabs.removeTab(index) + self.data.pop(index) + self.tabs.blockSignals(False) + self.update_actions() + + def __modified_readonly_title(self, title, is_modified, is_readonly): + if is_modified is not None and is_modified: + title += "*" + if is_readonly is not None and is_readonly: + title = "(%s)" % title + return title + + def get_tab_text(self, index, is_modified=None, is_readonly=None): + """Return tab title.""" + files_path_list = [finfo.filename for finfo in self.data] + fname = self.data[index].filename + fname = sourcecode.disambiguate_fname(files_path_list, fname) + return self.__modified_readonly_title(fname, + is_modified, is_readonly) + + def get_tab_tip(self, filename, is_modified=None, is_readonly=None): + """Return tab menu title""" + text = u"%s — %s" + text = self.__modified_readonly_title(text, + is_modified, is_readonly) + if self.tempfile_path is not None\ + and filename == encoding.to_unicode_from_fs(self.tempfile_path): + temp_file_str = to_text_string(_("Temporary file")) + return text % (temp_file_str, self.tempfile_path) + else: + return text % (osp.basename(filename), osp.dirname(filename)) + + def add_to_data(self, finfo, set_current, add_where='end'): + finfo.editor.oe_proxy = None + index = 0 if add_where == 'start' else len(self.data) + self.data.insert(index, finfo) + index = self.data.index(finfo) + editor = finfo.editor + self.tabs.insertTab(index, editor, self.get_tab_text(index)) + self.set_stack_title(index, False) + if set_current: + self.set_stack_index(index) + self.current_changed(index) + self.update_actions() + + def __repopulate_stack(self): + self.tabs.blockSignals(True) + self.tabs.clear() + for finfo in self.data: + if finfo.newly_created: + is_modified = True + else: + is_modified = None + index = self.data.index(finfo) + tab_text = self.get_tab_text(index, is_modified) + tab_tip = self.get_tab_tip(finfo.filename) + index = self.tabs.addTab(finfo.editor, tab_text) + self.tabs.setTabToolTip(index, tab_tip) + self.tabs.blockSignals(False) + + def rename_in_data(self, original_filename, new_filename): + index = self.has_filename(original_filename) + if index is None: + return + finfo = self.data[index] + + # Send close request to LSP + finfo.editor.notify_close() + + # Set new filename + finfo.filename = new_filename + finfo.editor.filename = new_filename + + # File type has changed! + original_ext = osp.splitext(original_filename)[1] + new_ext = osp.splitext(new_filename)[1] + if original_ext != new_ext: + # Set file language and re-run highlighter + txt = to_text_string(finfo.editor.get_text_with_eol()) + language = get_file_language(new_filename, txt) + finfo.editor.set_language(language, new_filename) + finfo.editor.run_pygments_highlighter() + + # If the user renamed the file to a different language, we + # need to emit sig_open_file to see if we can start a + # language server for it. + options = { + 'language': language, + 'filename': new_filename, + 'codeeditor': finfo.editor + } + self.sig_open_file.emit(options) + + # Update panels + finfo.editor.set_debug_panel( + show_debug_panel=True, language=language) + finfo.editor.cleanup_code_analysis() + finfo.editor.cleanup_folding() + else: + # If there's no language change, we simply need to request a + # document_did_open for the new file. + finfo.editor.document_did_open() + + set_new_index = index == self.get_stack_index() + current_fname = self.get_current_filename() + finfo.editor.filename = new_filename + new_index = self.data.index(finfo) + self.__repopulate_stack() + if set_new_index: + self.set_stack_index(new_index) + else: + # Fixes spyder-ide/spyder#1287. + self.set_current_filename(current_fname) + if self.outlineexplorer is not None: + self.outlineexplorer.file_renamed( + finfo.editor.oe_proxy, finfo.filename) + return new_index + + def set_stack_title(self, index, is_modified): + finfo = self.data[index] + fname = finfo.filename + is_modified = (is_modified or finfo.newly_created) and not finfo.default + is_readonly = finfo.editor.isReadOnly() + tab_text = self.get_tab_text(index, is_modified, is_readonly) + tab_tip = self.get_tab_tip(fname, is_modified, is_readonly) + + # Only update tab text if have changed, otherwise an unwanted scrolling + # will happen when changing tabs. See spyder-ide/spyder#1170. + if tab_text != self.tabs.tabText(index): + self.tabs.setTabText(index, tab_text) + self.tabs.setTabToolTip(index, tab_tip) + + # ------ Context menu + def __setup_menu(self): + """Setup tab context menu before showing it""" + self.menu.clear() + if self.data: + actions = self.menu_actions + else: + actions = (self.new_action, self.open_action) + self.setFocus() # --> Editor.__get_focus_editortabwidget + add_actions(self.menu, list(actions) + self.__get_split_actions()) + self.close_action.setEnabled(self.is_closable) + + if sys.platform == 'darwin': + set_menu_icons(self.menu, True) + + # ------ Hor/Ver splitting + def __get_split_actions(self): + if self.parent() is not None: + plugin = self.parent().plugin + else: + plugin = None + + # New window + if plugin is not None: + self.new_window_action = create_action( + self, _("New window"), + icon=ima.icon('newwindow'), + tip=_("Create a new editor window"), + triggered=plugin.create_new_window) + + # Splitting + self.versplit_action = create_action( + self, + _("Split vertically"), + icon=ima.icon('versplit'), + tip=_("Split vertically this editor window"), + triggered=lambda: self.sig_split_vertically.emit(), + shortcut=CONF.get_shortcut(context='Editor', + name='split vertically'), + context=Qt.WidgetShortcut) + + self.horsplit_action = create_action( + self, + _("Split horizontally"), + icon=ima.icon('horsplit'), + tip=_("Split horizontally this editor window"), + triggered=lambda: self.sig_split_horizontally.emit(), + shortcut=CONF.get_shortcut(context='Editor', + name='split horizontally'), + context=Qt.WidgetShortcut) + + self.close_action = create_action( + self, + _("Close this panel"), + icon=ima.icon('close_panel'), + triggered=self.close_split, + shortcut=CONF.get_shortcut(context='Editor', + name='close split panel'), + context=Qt.WidgetShortcut) + + # Regular actions + actions = [MENU_SEPARATOR, self.versplit_action, + self.horsplit_action, self.close_action] + + if self.new_window: + window = self.window() + close_window_action = create_action( + self, _("Close window"), + icon=ima.icon('close_pane'), + triggered=window.close) + actions += [MENU_SEPARATOR, self.new_window_action, + close_window_action] + elif plugin is not None: + if plugin._undocked_window is not None: + actions += [MENU_SEPARATOR, plugin._dock_action] + else: + actions += [MENU_SEPARATOR, self.new_window_action, + plugin._lock_unlock_action, + plugin._undock_action, + plugin._close_plugin_action] + + return actions + + def reset_orientation(self): + self.horsplit_action.setEnabled(True) + self.versplit_action.setEnabled(True) + + def set_orientation(self, orientation): + self.horsplit_action.setEnabled(orientation == Qt.Horizontal) + self.versplit_action.setEnabled(orientation == Qt.Vertical) + + def update_actions(self): + state = self.get_stack_count() > 0 + self.horsplit_action.setEnabled(state) + self.versplit_action.setEnabled(state) + + # ------ Accessors + def get_current_filename(self): + if self.data: + return self.data[self.get_stack_index()].filename + + def get_current_language(self): + if self.data: + return self.data[self.get_stack_index()].editor.language + + def get_filenames(self): + """ + Return a list with the names of all the files currently opened in + the editorstack. + """ + return [finfo.filename for finfo in self.data] + + def has_filename(self, filename): + """Return the self.data index position for the filename. + + Args: + filename: Name of the file to search for in self.data. + + Returns: + The self.data index for the filename. Returns None + if the filename is not found in self.data. + """ + data_filenames = self.get_filenames() + try: + # Try finding without calling the slow realpath + return data_filenames.index(filename) + except ValueError: + # See note about OSError on set_current_filename + # Fixes spyder-ide/spyder#17685 + try: + filename = fixpath(filename) + except OSError: + return None + + for index, editor_filename in enumerate(data_filenames): + if filename == fixpath(editor_filename): + return index + return None + + def set_current_filename(self, filename, focus=True): + """Set current filename and return the associated editor instance.""" + # FileNotFoundError: This is necessary to catch an error on Windows + # for files in a directory junction pointing to a symlink whose target + # is on a network drive that is unavailable at startup. + # Fixes spyder-ide/spyder#15714 + # OSError: This is necessary to catch an error on Windows when Spyder + # was closed with a file in a shared folder on a different computer on + # the network, and is started again when that folder is not available. + # Fixes spyder-ide/spyder#17685 + try: + index = self.has_filename(filename) + except (FileNotFoundError, OSError): + index = None + + if index is not None: + if focus: + self.set_stack_index(index) + editor = self.data[index].editor + if focus: + editor.setFocus() + else: + self.stack_history.remove_and_append(index) + + return editor + + def is_file_opened(self, filename=None): + """Return if filename is in the editor stack. + + Args: + filename: Name of the file to search for. If filename is None, + then checks if any file is open. + + Returns: + True: If filename is None and a file is open. + False: If filename is None and no files are open. + None: If filename is not None and the file isn't found. + integer: Index of file name in editor stack. + """ + if filename is None: + # Is there any file opened? + return len(self.data) > 0 + else: + return self.has_filename(filename) + + def get_index_from_filename(self, filename): + """ + Return the position index of a file in the tab bar of the editorstack + from its name. + """ + filenames = [d.filename for d in self.data] + return filenames.index(filename) + + @Slot(int, int) + def move_editorstack_data(self, start, end): + """Reorder editorstack.data so it is synchronized with the tab bar when + tabs are moved.""" + if start < 0 or end < 0: + return + else: + steps = abs(end - start) + direction = (end-start) // steps # +1 for right, -1 for left + + data = self.data + self.blockSignals(True) + + for i in range(start, end, direction): + data[i], data[i+direction] = data[i+direction], data[i] + + self.blockSignals(False) + self.refresh() + + # ------ Close file, tabwidget... + def close_file(self, index=None, force=False): + """Close file (index=None -> close current file) + Keep current file index unchanged (if current file + that is being closed)""" + current_index = self.get_stack_index() + count = self.get_stack_count() + + if index is None: + if count > 0: + index = current_index + else: + self.find_widget.set_editor(None) + return + + new_index = None + if count > 1: + if current_index == index: + new_index = self._get_previous_file_index() + else: + new_index = current_index + + can_close_file = self.parent().plugin.can_close_file( + self.data[index].filename) if self.parent() else True + is_ok = (force or self.save_if_changed(cancelable=True, index=index) + and can_close_file) + if is_ok: + finfo = self.data[index] + self.threadmanager.close_threads(finfo) + # Removing editor reference from outline explorer settings: + if self.outlineexplorer is not None: + self.outlineexplorer.remove_editor(finfo.editor.oe_proxy) + + filename = self.data[index].filename + self.remove_from_data(index) + finfo.editor.notify_close() + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.sig_close_file.emit(str(id(self)), filename) + + self.opened_files_list_changed.emit() + self.update_code_analysis_actions.emit() + self.refresh_file_dependent_actions.emit() + self.update_plugin_title.emit() + + editor = self.get_current_editor() + if editor: + editor.setFocus() + + if new_index is not None: + if index < new_index: + new_index -= 1 + self.set_stack_index(new_index) + + self.add_last_closed_file(finfo.filename) + + if finfo.filename in self.autosave.file_hashes: + del self.autosave.file_hashes[finfo.filename] + + if self.get_stack_count() == 0 and self.create_new_file_if_empty: + self.sig_new_file[()].emit() + self.update_fname_label() + return False + self.__modify_stack_title() + return is_ok + + def register_completion_capabilities(self, capabilities, language): + """ + Register completion server capabilities across all editors. + + Parameters + ---------- + capabilities: dict + Capabilities supported by a language server. + language: str + Programming language for the language server (it has to be + in small caps). + """ + for index in range(self.get_stack_count()): + editor = self.tabs.widget(index) + if editor.language.lower() == language: + editor.register_completion_capabilities(capabilities) + + def start_completion_services(self, language): + """Notify language server availability to code editors.""" + for index in range(self.get_stack_count()): + editor = self.tabs.widget(index) + if editor.language.lower() == language: + editor.start_completion_services() + + def stop_completion_services(self, language): + """Notify language server unavailability to code editors.""" + for index in range(self.get_stack_count()): + editor = self.tabs.widget(index) + if editor.language.lower() == language: + editor.stop_completion_services() + + def close_all_files(self): + """Close all opened scripts""" + while self.close_file(): + pass + + def close_all_right(self): + """ Close all files opened to the right """ + num = self.get_stack_index() + n = self.get_stack_count() + for __ in range(num, n-1): + self.close_file(num+1) + + def close_all_but_this(self): + """Close all files but the current one""" + self.close_all_right() + for __ in range(0, self.get_stack_count() - 1): + self.close_file(0) + + def sort_file_tabs_alphabetically(self): + """Sort open tabs alphabetically.""" + while self.sorted() is False: + for i in range(0, self.tabs.tabBar().count()): + if(self.tabs.tabBar().tabText(i) > + self.tabs.tabBar().tabText(i + 1)): + self.tabs.tabBar().moveTab(i, i + 1) + + def sorted(self): + """Utility function for sort_file_tabs_alphabetically().""" + for i in range(0, self.tabs.tabBar().count() - 1): + if (self.tabs.tabBar().tabText(i) > + self.tabs.tabBar().tabText(i + 1)): + return False + return True + + def add_last_closed_file(self, fname): + """Add to last closed file list.""" + if fname in self.last_closed_files: + self.last_closed_files.remove(fname) + self.last_closed_files.insert(0, fname) + if len(self.last_closed_files) > 10: + self.last_closed_files.pop(-1) + + def get_last_closed_files(self): + return self.last_closed_files + + def set_last_closed_files(self, fnames): + self.last_closed_files = fnames + + # ------ Save + def save_if_changed(self, cancelable=False, index=None): + """Ask user to save file if modified. + + Args: + cancelable: Show Cancel button. + index: File to check for modification. + + Returns: + False when save() fails or is cancelled. + True when save() is successful, there are no modifications, + or user selects No or NoToAll. + + This function controls the message box prompt for saving + changed files. The actual save is performed in save() for + each index processed. This function also removes autosave files + corresponding to files the user chooses not to save. + """ + if index is None: + indexes = list(range(self.get_stack_count())) + else: + indexes = [index] + buttons = QMessageBox.Yes | QMessageBox.No + if cancelable: + buttons |= QMessageBox.Cancel + unsaved_nb = 0 + for index in indexes: + if self.data[index].editor.document().isModified(): + unsaved_nb += 1 + if not unsaved_nb: + # No file to save + return True + if unsaved_nb > 1: + buttons |= int(QMessageBox.YesToAll | QMessageBox.NoToAll) + yes_all = no_all = False + for index in indexes: + self.set_stack_index(index) + finfo = self.data[index] + if finfo.filename == self.tempfile_path or yes_all: + if not self.save(index): + return False + elif no_all: + self.autosave.remove_autosave_file(finfo) + elif (finfo.editor.document().isModified() and + self.save_dialog_on_tests): + + self.msgbox = QMessageBox( + QMessageBox.Question, + self.title, + _("%s has been modified." + "
Do you want to save changes?" + ) % osp.basename(finfo.filename), + buttons, + parent=self) + + answer = self.msgbox.exec_() + if answer == QMessageBox.Yes: + if not self.save(index): + return False + elif answer == QMessageBox.No: + self.autosave.remove_autosave_file(finfo.filename) + elif answer == QMessageBox.YesToAll: + if not self.save(index): + return False + yes_all = True + elif answer == QMessageBox.NoToAll: + self.autosave.remove_autosave_file(finfo.filename) + no_all = True + elif answer == QMessageBox.Cancel: + return False + return True + + def compute_hash(self, fileinfo): + """Compute hash of contents of editor. + + Args: + fileinfo: FileInfo object associated to editor whose hash needs + to be computed. + + Returns: + int: computed hash. + """ + txt = to_text_string(fileinfo.editor.get_text_with_eol()) + return hash(txt) + + def _write_to_file(self, fileinfo, filename): + """Low-level function for writing text of editor to file. + + Args: + fileinfo: FileInfo object associated to editor to be saved + filename: str with filename to save to + + This is a low-level function that only saves the text to file in the + correct encoding without doing any error handling. + """ + txt = to_text_string(fileinfo.editor.get_text_with_eol()) + fileinfo.encoding = encoding.write(txt, filename, fileinfo.encoding) + + def save(self, index=None, force=False, save_new_files=True): + """Write text of editor to a file. + + Args: + index: self.data index to save. If None, defaults to + currentIndex(). + force: Force save regardless of file state. + + Returns: + True upon successful save or when file doesn't need to be saved. + False if save failed. + + If the text isn't modified and it's not newly created, then the save + is aborted. If the file hasn't been saved before, then save_as() + is invoked. Otherwise, the file is written using the file name + currently in self.data. This function doesn't change the file name. + """ + if index is None: + # Save the currently edited file + if not self.get_stack_count(): + return + index = self.get_stack_index() + + finfo = self.data[index] + if not (finfo.editor.document().isModified() or + finfo.newly_created) and not force: + return True + if not osp.isfile(finfo.filename) and not force: + # File has not been saved yet + if save_new_files: + return self.save_as(index=index) + # The file doesn't need to be saved + return True + + # The following options (`always_remove_trailing_spaces`, + # `remove_trailing_newlines` and `add_newline`) also depend on the + # `format_on_save` value. + # See spyder-ide/spyder#17716 + if self.always_remove_trailing_spaces and not self.format_on_save: + self.remove_trailing_spaces(index) + if self.remove_trailing_newlines and not self.format_on_save: + self.trim_trailing_newlines(index) + if self.add_newline and not self.format_on_save: + self.add_newline_to_file(index) + + if self.convert_eol_on_save: + # hack to account for the fact that the config file saves + # CR/LF/CRLF while set_os_eol_chars wants the os.name value. + osname_lookup = {'LF': 'posix', 'CRLF': 'nt', 'CR': 'mac'} + osname = osname_lookup[self.convert_eol_on_save_to] + self.set_os_eol_chars(osname=osname) + + try: + if self.format_on_save and finfo.editor.formatting_enabled: + # Wait for document autoformat and then save + + # Waiting for the autoformat to complete is needed + # when the file is going to be closed after saving. + # See spyder-ide/spyder#17836 + format_eventloop = finfo.editor.format_eventloop + format_timer = finfo.editor.format_timer + format_timer.setSingleShot(True) + format_timer.timeout.connect(format_eventloop.quit) + + finfo.editor.sig_stop_operation_in_progress.connect( + lambda: self._save_file(finfo)) + finfo.editor.sig_stop_operation_in_progress.connect( + format_timer.stop) + finfo.editor.sig_stop_operation_in_progress.connect( + format_eventloop.quit) + + format_timer.start(10000) + finfo.editor.format_document() + format_eventloop.exec_() + else: + self._save_file(finfo) + return True + except EnvironmentError as error: + self.msgbox = QMessageBox( + QMessageBox.Critical, + _("Save Error"), + _("Unable to save file '%s'" + "

Error message:
%s" + ) % (osp.basename(finfo.filename), + str(error)), + parent=self) + self.msgbox.exec_() + return False + + def _save_file(self, finfo): + index = self.data.index(finfo) + self._write_to_file(finfo, finfo.filename) + file_hash = self.compute_hash(finfo) + self.autosave.file_hashes[finfo.filename] = file_hash + self.autosave.remove_autosave_file(finfo.filename) + finfo.newly_created = False + self.encoding_changed.emit(finfo.encoding) + finfo.lastmodified = QFileInfo(finfo.filename).lastModified() + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + # The filename is passed instead of an index in case the tabs + # have been rearranged. See spyder-ide/spyder#5703. + self.file_saved.emit(str(id(self)), + finfo.filename, finfo.filename) + + finfo.editor.document().setModified(False) + self.modification_changed(index=index) + self.analyze_script(index=index) + + finfo.editor.notify_save() + + def file_saved_in_other_editorstack(self, original_filename, filename): + """ + File was just saved in another editorstack, let's synchronize! + This avoids file being automatically reloaded. + + The original filename is passed instead of an index in case the tabs + on the editor stacks were moved and are now in a different order - see + spyder-ide/spyder#5703. + Filename is passed in case file was just saved as another name. + """ + index = self.has_filename(original_filename) + if index is None: + return + finfo = self.data[index] + finfo.newly_created = False + finfo.filename = to_text_string(filename) + finfo.lastmodified = QFileInfo(finfo.filename).lastModified() + + def select_savename(self, original_filename): + """Select a name to save a file. + + Args: + original_filename: Used in the dialog to display the current file + path and name. + + Returns: + Normalized path for the selected file name or None if no name was + selected. + """ + if self.edit_filetypes is None: + self.edit_filetypes = get_edit_filetypes() + if self.edit_filters is None: + self.edit_filters = get_edit_filters() + + # Don't use filters on KDE to not make the dialog incredible + # slow + # Fixes spyder-ide/spyder#4156. + if is_kde_desktop() and not is_anaconda(): + filters = '' + selectedfilter = '' + else: + filters = self.edit_filters + selectedfilter = get_filter(self.edit_filetypes, + osp.splitext(original_filename)[1]) + + self.redirect_stdio.emit(False) + filename, _selfilter = getsavefilename(self, _("Save file"), + original_filename, + filters=filters, + selectedfilter=selectedfilter, + options=QFileDialog.HideNameFilterDetails) + self.redirect_stdio.emit(True) + if filename: + return osp.normpath(filename) + return None + + def save_as(self, index=None): + """Save file as... + + Args: + index: self.data index for the file to save. + + Returns: + False if no file name was selected or if save() was unsuccessful. + True is save() was successful. + + Gets the new file name from select_savename(). If no name is chosen, + then the save_as() aborts. Otherwise, the current stack is checked + to see if the selected name already exists and, if so, then the tab + with that name is closed. + + The current stack (self.data) and current tabs are updated with the + new name and other file info. The text is written with the new + name using save() and the name change is propagated to the other stacks + via the file_renamed_in_data signal. + """ + if index is None: + # Save the currently edited file + index = self.get_stack_index() + finfo = self.data[index] + original_newly_created = finfo.newly_created + # The next line is necessary to avoid checking if the file exists + # While running __check_file_status + # See spyder-ide/spyder#3678 and spyder-ide/spyder#3026. + finfo.newly_created = True + original_filename = finfo.filename + filename = self.select_savename(original_filename) + if filename: + ao_index = self.has_filename(filename) + # Note: ao_index == index --> saving an untitled file + if ao_index is not None and ao_index != index: + if not self.close_file(ao_index): + return + if ao_index < index: + index -= 1 + + new_index = self.rename_in_data(original_filename, + new_filename=filename) + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.file_renamed_in_data.emit(str(id(self)), + original_filename, filename) + + ok = self.save(index=new_index, force=True) + self.refresh(new_index) + self.set_stack_index(new_index) + return ok + else: + finfo.newly_created = original_newly_created + return False + + def save_copy_as(self, index=None): + """Save copy of file as... + + Args: + index: self.data index for the file to save. + + Returns: + False if no file name was selected or if save() was unsuccessful. + True is save() was successful. + + Gets the new file name from select_savename(). If no name is chosen, + then the save_copy_as() aborts. Otherwise, the current stack is + checked to see if the selected name already exists and, if so, then the + tab with that name is closed. + + Unlike save_as(), this calls write() directly instead of using save(). + The current file and tab aren't changed at all. The copied file is + opened in a new tab. + """ + if index is None: + # Save the currently edited file + index = self.get_stack_index() + finfo = self.data[index] + original_filename = finfo.filename + filename = self.select_savename(original_filename) + if filename: + ao_index = self.has_filename(filename) + # Note: ao_index == index --> saving an untitled file + if ao_index is not None and ao_index != index: + if not self.close_file(ao_index): + return + if ao_index < index: + index -= 1 + try: + self._write_to_file(finfo, filename) + # open created copy file + self.plugin_load.emit(filename) + return True + except EnvironmentError as error: + self.msgbox = QMessageBox( + QMessageBox.Critical, + _("Save Error"), + _("Unable to save file '%s'" + "

Error message:
%s" + ) % (osp.basename(finfo.filename), + str(error)), + parent=self) + self.msgbox.exec_() + else: + return False + + def save_all(self, save_new_files=True): + """Save all opened files. + + Iterate through self.data and call save() on any modified files. + """ + all_saved = True + for index in range(self.get_stack_count()): + if self.data[index].editor.document().isModified(): + all_saved &= self.save(index, save_new_files=save_new_files) + return all_saved + + #------ Update UI + def start_stop_analysis_timer(self): + self.is_analysis_done = False + self.analysis_timer.stop() + self.analysis_timer.start() + + def analyze_script(self, index=None): + """Analyze current script for TODOs.""" + if self.is_analysis_done: + return + if index is None: + index = self.get_stack_index() + if self.data and len(self.data) > index: + finfo = self.data[index] + if self.todolist_enabled: + finfo.run_todo_finder() + self.is_analysis_done = True + + def set_todo_results(self, filename, todo_results): + """Synchronize todo results between editorstacks""" + index = self.has_filename(filename) + if index is None: + return + self.data[index].set_todo_results(todo_results) + + def get_todo_results(self): + if self.data: + return self.data[self.get_stack_index()].todo_results + + def current_changed(self, index): + """Stack index has changed""" + editor = self.get_current_editor() + if index != -1: + editor.setFocus() + logger.debug("Set focus to: %s" % editor.filename) + else: + self.reset_statusbar.emit() + self.opened_files_list_changed.emit() + + self.stack_history.refresh() + self.stack_history.remove_and_append(index) + + # Needed to avoid an error generated after moving/renaming + # files outside Spyder while in debug mode. + # See spyder-ide/spyder#8749. + try: + logger.debug("Current changed: %d - %s" % + (index, self.data[index].editor.filename)) + except IndexError: + pass + + self.update_plugin_title.emit() + # Make sure that any replace happens in the editor on top + # See spyder-ide/spyder#9688. + self.find_widget.set_editor(editor, refresh=False) + + if editor is not None: + # Needed in order to handle the close of files open in a directory + # that has been renamed. See spyder-ide/spyder#5157. + try: + line, col = editor.get_cursor_line_column() + self.current_file_changed.emit(self.data[index].filename, + editor.get_position('cursor'), + line, col) + except IndexError: + pass + + def _get_previous_file_index(self): + """Return the penultimate element of the stack history.""" + try: + return self.stack_history[-2] + except IndexError: + return None + + def tab_navigation_mru(self, forward=True): + """ + Tab navigation with "most recently used" behaviour. + + It's fired when pressing 'go to previous file' or 'go to next file' + shortcuts. + + forward: + True: move to next file + False: move to previous file + """ + self.tabs_switcher = TabSwitcherWidget(self, self.stack_history, + self.tabs) + self.tabs_switcher.show() + self.tabs_switcher.select_row(1 if forward else -1) + self.tabs_switcher.setFocus() + + def focus_changed(self): + """Editor focus has changed""" + fwidget = QApplication.focusWidget() + for finfo in self.data: + if fwidget is finfo.editor: + if finfo.editor.operation_in_progress: + self.spinner.start() + else: + self.spinner.stop() + self.refresh() + self.editor_focus_changed.emit() + + def _refresh_outlineexplorer(self, index=None, update=True, clear=False): + """Refresh outline explorer panel""" + oe = self.outlineexplorer + if oe is None: + return + if index is None: + index = self.get_stack_index() + if self.data and len(self.data) > index: + finfo = self.data[index] + oe.setEnabled(True) + oe.set_current_editor(finfo.editor.oe_proxy, + update=update, clear=clear) + if index != self.get_stack_index(): + # The last file added to the outline explorer is not the + # currently focused one in the editor stack. Therefore, + # we need to force a refresh of the outline explorer to set + # the current editor to the currently focused one in the + # editor stack. See spyder-ide/spyder#8015. + self._refresh_outlineexplorer(update=False) + return + self._sync_outlineexplorer_file_order() + + def _sync_outlineexplorer_file_order(self): + """ + Order the root file items of the outline explorer as in the tabbar + of the current EditorStack. + """ + if self.outlineexplorer is not None: + self.outlineexplorer.treewidget.set_editor_ids_order( + [finfo.editor.get_document_id() for finfo in self.data]) + + def __refresh_statusbar(self, index): + """Refreshing statusbar widgets""" + if self.data and len(self.data) > index: + finfo = self.data[index] + self.encoding_changed.emit(finfo.encoding) + # Refresh cursor position status: + line, index = finfo.editor.get_cursor_line_column() + self.sig_editor_cursor_position_changed.emit(line, index) + + def __refresh_readonly(self, index): + if self.data and len(self.data) > index: + finfo = self.data[index] + read_only = not QFileInfo(finfo.filename).isWritable() + if not osp.isfile(finfo.filename): + # This is an 'untitledX.py' file (newly created) + read_only = False + elif os.name == 'nt': + try: + # Try to open the file to see if its permissions allow + # to write on it + # Fixes spyder-ide/spyder#10657 + fd = os.open(finfo.filename, os.O_RDWR) + os.close(fd) + except (IOError, OSError): + read_only = True + finfo.editor.setReadOnly(read_only) + self.readonly_changed.emit(read_only) + + def __check_file_status(self, index): + """Check if file has been changed in any way outside Spyder: + 1. removed, moved or renamed outside Spyder + 2. modified outside Spyder""" + if self.__file_status_flag: + # Avoid infinite loop: when the QMessageBox.question pops, it + # gets focus and then give it back to the CodeEditor instance, + # triggering a refresh cycle which calls this method + return + self.__file_status_flag = True + + if len(self.data) <= index: + index = self.get_stack_index() + + finfo = self.data[index] + name = osp.basename(finfo.filename) + + if finfo.newly_created: + # File was just created (not yet saved): do nothing + # (do not return because of the clean-up at the end of the method) + pass + + elif not osp.isfile(finfo.filename): + # File doesn't exist (removed, moved or offline): + self.msgbox = QMessageBox( + QMessageBox.Warning, + self.title, + _("%s is unavailable " + "(this file may have been removed, moved " + "or renamed outside Spyder)." + "
Do you want to close it?") % name, + QMessageBox.Yes | QMessageBox.No, + self) + answer = self.msgbox.exec_() + if answer == QMessageBox.Yes: + self.close_file(index) + else: + finfo.newly_created = True + finfo.editor.document().setModified(True) + self.modification_changed(index=index) + + else: + # Else, testing if it has been modified elsewhere: + lastm = QFileInfo(finfo.filename).lastModified() + if to_text_string(lastm.toString()) \ + != to_text_string(finfo.lastmodified.toString()): + if finfo.editor.document().isModified(): + self.msgbox = QMessageBox( + QMessageBox.Question, + self.title, + _("%s has been modified outside Spyder." + "
Do you want to reload it and lose all " + "your changes?") % name, + QMessageBox.Yes | QMessageBox.No, + self) + answer = self.msgbox.exec_() + if answer == QMessageBox.Yes: + self.reload(index) + else: + finfo.lastmodified = lastm + else: + self.reload(index) + + # Finally, resetting temporary flag: + self.__file_status_flag = False + + def __modify_stack_title(self): + for index, finfo in enumerate(self.data): + state = finfo.editor.document().isModified() + self.set_stack_title(index, state) + + def refresh(self, index=None): + """Refresh tabwidget""" + if index is None: + index = self.get_stack_index() + # Set current editor + if self.get_stack_count(): + index = self.get_stack_index() + finfo = self.data[index] + editor = finfo.editor + editor.setFocus() + self._refresh_outlineexplorer(index, update=False) + self.update_code_analysis_actions.emit() + self.__refresh_statusbar(index) + self.__refresh_readonly(index) + self.__check_file_status(index) + self.__modify_stack_title() + self.update_plugin_title.emit() + else: + editor = None + # Update the modification-state-dependent parameters + self.modification_changed() + # Update FindReplace binding + self.find_widget.set_editor(editor, refresh=False) + + def modification_changed(self, state=None, index=None, editor_id=None): + """ + Current editor's modification state has changed + --> change tab title depending on new modification state + --> enable/disable save/save all actions + """ + if editor_id is not None: + for index, _finfo in enumerate(self.data): + if id(_finfo.editor) == editor_id: + break + # This must be done before refreshing save/save all actions: + # (otherwise Save/Save all actions will always be enabled) + self.opened_files_list_changed.emit() + # -- + if index is None: + index = self.get_stack_index() + if index == -1: + return + finfo = self.data[index] + if state is None: + state = finfo.editor.document().isModified() or finfo.newly_created + self.set_stack_title(index, state) + # Toggle save/save all actions state + self.save_action.setEnabled(state) + self.refresh_save_all_action.emit() + # Refreshing eol mode + eol_chars = finfo.editor.get_line_separator() + self.refresh_eol_chars(eol_chars) + self.stack_history.refresh() + + def refresh_eol_chars(self, eol_chars): + os_name = sourcecode.get_os_name_from_eol_chars(eol_chars) + self.sig_refresh_eol_chars.emit(os_name) + + # ------ Load, reload + def reload(self, index): + """Reload file from disk.""" + finfo = self.data[index] + logger.debug("Reloading {}".format(finfo.filename)) + + txt, finfo.encoding = encoding.read(finfo.filename) + finfo.lastmodified = QFileInfo(finfo.filename).lastModified() + position = finfo.editor.get_position('cursor') + finfo.editor.set_text(txt) + finfo.editor.document().setModified(False) + self.autosave.file_hashes[finfo.filename] = hash(txt) + finfo.editor.set_cursor_position(position) + + #XXX CodeEditor-only: re-scan the whole text to rebuild outline + # explorer data from scratch (could be optimized because + # rehighlighting text means searching for all syntax coloring + # patterns instead of only searching for class/def patterns which + # would be sufficient for outline explorer data. + finfo.editor.rehighlight() + + def revert(self): + """Revert file from disk.""" + index = self.get_stack_index() + finfo = self.data[index] + logger.debug("Reverting {}".format(finfo.filename)) + + filename = finfo.filename + if finfo.editor.document().isModified(): + self.msgbox = QMessageBox( + QMessageBox.Warning, + self.title, + _("All changes to %s will be lost." + "
Do you want to revert file from disk?" + ) % osp.basename(filename), + QMessageBox.Yes | QMessageBox.No, + self) + answer = self.msgbox.exec_() + if answer != QMessageBox.Yes: + return + self.reload(index) + + def create_new_editor(self, fname, enc, txt, set_current, new=False, + cloned_from=None, add_where='end'): + """ + Create a new editor instance + Returns finfo object (instead of editor as in previous releases) + """ + editor = codeeditor.CodeEditor(self) + editor.go_to_definition.connect( + lambda fname, line, column: self.sig_go_to_definition.emit( + fname, line, column)) + + finfo = FileInfo(fname, enc, editor, new, self.threadmanager) + + self.add_to_data(finfo, set_current, add_where) + finfo.sig_send_to_help.connect(self.send_to_help) + finfo.sig_show_object_info.connect(self.inspect_current_object) + finfo.todo_results_changed.connect( + lambda: self.todo_results_changed.emit()) + finfo.edit_goto.connect(lambda fname, lineno, name: + self.edit_goto.emit(fname, lineno, name)) + finfo.sig_save_bookmarks.connect(lambda s1, s2: + self.sig_save_bookmarks.emit(s1, s2)) + editor.sig_run_selection.connect(self.run_selection) + editor.sig_run_to_line.connect(self.run_to_line) + editor.sig_run_from_line.connect(self.run_from_line) + editor.sig_run_cell.connect(self.run_cell) + editor.sig_debug_cell.connect(self.debug_cell) + editor.sig_run_cell_and_advance.connect(self.run_cell_and_advance) + editor.sig_re_run_last_cell.connect(self.re_run_last_cell) + editor.sig_new_file.connect(self.sig_new_file) + editor.sig_breakpoints_saved.connect(self.sig_breakpoints_saved) + editor.sig_process_code_analysis.connect( + lambda: self.update_code_analysis_actions.emit()) + editor.sig_refresh_formatting.connect(self.sig_refresh_formatting) + language = get_file_language(fname, txt) + editor.setup_editor( + linenumbers=self.linenumbers_enabled, + show_blanks=self.blanks_enabled, + underline_errors=self.underline_errors_enabled, + scroll_past_end=self.scrollpastend_enabled, + edge_line=self.edgeline_enabled, + edge_line_columns=self.edgeline_columns, + language=language, + markers=self.has_markers(), + font=self.default_font, + color_scheme=self.color_scheme, + wrap=self.wrap_enabled, + tab_mode=self.tabmode_enabled, + strip_mode=self.stripmode_enabled, + intelligent_backspace=self.intelligent_backspace_enabled, + automatic_completions=self.automatic_completions_enabled, + automatic_completions_after_chars=self.automatic_completion_chars, + automatic_completions_after_ms=self.automatic_completion_ms, + code_snippets=self.code_snippets_enabled, + completions_hint=self.completions_hint_enabled, + completions_hint_after_ms=self.completions_hint_after_ms, + hover_hints=self.hover_hints_enabled, + highlight_current_line=self.highlight_current_line_enabled, + highlight_current_cell=self.highlight_current_cell_enabled, + occurrence_highlighting=self.occurrence_highlighting_enabled, + occurrence_timeout=self.occurrence_highlighting_timeout, + close_parentheses=self.close_parentheses_enabled, + close_quotes=self.close_quotes_enabled, + add_colons=self.add_colons_enabled, + auto_unindent=self.auto_unindent_enabled, + indent_chars=self.indent_chars, + tab_stop_width_spaces=self.tab_stop_width_spaces, + cloned_from=cloned_from, + filename=fname, + show_class_func_dropdown=self.show_class_func_dropdown, + indent_guides=self.indent_guides, + folding=self.code_folding_enabled, + remove_trailing_spaces=self.always_remove_trailing_spaces, + remove_trailing_newlines=self.remove_trailing_newlines, + add_newline=self.add_newline, + format_on_save=self.format_on_save + ) + if cloned_from is None: + editor.set_text(txt) + editor.document().setModified(False) + finfo.text_changed_at.connect( + lambda fname, position: + self.text_changed_at.emit(fname, position)) + editor.sig_cursor_position_changed.connect( + self.editor_cursor_position_changed) + editor.textChanged.connect(self.start_stop_analysis_timer) + + # Register external panels + for panel_class, args, kwargs, position in self.external_panels: + self.register_panel( + panel_class, *args, position=position, **kwargs) + + def perform_completion_request(lang, method, params): + self.sig_perform_completion_request.emit(lang, method, params) + + editor.sig_perform_completion_request.connect( + perform_completion_request) + editor.sig_start_operation_in_progress.connect(self.spinner.start) + editor.sig_stop_operation_in_progress.connect(self.spinner.stop) + editor.modificationChanged.connect( + lambda state: self.modification_changed( + state, editor_id=id(editor))) + editor.focus_in.connect(self.focus_changed) + editor.zoom_in.connect(lambda: self.zoom_in.emit()) + editor.zoom_out.connect(lambda: self.zoom_out.emit()) + editor.zoom_reset.connect(lambda: self.zoom_reset.emit()) + editor.sig_eol_chars_changed.connect( + lambda eol_chars: self.refresh_eol_chars(eol_chars)) + editor.sig_next_cursor.connect(self.sig_next_cursor) + editor.sig_prev_cursor.connect(self.sig_prev_cursor) + + self.find_widget.set_editor(editor) + + self.refresh_file_dependent_actions.emit() + self.modification_changed(index=self.data.index(finfo)) + + # To update the outline explorer. + editor.oe_proxy = OutlineExplorerProxyEditor(editor, editor.filename) + if self.outlineexplorer is not None: + self.outlineexplorer.register_editor(editor.oe_proxy) + + # Needs to reset the highlighting on startup in case the PygmentsSH + # is in use + editor.run_pygments_highlighter() + options = { + 'language': editor.language, + 'filename': editor.filename, + 'codeeditor': editor + } + self.sig_open_file.emit(options) + if self.get_stack_index() == 0: + self.current_changed(0) + + return finfo + + def editor_cursor_position_changed(self, line, index): + """Cursor position of one of the editor in the stack has changed""" + self.sig_editor_cursor_position_changed.emit(line, index) + + @Slot(str, str, bool) + def send_to_help(self, name, signature, force=False): + """qstr1: obj_text, qstr2: argpspec, qstr3: note, qstr4: doc_text""" + if not force and not self.help_enabled: + return + + editor = self.get_current_editor() + language = editor.language.lower() + signature = to_text_string(signature) + signature = unicodedata.normalize("NFKD", signature) + parts = signature.split('\n\n') + definition = parts[0] + documentation = '\n\n'.join(parts[1:]) + args = '' + + if '(' in definition and language == 'python': + args = definition[definition.find('('):] + else: + documentation = signature + + doc = { + 'obj_text': '', + 'name': name, + 'argspec': args, + 'note': '', + 'docstring': documentation, + 'force_refresh': force, + 'path': editor.filename + } + self.sig_help_requested.emit(doc) + + def new(self, filename, encoding, text, default_content=False, + empty=False): + """ + Create new filename with *encoding* and *text* + """ + finfo = self.create_new_editor(filename, encoding, text, + set_current=False, new=True) + finfo.editor.set_cursor_position('eof') + if not empty: + finfo.editor.insert_text(os.linesep) + if default_content: + finfo.default = True + finfo.editor.document().setModified(False) + return finfo + + def load(self, filename, set_current=True, add_where='end', + processevents=True): + """ + Load filename, create an editor instance and return it + + This also sets the hash of the loaded file in the autosave component. + + *Warning* This is loading file, creating editor but not executing + the source code analysis -- the analysis must be done by the editor + plugin (in case multiple editorstack instances are handled) + """ + filename = osp.abspath(to_text_string(filename)) + if processevents: + self.starting_long_process.emit(_("Loading %s...") % filename) + text, enc = encoding.read(filename) + self.autosave.file_hashes[filename] = hash(text) + finfo = self.create_new_editor(filename, enc, text, set_current, + add_where=add_where) + index = self.data.index(finfo) + if processevents: + self.ending_long_process.emit("") + if self.isVisible() and self.checkeolchars_enabled \ + and sourcecode.has_mixed_eol_chars(text): + name = osp.basename(filename) + self.msgbox = QMessageBox( + QMessageBox.Warning, + self.title, + _("%s contains mixed end-of-line " + "characters.
Spyder will fix this " + "automatically.") % name, + QMessageBox.Ok, + self) + self.msgbox.exec_() + self.set_os_eol_chars(index) + self.is_analysis_done = False + self.analyze_script(index) + finfo.editor.set_sync_symbols_and_folding_timeout() + return finfo + + def set_os_eol_chars(self, index=None, osname=None): + """ + Sets the EOL character(s) based on the operating system. + + If `osname` is None, then the default line endings for the current + operating system will be used. + + `osname` can be one of: 'posix', 'nt', 'mac'. + """ + if osname is None: + if os.name == 'nt': + osname = 'nt' + elif sys.platform == 'darwin': + osname = 'mac' + else: + osname = 'posix' + + if index is None: + index = self.get_stack_index() + + finfo = self.data[index] + eol_chars = sourcecode.get_eol_chars_from_os_name(osname) + logger.debug(f"Set OS eol chars {eol_chars} for file {finfo.filename}") + finfo.editor.set_eol_chars(eol_chars=eol_chars) + finfo.editor.document().setModified(True) + + def remove_trailing_spaces(self, index=None): + """Remove trailing spaces""" + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Remove trailing spaces for file {finfo.filename}") + finfo.editor.trim_trailing_spaces() + + def trim_trailing_newlines(self, index=None): + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Trim trailing new lines for file {finfo.filename}") + finfo.editor.trim_trailing_newlines() + + def add_newline_to_file(self, index=None): + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Add new line to file {finfo.filename}") + finfo.editor.add_newline_to_file() + + def fix_indentation(self, index=None): + """Replace tab characters by spaces""" + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Fix indentation for file {finfo.filename}") + finfo.editor.fix_indentation() + + def format_document_or_selection(self, index=None): + if index is None: + index = self.get_stack_index() + finfo = self.data[index] + logger.debug(f"Run formatting in file {finfo.filename}") + finfo.editor.format_document_or_range() + + # ------ Run + def _run_lines_cursor(self, direction): + """ Select and run all lines from cursor in given direction""" + editor = self.get_current_editor() + + # Move cursor to start of line then move to beginning or end of + # document with KeepAnchor + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.StartOfLine) + + if direction == 'up': + cursor.movePosition(QTextCursor.Start, QTextCursor.KeepAnchor) + elif direction == 'down': + cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + + code_text = editor.get_selection_as_executable_code(cursor) + if code_text: + self.exec_in_extconsole.emit(code_text.rstrip(), + self.focus_to_editor) + + def run_to_line(self): + """ + Run all lines from the beginning up to, but not including, current + line. + """ + self._run_lines_cursor(direction='up') + + def run_from_line(self): + """ + Run all lines from and including the current line to the end of + the document. + """ + self._run_lines_cursor(direction='down') + + def run_selection(self): + """ + Run selected text or current line in console. + + If some text is selected, then execute that text in console. + + If no text is selected, then execute current line, unless current line + is empty. Then, advance cursor to next line. If cursor is on last line + and that line is not empty, then add a new blank line and move the + cursor there. If cursor is on last line and that line is empty, then do + not move cursor. + """ + text = self.get_current_editor().get_selection_as_executable_code() + if text: + self.exec_in_extconsole.emit(text.rstrip(), self.focus_to_editor) + return + editor = self.get_current_editor() + line = editor.get_current_line() + text = line.lstrip() + if text: + self.exec_in_extconsole.emit(text, self.focus_to_editor) + if editor.is_cursor_on_last_line() and text: + editor.append(editor.get_line_separator()) + editor.move_cursor_to_next('line', 'down') + + def run_cell(self, debug=False): + """Run current cell.""" + text, block = self.get_current_editor().get_cell_as_executable_code() + finfo = self.get_current_finfo() + editor = self.get_current_editor() + name = cell_name(block) + filename = finfo.filename + + self._run_cell_text(text, editor, (filename, name), debug) + + def debug_cell(self): + """Debug current cell.""" + self.run_cell(debug=True) + + def run_cell_and_advance(self): + """Run current cell and advance to the next one""" + self.run_cell() + self.advance_cell() + + def advance_cell(self, reverse=False): + """Advance to the next cell. + + reverse = True --> go to previous cell. + """ + if not reverse: + move_func = self.get_current_editor().go_to_next_cell + else: + move_func = self.get_current_editor().go_to_previous_cell + + move_func() + + def re_run_last_cell(self): + """Run the previous cell again.""" + if self.last_cell_call is None: + return + filename, cell_name = self.last_cell_call + index = self.has_filename(filename) + if index is None: + return + editor = self.data[index].editor + + try: + text = editor.get_cell_code(cell_name) + except RuntimeError: + return + + self._run_cell_text(text, editor, (filename, cell_name)) + + def _run_cell_text(self, text, editor, cell_id, debug=False): + """Run cell code in the console. + + Cell code is run in the console by copying it to the console if + `self.run_cell_copy` is ``True`` otherwise by using the `run_cell` + function. + + Parameters + ---------- + text : str + The code in the cell as a string. + line : int + The starting line number of the cell in the file. + """ + (filename, cell_name) = cell_id + if editor.is_python_or_ipython(): + args = (text, cell_name, filename, self.run_cell_copy, + self.focus_to_editor) + if debug: + self.debug_cell_in_ipyclient.emit(*args) + else: + self.run_cell_in_ipyclient.emit(*args) + + # ------ Drag and drop + def dragEnterEvent(self, event): + """ + Reimplemented Qt method. + + Inform Qt about the types of data that the widget accepts. + """ + logger.debug("dragEnterEvent was received") + source = event.mimeData() + # The second check is necessary on Windows, where source.hasUrls() + # can return True but source.urls() is [] + # The third check is needed since a file could be dropped from + # compressed files. In Windows mimedata2url(source) returns None + # Fixes spyder-ide/spyder#5218. + has_urls = source.hasUrls() + has_text = source.hasText() + urls = source.urls() + all_urls = mimedata2url(source) + logger.debug("Drag event source has_urls: {}".format(has_urls)) + logger.debug("Drag event source urls: {}".format(urls)) + logger.debug("Drag event source all_urls: {}".format(all_urls)) + logger.debug("Drag event source has_text: {}".format(has_text)) + if has_urls and urls and all_urls: + text = [encoding.is_text_file(url) for url in all_urls] + logger.debug("Accept proposed action?: {}".format(any(text))) + if any(text): + event.acceptProposedAction() + else: + event.ignore() + elif source.hasText(): + event.acceptProposedAction() + elif os.name == 'nt': + # This covers cases like dragging from compressed files, + # which can be opened by the Editor if they are plain + # text, but doesn't come with url info. + # Fixes spyder-ide/spyder#2032. + logger.debug("Accept proposed action on Windows") + event.acceptProposedAction() + else: + logger.debug("Ignore drag event") + event.ignore() + + def dropEvent(self, event): + """ + Reimplement Qt method. + + Unpack dropped data and handle it. + """ + logger.debug("dropEvent was received") + source = event.mimeData() + # The second check is necessary when mimedata2url(source) + # returns None. + # Fixes spyder-ide/spyder#7742. + if source.hasUrls() and mimedata2url(source): + files = mimedata2url(source) + files = [f for f in files if encoding.is_text_file(f)] + files = set(files or []) + for fname in files: + self.plugin_load.emit(fname) + elif source.hasText(): + editor = self.get_current_editor() + if editor is not None: + editor.insert_text(source.text()) + else: + event.ignore() + event.acceptProposedAction() + + def register_panel(self, panel_class, *args, + position=Panel.Position.LEFT, **kwargs): + """Register a panel in all codeeditors.""" + if (panel_class, args, kwargs, position) not in self.external_panels: + self.external_panels.append((panel_class, args, kwargs, position)) + for finfo in self.data: + cur_panel = finfo.editor.panels.register( + panel_class(*args, **kwargs), position=position) + if not cur_panel.isVisible(): + cur_panel.setVisible(True) + + +class EditorSplitter(QSplitter): + """QSplitter for editor windows.""" + + def __init__(self, parent, plugin, menu_actions, first=False, + register_editorstack_cb=None, unregister_editorstack_cb=None): + """Create a splitter for dividing an editor window into panels. + + Adds a new EditorStack instance to this splitter. If it's not + the first splitter, clones the current EditorStack from the plugin. + + Args: + parent: Parent widget. + plugin: Plugin this widget belongs to. + menu_actions: QActions to include from the parent. + first: Boolean if this is the first splitter in the editor. + register_editorstack_cb: Callback to register the EditorStack. + Defaults to plugin.register_editorstack() to + register the EditorStack with the Editor plugin. + unregister_editorstack_cb: Callback to unregister the EditorStack. + Defaults to plugin.unregister_editorstack() to + unregister the EditorStack with the Editor plugin. + """ + + QSplitter.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose) + self.setChildrenCollapsible(False) + + self.toolbar_list = None + self.menu_list = None + + self.plugin = plugin + + if register_editorstack_cb is None: + register_editorstack_cb = self.plugin.register_editorstack + self.register_editorstack_cb = register_editorstack_cb + if unregister_editorstack_cb is None: + unregister_editorstack_cb = self.plugin.unregister_editorstack + self.unregister_editorstack_cb = unregister_editorstack_cb + + self.menu_actions = menu_actions + self.editorstack = EditorStack(self, menu_actions) + self.register_editorstack_cb(self.editorstack) + if not first: + self.plugin.clone_editorstack(editorstack=self.editorstack) + self.editorstack.destroyed.connect(lambda: self.editorstack_closed()) + self.editorstack.sig_split_vertically.connect( + lambda: self.split(orientation=Qt.Vertical)) + self.editorstack.sig_split_horizontally.connect( + lambda: self.split(orientation=Qt.Horizontal)) + self.addWidget(self.editorstack) + + if not running_under_pytest(): + self.editorstack.set_color_scheme(plugin.get_color_scheme()) + + self.setStyleSheet(self._stylesheet) + + def closeEvent(self, event): + """Override QWidget closeEvent(). + + This event handler is called with the given event when Qt + receives a window close request from a top-level widget. + """ + QSplitter.closeEvent(self, event) + + def __give_focus_to_remaining_editor(self): + focus_widget = self.plugin.get_focus_widget() + if focus_widget is not None: + focus_widget.setFocus() + + def editorstack_closed(self): + try: + logger.debug("method 'editorstack_closed':") + logger.debug(" self : %r" % self) + self.unregister_editorstack_cb(self.editorstack) + self.editorstack = None + close_splitter = self.count() == 1 + if close_splitter: + # editorstack just closed was the last widget in this QSplitter + self.close() + return + self.__give_focus_to_remaining_editor() + except (RuntimeError, AttributeError): + # editorsplitter has been destroyed (happens when closing a + # EditorMainWindow instance) + return + + def editorsplitter_closed(self): + logger.debug("method 'editorsplitter_closed':") + logger.debug(" self : %r" % self) + try: + close_splitter = self.count() == 1 and self.editorstack is None + except RuntimeError: + # editorsplitter has been destroyed (happens when closing a + # EditorMainWindow instance) + return + if close_splitter: + # editorsplitter just closed was the last widget in this QSplitter + self.close() + return + elif self.count() == 2 and self.editorstack: + # back to the initial state: a single editorstack instance, + # as a single widget in this QSplitter: orientation may be changed + self.editorstack.reset_orientation() + self.__give_focus_to_remaining_editor() + + def split(self, orientation=Qt.Vertical): + """Create and attach a new EditorSplitter to the current EditorSplitter. + + The new EditorSplitter widget will contain an EditorStack that + is a clone of the current EditorStack. + + A single EditorSplitter instance can be split multiple times, but the + orientation will be the same for all the direct splits. If one of + the child splits is split, then that split can have a different + orientation. + """ + self.setOrientation(orientation) + self.editorstack.set_orientation(orientation) + editorsplitter = EditorSplitter(self.parent(), self.plugin, + self.menu_actions, + register_editorstack_cb=self.register_editorstack_cb, + unregister_editorstack_cb=self.unregister_editorstack_cb) + self.addWidget(editorsplitter) + editorsplitter.destroyed.connect(self.editorsplitter_closed) + current_editor = editorsplitter.editorstack.get_current_editor() + if current_editor is not None: + current_editor.setFocus() + + def iter_editorstacks(self): + """Return the editor stacks for this splitter and every first child. + + Note: If a splitter contains more than one splitter as a direct + child, only the first child's editor stack is included. + + Returns: + List of tuples containing (EditorStack instance, orientation). + """ + editorstacks = [(self.widget(0), self.orientation())] + if self.count() > 1: + editorsplitter = self.widget(1) + editorstacks += editorsplitter.iter_editorstacks() + return editorstacks + + def get_layout_settings(self): + """Return the layout state for this splitter and its children. + + Record the current state, including file names and current line + numbers, of the splitter panels. + + Returns: + A dictionary containing keys {hexstate, sizes, splitsettings}. + hexstate: String of saveState() for self. + sizes: List for size() for self. + splitsettings: List of tuples of the form + (orientation, cfname, clines) for each EditorSplitter + and its EditorStack. + orientation: orientation() for the editor + splitter (which may be a child of self). + cfname: EditorStack current file name. + clines: Current line number for each file in the + EditorStack. + """ + splitsettings = [] + for editorstack, orientation in self.iter_editorstacks(): + clines = [] + cfname = '' + # XXX - this overrides value from the loop to always be False? + orientation = False + if hasattr(editorstack, 'data'): + clines = [finfo.editor.get_cursor_line_number() + for finfo in editorstack.data] + cfname = editorstack.get_current_filename() + splitsettings.append((orientation == Qt.Vertical, cfname, clines)) + return dict(hexstate=qbytearray_to_str(self.saveState()), + sizes=self.sizes(), splitsettings=splitsettings) + + def set_layout_settings(self, settings, dont_goto=None): + """Restore layout state for the splitter panels. + + Apply the settings to restore a saved layout within the editor. If + the splitsettings key doesn't exist, then return without restoring + any settings. + + The current EditorSplitter (self) calls split() for each element + in split_settings, thus recreating the splitter panels from the saved + state. split() also clones the editorstack, which is then + iterated over to restore the saved line numbers on each file. + + The size and positioning of each splitter panel is restored from + hexstate. + + Args: + settings: A dictionary with keys {hexstate, sizes, orientation} + that define the layout for the EditorSplitter panels. + dont_goto: Defaults to None, which positions the cursor to the + end of the editor. If there's a value, positions the + cursor on the saved line number for each editor. + """ + splitsettings = settings.get('splitsettings') + if splitsettings is None: + return + splitter = self + editor = None + for i, (is_vertical, cfname, clines) in enumerate(splitsettings): + if i > 0: + splitter.split(Qt.Vertical if is_vertical else Qt.Horizontal) + splitter = splitter.widget(1) + editorstack = splitter.widget(0) + for j, finfo in enumerate(editorstack.data): + editor = finfo.editor + # TODO: go_to_line is not working properly (the line it jumps + # to is not the corresponding to that file). This will be fixed + # in a future PR (which will fix spyder-ide/spyder#3857). + if dont_goto is not None: + # Skip go to line for first file because is already there. + pass + else: + try: + editor.go_to_line(clines[j]) + except IndexError: + pass + hexstate = settings.get('hexstate') + if hexstate is not None: + self.restoreState( QByteArray().fromHex( + str(hexstate).encode('utf-8')) ) + sizes = settings.get('sizes') + if sizes is not None: + self.setSizes(sizes) + if editor is not None: + editor.clearFocus() + editor.setFocus() + + @property + def _stylesheet(self): + css = qstylizer.style.StyleSheet() + css.QSplitter.setValues( + background=QStylePalette.COLOR_BACKGROUND_1 + ) + return css.toString() + + +class EditorWidget(QSplitter): + CONF_SECTION = 'editor' + + def __init__(self, parent, plugin, menu_actions): + QSplitter.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose) + + statusbar = parent.statusBar() # Create a status bar + self.vcs_status = VCSStatus(self) + self.cursorpos_status = CursorPositionStatus(self) + self.encoding_status = EncodingStatus(self) + self.eol_status = EOLStatus(self) + self.readwrite_status = ReadWriteStatus(self) + + statusbar.insertPermanentWidget(0, self.readwrite_status) + statusbar.insertPermanentWidget(0, self.eol_status) + statusbar.insertPermanentWidget(0, self.encoding_status) + statusbar.insertPermanentWidget(0, self.cursorpos_status) + statusbar.insertPermanentWidget(0, self.vcs_status) + + self.editorstacks = [] + + self.plugin = plugin + + self.find_widget = FindReplace(self, enable_replace=True) + self.plugin.register_widget_shortcuts(self.find_widget) + self.find_widget.hide() + + # TODO: Check this initialization once the editor is migrated to the + # new API + self.outlineexplorer = OutlineExplorerWidget( + 'outline_explorer', + plugin, + self, + context=f'editor_window_{str(id(self))}' + ) + self.outlineexplorer.edit_goto.connect( + lambda filenames, goto, word: + plugin.load(filenames=filenames, goto=goto, word=word, + editorwindow=self.parent())) + + editor_widgets = QWidget(self) + editor_layout = QVBoxLayout() + editor_layout.setContentsMargins(0, 0, 0, 0) + editor_widgets.setLayout(editor_layout) + editorsplitter = EditorSplitter(self, plugin, menu_actions, + register_editorstack_cb=self.register_editorstack, + unregister_editorstack_cb=self.unregister_editorstack) + self.editorsplitter = editorsplitter + editor_layout.addWidget(editorsplitter) + editor_layout.addWidget(self.find_widget) + + splitter = QSplitter(self) + splitter.setContentsMargins(0, 0, 0, 0) + splitter.addWidget(editor_widgets) + splitter.addWidget(self.outlineexplorer) + splitter.setStretchFactor(0, 5) + splitter.setStretchFactor(1, 1) + + def register_editorstack(self, editorstack): + self.editorstacks.append(editorstack) + logger.debug("EditorWidget.register_editorstack: %r" % editorstack) + self.__print_editorstacks() + self.plugin.last_focused_editorstack[self.parent()] = editorstack + editorstack.set_closable(len(self.editorstacks) > 1) + editorstack.set_outlineexplorer(self.outlineexplorer) + editorstack.set_find_widget(self.find_widget) + editorstack.reset_statusbar.connect(self.readwrite_status.hide) + editorstack.reset_statusbar.connect(self.encoding_status.hide) + editorstack.reset_statusbar.connect(self.cursorpos_status.hide) + editorstack.readonly_changed.connect( + self.readwrite_status.update_readonly) + editorstack.encoding_changed.connect( + self.encoding_status.update_encoding) + editorstack.sig_editor_cursor_position_changed.connect( + self.cursorpos_status.update_cursor_position) + editorstack.sig_refresh_eol_chars.connect(self.eol_status.update_eol) + self.plugin.register_editorstack(editorstack) + + def __print_editorstacks(self): + logger.debug("%d editorstack(s) in editorwidget:" % + len(self.editorstacks)) + for edst in self.editorstacks: + logger.debug(" %r" % edst) + + def unregister_editorstack(self, editorstack): + logger.debug("EditorWidget.unregister_editorstack: %r" % editorstack) + self.plugin.unregister_editorstack(editorstack) + self.editorstacks.pop(self.editorstacks.index(editorstack)) + self.__print_editorstacks() + + +class EditorMainWindow(QMainWindow): + def __init__( + self, plugin, menu_actions, toolbar_list, menu_list, parent=None): + # Parent needs to be `None` if the the created widget is meant to be + # independent. See spyder-ide/spyder#17803 + QMainWindow.__init__(self, parent) + self.setAttribute(Qt.WA_DeleteOnClose) + + self.plugin = plugin + self.window_size = None + + self.editorwidget = EditorWidget(self, plugin, menu_actions) + self.setCentralWidget(self.editorwidget) + + # Setting interface theme + self.setStyleSheet(str(APP_STYLESHEET)) + + # Give focus to current editor to update/show all status bar widgets + editorstack = self.editorwidget.editorsplitter.editorstack + editor = editorstack.get_current_editor() + if editor is not None: + editor.setFocus() + + self.setWindowTitle("Spyder - %s" % plugin.windowTitle()) + self.setWindowIcon(plugin.windowIcon()) + + if toolbar_list: + self.toolbars = [] + for title, object_name, actions in toolbar_list: + toolbar = self.addToolBar(title) + toolbar.setObjectName(object_name) + toolbar.setStyleSheet(str(APP_TOOLBAR_STYLESHEET)) + toolbar.setMovable(False) + add_actions(toolbar, actions) + self.toolbars.append(toolbar) + if menu_list: + quit_action = create_action(self, _("Close window"), + icon=ima.icon("close_pane"), + tip=_("Close this window"), + triggered=self.close) + self.menus = [] + for index, (title, actions) in enumerate(menu_list): + menu = self.menuBar().addMenu(title) + if index == 0: + # File menu + add_actions(menu, actions+[None, quit_action]) + else: + add_actions(menu, actions) + self.menus.append(menu) + + def get_toolbars(self): + """Get the toolbars.""" + return self.toolbars + + def add_toolbars_to_menu(self, menu_title, actions): + """Add toolbars to a menu.""" + # Six is the position of the view menu in menus list + # that you can find in plugins/editor.py setup_other_windows. + view_menu = self.menus[6] + view_menu.setObjectName('checkbox-padding') + if actions == self.toolbars and view_menu: + toolbars = [] + for toolbar in self.toolbars: + action = toolbar.toggleViewAction() + toolbars.append(action) + add_actions(view_menu, toolbars) + + def load_toolbars(self): + """Loads the last visible toolbars from the .ini file.""" + toolbars_names = CONF.get('main', 'last_visible_toolbars', default=[]) + if toolbars_names: + dic = {} + for toolbar in self.toolbars: + dic[toolbar.objectName()] = toolbar + toolbar.toggleViewAction().setChecked(False) + toolbar.setVisible(False) + for name in toolbars_names: + if name in dic: + dic[name].toggleViewAction().setChecked(True) + dic[name].setVisible(True) + + def resizeEvent(self, event): + """Reimplement Qt method""" + if not self.isMaximized() and not self.isFullScreen(): + self.window_size = self.size() + QMainWindow.resizeEvent(self, event) + + def closeEvent(self, event): + """Reimplement Qt method""" + if self.plugin._undocked_window is not None: + self.plugin.dockwidget.setWidget(self.plugin) + self.plugin.dockwidget.setVisible(True) + self.plugin.switch_to_plugin() + QMainWindow.closeEvent(self, event) + if self.plugin._undocked_window is not None: + self.plugin._undocked_window = None + + def get_layout_settings(self): + """Return layout state""" + splitsettings = self.editorwidget.editorsplitter.get_layout_settings() + return dict(size=(self.window_size.width(), self.window_size.height()), + pos=(self.pos().x(), self.pos().y()), + is_maximized=self.isMaximized(), + is_fullscreen=self.isFullScreen(), + hexstate=qbytearray_to_str(self.saveState()), + splitsettings=splitsettings) + + def set_layout_settings(self, settings): + """Restore layout state""" + size = settings.get('size') + if size is not None: + self.resize( QSize(*size) ) + self.window_size = self.size() + pos = settings.get('pos') + if pos is not None: + self.move( QPoint(*pos) ) + hexstate = settings.get('hexstate') + if hexstate is not None: + self.restoreState( QByteArray().fromHex( + str(hexstate).encode('utf-8')) ) + if settings.get('is_maximized'): + self.setWindowState(Qt.WindowMaximized) + if settings.get('is_fullscreen'): + self.setWindowState(Qt.WindowFullScreen) + splitsettings = settings.get('splitsettings') + if splitsettings is not None: + self.editorwidget.editorsplitter.set_layout_settings(splitsettings) + + +class EditorPluginExample(QSplitter): + def __init__(self): + QSplitter.__init__(self) + + self._dock_action = None + self._undock_action = None + self._close_plugin_action = None + self._undocked_window = None + self._lock_unlock_action = None + menu_actions = [] + + self.editorstacks = [] + self.editorwindows = [] + + self.last_focused_editorstack = {} # fake + + self.find_widget = FindReplace(self, enable_replace=True) + self.outlineexplorer = OutlineExplorerWidget(None, self, self) + self.outlineexplorer.edit_goto.connect(self.go_to_file) + self.editor_splitter = EditorSplitter(self, self, menu_actions, + first=True) + + editor_widgets = QWidget(self) + editor_layout = QVBoxLayout() + editor_layout.setContentsMargins(0, 0, 0, 0) + editor_widgets.setLayout(editor_layout) + editor_layout.addWidget(self.editor_splitter) + editor_layout.addWidget(self.find_widget) + + self.setContentsMargins(0, 0, 0, 0) + self.addWidget(editor_widgets) + self.addWidget(self.outlineexplorer) + + self.setStretchFactor(0, 5) + self.setStretchFactor(1, 1) + + self.menu_actions = menu_actions + self.toolbar_list = None + self.menu_list = None + self.setup_window([], []) + + def go_to_file(self, fname, lineno, text='', start_column=None): + editorstack = self.editorstacks[0] + editorstack.set_current_filename(to_text_string(fname)) + editor = editorstack.get_current_editor() + editor.go_to_line(lineno, word=text, start_column=start_column) + + def closeEvent(self, event): + for win in self.editorwindows[:]: + win.close() + logger.debug("%d: %r" % (len(self.editorwindows), self.editorwindows)) + logger.debug("%d: %r" % (len(self.editorstacks), self.editorstacks)) + event.accept() + + def load(self, fname): + QApplication.processEvents() + editorstack = self.editorstacks[0] + editorstack.load(fname) + editorstack.analyze_script() + + def register_editorstack(self, editorstack): + logger.debug("FakePlugin.register_editorstack: %r" % editorstack) + self.editorstacks.append(editorstack) + if self.isAncestorOf(editorstack): + # editorstack is a child of the Editor plugin + editorstack.set_closable(len(self.editorstacks) > 1) + editorstack.set_outlineexplorer(self.outlineexplorer) + editorstack.set_find_widget(self.find_widget) + oe_btn = create_toolbutton(self) + editorstack.add_corner_widgets_to_tabbar([5, oe_btn]) + + action = QAction(self) + editorstack.set_io_actions(action, action, action, action) + font = QFont("Courier New") + font.setPointSize(10) + editorstack.set_default_font(font, color_scheme='Spyder') + + editorstack.sig_close_file.connect(self.close_file_in_all_editorstacks) + editorstack.file_saved.connect(self.file_saved_in_editorstack) + editorstack.file_renamed_in_data.connect( + self.file_renamed_in_data_in_editorstack) + editorstack.plugin_load.connect(self.load) + + def unregister_editorstack(self, editorstack): + logger.debug("FakePlugin.unregister_editorstack: %r" % editorstack) + self.editorstacks.pop(self.editorstacks.index(editorstack)) + + def clone_editorstack(self, editorstack): + editorstack.clone_from(self.editorstacks[0]) + + def setup_window(self, toolbar_list, menu_list): + self.toolbar_list = toolbar_list + self.menu_list = menu_list + + def create_new_window(self): + window = EditorMainWindow(self, self.menu_actions, + self.toolbar_list, self.menu_list, + show_fullpath=False, show_all_files=False, + group_cells=True, show_comments=True, + sort_files_alphabetically=False) + window.resize(self.size()) + window.show() + self.register_editorwindow(window) + window.destroyed.connect(lambda: self.unregister_editorwindow(window)) + + def register_editorwindow(self, window): + logger.debug("register_editorwindowQObject*: %r" % window) + self.editorwindows.append(window) + + def unregister_editorwindow(self, window): + logger.debug("unregister_editorwindow: %r" % window) + self.editorwindows.pop(self.editorwindows.index(window)) + + def get_focus_widget(self): + pass + + @Slot(str, str) + def close_file_in_all_editorstacks(self, editorstack_id_str, filename): + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.blockSignals(True) + index = editorstack.get_index_from_filename(filename) + editorstack.close_file(index, force=True) + editorstack.blockSignals(False) + + # This method is never called in this plugin example. It's here only + # to show how to use the file_saved signal (see above). + @Slot(str, str, str) + def file_saved_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was saved in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.file_saved_in_other_editorstack(original_filename, + filename) + + # This method is never called in this plugin example. It's here only + # to show how to use the file_saved signal (see above). + @Slot(str, str, str) + def file_renamed_in_data_in_editorstack(self, editorstack_id_str, + original_filename, filename): + """A file was renamed in data in editorstack, this notifies others""" + for editorstack in self.editorstacks: + if str(id(editorstack)) != editorstack_id_str: + editorstack.rename_in_data(original_filename, filename) + + def register_widget_shortcuts(self, widget): + """Fake!""" + pass + + def get_color_scheme(self): + pass + + +def test(): + from spyder.utils.qthelpers import qapplication + from spyder.config.base import get_module_path + + spyder_dir = get_module_path('spyder') + app = qapplication(test_time=8) + + test = EditorPluginExample() + test.resize(900, 700) + test.show() + + import time + t0 = time.time() + test.load(osp.join(spyder_dir, "widgets", "collectionseditor.py")) + test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", + "editor.py")) + test.load(osp.join(spyder_dir, "plugins", "explorer", "widgets", + 'explorer.py')) + test.load(osp.join(spyder_dir, "plugins", "editor", "widgets", + "codeeditor.py")) + print("Elapsed time: %.3f s" % (time.time()-t0)) # spyder: test-skip + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/explorer/plugin.py b/spyder/plugins/explorer/plugin.py index 9a6a37ece72..b3570f21742 100644 --- a/spyder/plugins/explorer/plugin.py +++ b/spyder/plugins/explorer/plugin.py @@ -1,271 +1,271 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Files and Directories Explorer Plugin""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.plugins import SpyderDockablePlugin, Plugins -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.plugins.explorer.widgets.main_widget import ExplorerWidget -from spyder.plugins.explorer.confpage import ExplorerConfigPage - -# Localization -_ = get_translation('spyder') - - -class Explorer(SpyderDockablePlugin): - """File and Directories Explorer DockWidget.""" - - NAME = 'explorer' - REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.IPythonConsole, Plugins.Editor] - TABIFY = Plugins.VariableExplorer - WIDGET_CLASS = ExplorerWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = ExplorerConfigPage - CONF_FILE = False - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_dir_opened = Signal(str) - """ - This signal is emitted to indicate a folder has been opened. - - Parameters - ---------- - directory: str - The opened path directory. - - Notes - ----- - This will update the current working directory. - """ - - sig_file_created = Signal(str) - """ - This signal is emitted to request creating a new file with Spyder. - - Parameters - ---------- - path: str - File path to run. - """ - - sig_file_removed = Signal(str) - """ - This signal is emitted when a file is removed. - - Parameters - ---------- - path: str - File path removed. - """ - - sig_file_renamed = Signal(str, str) - """ - This signal is emitted when a file is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed file. - new_path: str - New path for renamed file. - """ - - sig_open_file_requested = Signal(str) - """ - This signal is emitted to request opening a new file with Spyder. - - Parameters - ---------- - path: str - File path to run. - """ - - sig_folder_removed = Signal(str) - """ - This signal is emitted when a folder is removed. - - Parameters - ---------- - path: str - Folder to remove. - """ - - sig_folder_renamed = Signal(str, str) - """ - This signal is emitted when a folder is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed folder. - new_path: str - New path for renamed folder. - """ - - sig_interpreter_opened = Signal(str) - """ - This signal is emitted to request opening an interpreter with the given - path as working directory. - - Parameters - ---------- - path: str - Path to use as working directory of interpreter. - """ - - sig_module_created = Signal(str) - """ - This signal is emitted to indicate a module has been created. - - Parameters - ---------- - directory: str - The created path directory. - """ - - sig_run_requested = Signal(str) - """ - This signal is emitted to request running a file. - - Parameters - ---------- - path: str - File path to run. - """ - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - """Return widget title""" - return _("Files") - - def get_description(self): - """Return the description of the explorer widget.""" - return _("Explore files in the computer with a tree view.") - - def get_icon(self): - """Return the explorer icon.""" - # TODO: Find a decent icon for the explorer - return self.create_icon('outline_explorer') - - def on_initialize(self): - widget = self.get_widget() - - # Expose widget signals on the plugin - widget.sig_dir_opened.connect(self.sig_dir_opened) - widget.sig_file_created.connect(self.sig_file_created) - widget.sig_open_file_requested.connect(self.sig_open_file_requested) - widget.sig_open_interpreter_requested.connect( - self.sig_interpreter_opened) - widget.sig_module_created.connect(self.sig_module_created) - widget.sig_removed.connect(self.sig_file_removed) - widget.sig_renamed.connect(self.sig_file_renamed) - widget.sig_run_requested.connect(self.sig_run_requested) - widget.sig_tree_removed.connect(self.sig_folder_removed) - widget.sig_tree_renamed.connect(self.sig_folder_renamed) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_dir_opened.connect(self.chdir) - self.sig_file_created.connect(lambda t: editor.new(text=t)) - self.sig_file_removed.connect(editor.removed) - self.sig_file_renamed.connect(editor.renamed) - self.sig_folder_removed.connect(editor.removed_tree) - self.sig_folder_renamed.connect(editor.renamed_tree) - self.sig_module_created.connect(editor.new) - self.sig_open_file_requested.connect(editor.load) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - # Add preference config page - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipython_console_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - self.sig_interpreter_opened.connect( - ipyconsole.create_client_from_path) - self.sig_run_requested.connect( - lambda fname: - ipyconsole.run_script(fname, osp.dirname(fname), '', False, - False, False, True, False)) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_dir_opened.disconnect(self.chdir) - self.sig_file_created.disconnect() - self.sig_file_removed.disconnect(editor.removed) - self.sig_file_renamed.disconnect(editor.renamed) - self.sig_folder_removed.disconnect(editor.removed_tree) - self.sig_folder_renamed.disconnect(editor.renamed_tree) - self.sig_module_created.disconnect(editor.new) - self.sig_open_file_requested.disconnect(editor.load) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipython_console_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - self.sig_interpreter_opened.disconnect( - ipyconsole.create_client_from_path) - self.sig_run_requested.disconnect() - - # ---- Public API - # ------------------------------------------------------------------------ - def chdir(self, directory, emit=True): - """ - Set working directory. - - Parameters - ---------- - directory: str - The new working directory path. - emit: bool, optional - Emit a signal to indicate the working directory has changed. - Default is True. - """ - self.get_widget().chdir(directory, emit=emit) - - def refresh(self, new_path=None, force_current=True): - """ - Refresh history. - - Parameters - ---------- - new_path: str, optional - Path to add to history. Default is None. - force_current: bool, optional - Default is True. - """ - widget = self.get_widget() - widget.update_history(new_path) - widget.refresh(new_path, force_current=force_current) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Files and Directories Explorer Plugin""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.plugins import SpyderDockablePlugin, Plugins +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.plugins.explorer.widgets.main_widget import ExplorerWidget +from spyder.plugins.explorer.confpage import ExplorerConfigPage + +# Localization +_ = get_translation('spyder') + + +class Explorer(SpyderDockablePlugin): + """File and Directories Explorer DockWidget.""" + + NAME = 'explorer' + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.IPythonConsole, Plugins.Editor] + TABIFY = Plugins.VariableExplorer + WIDGET_CLASS = ExplorerWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = ExplorerConfigPage + CONF_FILE = False + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_dir_opened = Signal(str) + """ + This signal is emitted to indicate a folder has been opened. + + Parameters + ---------- + directory: str + The opened path directory. + + Notes + ----- + This will update the current working directory. + """ + + sig_file_created = Signal(str) + """ + This signal is emitted to request creating a new file with Spyder. + + Parameters + ---------- + path: str + File path to run. + """ + + sig_file_removed = Signal(str) + """ + This signal is emitted when a file is removed. + + Parameters + ---------- + path: str + File path removed. + """ + + sig_file_renamed = Signal(str, str) + """ + This signal is emitted when a file is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed file. + new_path: str + New path for renamed file. + """ + + sig_open_file_requested = Signal(str) + """ + This signal is emitted to request opening a new file with Spyder. + + Parameters + ---------- + path: str + File path to run. + """ + + sig_folder_removed = Signal(str) + """ + This signal is emitted when a folder is removed. + + Parameters + ---------- + path: str + Folder to remove. + """ + + sig_folder_renamed = Signal(str, str) + """ + This signal is emitted when a folder is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed folder. + new_path: str + New path for renamed folder. + """ + + sig_interpreter_opened = Signal(str) + """ + This signal is emitted to request opening an interpreter with the given + path as working directory. + + Parameters + ---------- + path: str + Path to use as working directory of interpreter. + """ + + sig_module_created = Signal(str) + """ + This signal is emitted to indicate a module has been created. + + Parameters + ---------- + directory: str + The created path directory. + """ + + sig_run_requested = Signal(str) + """ + This signal is emitted to request running a file. + + Parameters + ---------- + path: str + File path to run. + """ + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + """Return widget title""" + return _("Files") + + def get_description(self): + """Return the description of the explorer widget.""" + return _("Explore files in the computer with a tree view.") + + def get_icon(self): + """Return the explorer icon.""" + # TODO: Find a decent icon for the explorer + return self.create_icon('outline_explorer') + + def on_initialize(self): + widget = self.get_widget() + + # Expose widget signals on the plugin + widget.sig_dir_opened.connect(self.sig_dir_opened) + widget.sig_file_created.connect(self.sig_file_created) + widget.sig_open_file_requested.connect(self.sig_open_file_requested) + widget.sig_open_interpreter_requested.connect( + self.sig_interpreter_opened) + widget.sig_module_created.connect(self.sig_module_created) + widget.sig_removed.connect(self.sig_file_removed) + widget.sig_renamed.connect(self.sig_file_renamed) + widget.sig_run_requested.connect(self.sig_run_requested) + widget.sig_tree_removed.connect(self.sig_folder_removed) + widget.sig_tree_renamed.connect(self.sig_folder_renamed) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_dir_opened.connect(self.chdir) + self.sig_file_created.connect(lambda t: editor.new(text=t)) + self.sig_file_removed.connect(editor.removed) + self.sig_file_renamed.connect(editor.renamed) + self.sig_folder_removed.connect(editor.removed_tree) + self.sig_folder_renamed.connect(editor.renamed_tree) + self.sig_module_created.connect(editor.new) + self.sig_open_file_requested.connect(editor.load) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Add preference config page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipython_console_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.sig_interpreter_opened.connect( + ipyconsole.create_client_from_path) + self.sig_run_requested.connect( + lambda fname: + ipyconsole.run_script(fname, osp.dirname(fname), '', False, + False, False, True, False)) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_dir_opened.disconnect(self.chdir) + self.sig_file_created.disconnect() + self.sig_file_removed.disconnect(editor.removed) + self.sig_file_renamed.disconnect(editor.renamed) + self.sig_folder_removed.disconnect(editor.removed_tree) + self.sig_folder_renamed.disconnect(editor.renamed_tree) + self.sig_module_created.disconnect(editor.new) + self.sig_open_file_requested.disconnect(editor.load) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipython_console_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.sig_interpreter_opened.disconnect( + ipyconsole.create_client_from_path) + self.sig_run_requested.disconnect() + + # ---- Public API + # ------------------------------------------------------------------------ + def chdir(self, directory, emit=True): + """ + Set working directory. + + Parameters + ---------- + directory: str + The new working directory path. + emit: bool, optional + Emit a signal to indicate the working directory has changed. + Default is True. + """ + self.get_widget().chdir(directory, emit=emit) + + def refresh(self, new_path=None, force_current=True): + """ + Refresh history. + + Parameters + ---------- + new_path: str, optional + Path to add to history. Default is None. + force_current: bool, optional + Default is True. + """ + widget = self.get_widget() + widget.update_history(new_path) + widget.refresh(new_path, force_current=force_current) diff --git a/spyder/plugins/explorer/widgets/explorer.py b/spyder/plugins/explorer/widgets/explorer.py index 428b70c3064..cde17b99ee5 100644 --- a/spyder/plugins/explorer/widgets/explorer.py +++ b/spyder/plugins/explorer/widgets/explorer.py @@ -1,1938 +1,1938 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Files and Directories Explorer""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -from __future__ import with_statement - -# Standard library imports -import os -import os.path as osp -import re -import shutil -import sys - -# Third party imports -from qtpy import PYQT5 -from qtpy.compat import getexistingdirectory, getsavefilename -from qtpy.QtCore import QDir, QMimeData, Qt, QTimer, QUrl, Signal, Slot -from qtpy.QtGui import QDrag -from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, - QFileSystemModel, QInputDialog, QLabel, QLineEdit, - QMessageBox, QProxyStyle, QStyle, QTextEdit, - QToolTip, QTreeView, QVBoxLayout) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import get_home_dir -from spyder.config.main import NAME_FILTERS -from spyder.plugins.explorer.widgets.utils import ( - create_script, fixpath, IconProvider, show_in_external_file_explorer) -from spyder.py3compat import to_binary_string -from spyder.utils import encoding -from spyder.utils.icon_manager import ima -from spyder.utils import misc, programs, vcs -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import file_uri, start_file - -try: - from nbconvert import PythonExporter as nbexporter -except: - nbexporter = None # analysis:ignore - - -# Localization -_ = get_translation('spyder') - - -# ---- Constants -# ---------------------------------------------------------------------------- -class DirViewColumns: - Size = 1 - Type = 2 - Date = 3 - - -class DirViewOpenWithSubMenuSections: - Main = 'Main' - - -class DirViewActions: - # Toggles - ToggleDateColumn = 'toggle_date_column_action' - ToggleSingleClick = 'toggle_single_click_to_open_action' - ToggleSizeColumn = 'toggle_size_column_action' - ToggleTypeColumn = 'toggle_type_column_action' - ToggleHiddenFiles = 'toggle_show_hidden_action' - - # Triggers - EditNameFilters = 'edit_name_filters_action' - NewFile = 'new_file_action' - NewModule = 'new_module_action' - NewFolder = 'new_folder_action' - NewPackage = 'new_package_action' - OpenWithSpyder = 'open_with_spyder_action' - OpenWithSystem = 'open_with_system_action' - OpenWithSystem2 = 'open_with_system_2_action' - Delete = 'delete_action' - Rename = 'rename_action' - Move = 'move_action' - Copy = 'copy_action' - Paste = 'paste_action' - CopyAbsolutePath = 'copy_absolute_path_action' - CopyRelativePath = 'copy_relative_path_action' - ShowInSystemExplorer = 'show_system_explorer_action' - VersionControlCommit = 'version_control_commit_action' - VersionControlBrowse = 'version_control_browse_action' - ConvertNotebook = 'convert_notebook_action' - - # TODO: Move this to the IPython Console - OpenInterpreter = 'open_interpreter_action' - Run = 'run_action' - - -class DirViewMenus: - Context = 'context_menu' - Header = 'header_menu' - New = 'new_menu' - OpenWith = 'open_with_menu' - - -class DirViewHeaderMenuSections: - Main = 'main_section' - - -class DirViewNewSubMenuSections: - General = 'general_section' - Language = 'language_section' - - -class DirViewContextMenuSections: - CopyPaste = 'copy_paste_section' - Extras = 'extras_section' - New = 'new_section' - System = 'system_section' - VersionControl = 'version_control_section' - - -class ExplorerTreeWidgetActions: - # Toggles - ToggleFilter = 'toggle_filter_files_action' - - # Triggers - Next = 'next_action' - Parent = 'parent_action' - Previous = 'previous_action' - - -# ---- Styles -# ---------------------------------------------------------------------------- -class DirViewStyle(QProxyStyle): - - def styleHint(self, hint, option=None, widget=None, return_data=None): - """ - To show tooltips with longer delays. - - From https://stackoverflow.com/a/59059919/438386 - """ - if hint == QStyle.SH_ToolTip_WakeUpDelay: - return 1000 # 1 sec - elif hint == QStyle.SH_ToolTip_FallAsleepDelay: - # This removes some flickering when showing tooltips - return 0 - - return super().styleHint(hint, option, widget, return_data) - - -# ---- Widgets -# ---------------------------------------------------------------------------- -class DirView(QTreeView, SpyderWidgetMixin): - """Base file/directory tree view.""" - - # Signals - sig_file_created = Signal(str) - """ - This signal is emitted when a file is created - - Parameters - ---------- - module: str - Path to the created file. - """ - - sig_open_interpreter_requested = Signal(str) - """ - This signal is emitted when the interpreter opened is requested - - Parameters - ---------- - module: str - Path to use as working directory of interpreter. - """ - - sig_module_created = Signal(str) - """ - This signal is emitted when a new python module is created. - - Parameters - ---------- - module: str - Path to the new module created. - """ - - sig_redirect_stdio_requested = Signal(bool) - """ - This signal is emitted when redirect stdio is requested. - - Parameters - ---------- - enable: bool - Enable/Disable standard input/output redirection. - """ - - sig_removed = Signal(str) - """ - This signal is emitted when a file is removed. - - Parameters - ---------- - path: str - File path removed. - """ - - sig_renamed = Signal(str, str) - """ - This signal is emitted when a file is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed file. - new_path: str - New path for renamed file. - """ - - sig_run_requested = Signal(str) - """ - This signal is emitted to request running a file. - - Parameters - ---------- - path: str - File path to run. - """ - - sig_tree_removed = Signal(str) - """ - This signal is emitted when a folder is removed. - - Parameters - ---------- - path: str - Folder to remove. - """ - - sig_tree_renamed = Signal(str, str) - """ - This signal is emitted when a folder is renamed. - - Parameters - ---------- - old_path: str - Old path for renamed folder. - new_path: str - New path for renamed folder. - """ - - sig_open_file_requested = Signal(str) - """ - This signal is emitted to request opening a new file with Spyder. - - Parameters - ---------- - path: str - File path to run. - """ - - def __init__(self, parent=None): - """Initialize the DirView. - - Parameters - ---------- - parent: QWidget - Parent QWidget of the widget. - """ - if PYQT5: - super().__init__(parent=parent, class_parent=parent) - else: - QTreeView.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - # Attributes - self._parent = parent - self._last_column = 0 - self._last_order = True - self._scrollbar_positions = None - self._to_be_loaded = None - self.__expanded_state = None - self.common_actions = None - self.filter_on = False - self.expanded_or_colapsed_by_mouse = False - - # Widgets - self.fsmodel = None - self.menu = None - self.header_menu = None - header = self.header() - - # Signals - header.customContextMenuRequested.connect(self.show_header_menu) - - # Style adjustments - self._style = DirViewStyle(None) - self._style.setParent(self) - self.setStyle(self._style) - - # Setup - self.setup_fs_model() - self.setSelectionMode(self.ExtendedSelection) - header.setContextMenuPolicy(Qt.CustomContextMenu) - - # Track mouse movements. This activates the mouseMoveEvent declared - # below. - self.setMouseTracking(True) - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - def setup(self): - self.setup_view() - - # New actions - new_file_action = self.create_action( - DirViewActions.NewFile, - text=_("File..."), - icon=self.create_icon('TextFileIcon'), - triggered=lambda: self.new_file(), - ) - - new_module_action = self.create_action( - DirViewActions.NewModule, - text=_("Python file..."), - icon=self.create_icon('python'), - triggered=lambda: self.new_module(), - ) - - new_folder_action = self.create_action( - DirViewActions.NewFolder, - text=_("Folder..."), - icon=self.create_icon('folder_new'), - triggered=lambda: self.new_folder(), - ) - - new_package_action = self.create_action( - DirViewActions.NewPackage, - text=_("Python Package..."), - icon=self.create_icon('package_new'), - triggered=lambda: self.new_package(), - ) - - # Open actions - self.open_with_spyder_action = self.create_action( - DirViewActions.OpenWithSpyder, - text=_("Open in Spyder"), - icon=self.create_icon('edit'), - triggered=lambda: self.open(), - ) - - self.open_external_action = self.create_action( - DirViewActions.OpenWithSystem, - text=_("Open externally"), - triggered=lambda: self.open_external(), - ) - - self.open_external_action_2 = self.create_action( - DirViewActions.OpenWithSystem2, - text=_("Default external application"), - triggered=lambda: self.open_external(), - register_shortcut=False, - ) - - # File management actions - delete_action = self.create_action( - DirViewActions.Delete, - text=_("Delete..."), - icon=self.create_icon('editdelete'), - triggered=lambda: self.delete(), - ) - - rename_action = self.create_action( - DirViewActions.Rename, - text=_("Rename..."), - icon=self.create_icon('rename'), - triggered=lambda: self.rename(), - ) - - self.move_action = self.create_action( - DirViewActions.Move, - text=_("Move..."), - icon=self.create_icon('move'), - triggered=lambda: self.move(), - ) - - # Copy/Paste actions - copy_action = self.create_action( - DirViewActions.Copy, - text=_("Copy"), - icon=self.create_icon('editcopy'), - triggered=lambda: self.copy_file_clipboard(), - ) - - self.paste_action = self.create_action( - DirViewActions.Paste, - text=_("Paste"), - icon=self.create_icon('editpaste'), - triggered=lambda: self.save_file_clipboard(), - ) - - copy_absolute_path_action = self.create_action( - DirViewActions.CopyAbsolutePath, - text=_("Copy Absolute Path"), - triggered=lambda: self.copy_absolute_path(), - ) - - copy_relative_path_action = self.create_action( - DirViewActions.CopyRelativePath, - text=_("Copy Relative Path"), - triggered=lambda: self.copy_relative_path(), - ) - - # Show actions - if sys.platform == 'darwin': - show_in_finder_text = _("Show in Finder") - else: - show_in_finder_text = _("Show in Folder") - - show_in_system_explorer_action = self.create_action( - DirViewActions.ShowInSystemExplorer, - text=show_in_finder_text, - triggered=lambda: self.show_in_external_file_explorer(), - ) - - # Version control actions - self.vcs_commit_action = self.create_action( - DirViewActions.VersionControlCommit, - text=_("Commit"), - icon=self.create_icon('vcs_commit'), - triggered=lambda: self.vcs_command('commit'), - ) - self.vcs_log_action = self.create_action( - DirViewActions.VersionControlBrowse, - text=_("Browse repository"), - icon=self.create_icon('vcs_browse'), - triggered=lambda: self.vcs_command('browse'), - ) - - # Common actions - self.hidden_action = self.create_action( - DirViewActions.ToggleHiddenFiles, - text=_("Show hidden files"), - toggled=True, - initial=self.get_conf('show_hidden'), - option='show_hidden' - ) - - self.filters_action = self.create_action( - DirViewActions.EditNameFilters, - text=_("Edit filter settings..."), - icon=self.create_icon('filter'), - triggered=lambda: self.edit_filter(), - ) - - self.create_action( - DirViewActions.ToggleSingleClick, - text=_("Single click to open"), - toggled=True, - initial=self.get_conf('single_click_to_open'), - option='single_click_to_open' - ) - - # IPython console actions - # TODO: Move this option to the ipython console setup - self.open_interpreter_action = self.create_action( - DirViewActions.OpenInterpreter, - text=_("Open IPython console here"), - triggered=lambda: self.open_interpreter(), - ) - - # TODO: Move this option to the ipython console setup - run_action = self.create_action( - DirViewActions.Run, - text=_("Run"), - icon=self.create_icon('run'), - triggered=lambda: self.run(), - ) - - # Notebook Actions - ipynb_convert_action = self.create_action( - DirViewActions.ConvertNotebook, - _("Convert to Python file"), - icon=ima.icon('python'), - triggered=lambda: self.convert_notebooks() - ) - - # Header Actions - size_column_action = self.create_action( - DirViewActions.ToggleSizeColumn, - text=_('Size'), - toggled=True, - initial=self.get_conf('size_column'), - register_shortcut=False, - option='size_column' - ) - type_column_action = self.create_action( - DirViewActions.ToggleTypeColumn, - text=_('Type') if sys.platform == 'darwin' else _('Type'), - toggled=True, - initial=self.get_conf('type_column'), - register_shortcut=False, - option='type_column' - ) - date_column_action = self.create_action( - DirViewActions.ToggleDateColumn, - text=_("Date modified"), - toggled=True, - initial=self.get_conf('date_column'), - register_shortcut=False, - option='date_column' - ) - - # Header Context Menu - self.header_menu = self.create_menu(DirViewMenus.Header) - for item in [size_column_action, type_column_action, - date_column_action]: - self.add_item_to_menu( - item, - menu=self.header_menu, - section=DirViewHeaderMenuSections.Main, - ) - - # New submenu - new_submenu = self.create_menu( - DirViewMenus.New, - _('New'), - ) - for item in [new_file_action, new_folder_action]: - self.add_item_to_menu( - item, - menu=new_submenu, - section=DirViewNewSubMenuSections.General, - ) - - for item in [new_module_action, new_package_action]: - self.add_item_to_menu( - item, - menu=new_submenu, - section=DirViewNewSubMenuSections.Language, - ) - - # Open with submenu - self.open_with_submenu = self.create_menu( - DirViewMenus.OpenWith, - _('Open with'), - ) - - # Context submenu - self.context_menu = self.create_menu(DirViewMenus.Context) - for item in [new_submenu, run_action, - self.open_with_spyder_action, - self.open_with_submenu, - self.open_external_action, - delete_action, rename_action, self.move_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.New, - ) - - # Copy/Paste section - for item in [copy_action, self.paste_action, copy_absolute_path_action, - copy_relative_path_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.CopyPaste, - ) - - self.add_item_to_menu( - show_in_system_explorer_action, - menu=self.context_menu, - section=DirViewContextMenuSections.System, - ) - - # Version control section - for item in [self.vcs_commit_action, self.vcs_log_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.VersionControl - ) - - for item in [self.open_interpreter_action, ipynb_convert_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=DirViewContextMenuSections.Extras, - ) - - # Signals - self.context_menu.aboutToShow.connect(self.update_actions) - - @on_conf_change(option=['size_column', 'type_column', 'date_column', - 'name_filters', 'show_hidden', - 'single_click_to_open']) - def on_conf_update(self, option, value): - if option == 'size_column': - self.setColumnHidden(DirViewColumns.Size, not value) - elif option == 'type_column': - self.setColumnHidden(DirViewColumns.Type, not value) - elif option == 'date_column': - self.setColumnHidden(DirViewColumns.Date, not value) - elif option == 'name_filters': - if self.filter_on: - self.filter_files(value) - elif option == 'show_hidden': - self.set_show_hidden(value) - elif option == 'single_click_to_open': - self.set_single_click_to_open(value) - - def update_actions(self): - fnames = self.get_selected_filenames() - if fnames: - if osp.isdir(fnames[0]): - dirname = fnames[0] - else: - dirname = osp.dirname(fnames[0]) - - basedir = fixpath(osp.dirname(fnames[0])) - only_dirs = fnames and all([osp.isdir(fname) for fname in fnames]) - only_files = all([osp.isfile(fname) for fname in fnames]) - only_valid = all([encoding.is_text_file(fna) for fna in fnames]) - else: - only_files = False - only_valid = False - only_dirs = False - dirname = '' - basedir = '' - - vcs_visible = vcs.is_vcs_repository(dirname) - - # Make actions visible conditionally - self.move_action.setVisible( - all([fixpath(osp.dirname(fname)) == basedir for fname in fnames])) - self.open_external_action.setVisible(False) - self.open_interpreter_action.setVisible(only_dirs) - self.open_with_spyder_action.setVisible(only_files and only_valid) - self.open_with_submenu.menuAction().setVisible(False) - clipboard = QApplication.clipboard() - has_urls = clipboard.mimeData().hasUrls() - self.paste_action.setDisabled(not has_urls) - - # VCS support is quite limited for now, so we are enabling the VCS - # related actions only when a single file/folder is selected: - self.vcs_commit_action.setVisible(vcs_visible) - self.vcs_log_action.setVisible(vcs_visible) - - if only_files: - if len(fnames) == 1: - assoc = self.get_file_associations(fnames[0]) - elif len(fnames) > 1: - assoc = self.get_common_file_associations(fnames) - - if len(assoc) >= 1: - actions = self._create_file_associations_actions() - self.open_with_submenu.menuAction().setVisible(True) - self.open_with_submenu.clear_actions() - for action in actions: - self.add_item_to_menu( - action, - menu=self.open_with_submenu, - section=DirViewOpenWithSubMenuSections.Main, - ) - else: - self.open_external_action.setVisible(True) - - fnames = self.get_selected_filenames() - only_notebooks = all([osp.splitext(fname)[1] == '.ipynb' - for fname in fnames]) - only_modules = all([osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy') - for fname in fnames]) - - nb_visible = only_notebooks and nbexporter is not None - self.get_action(DirViewActions.ConvertNotebook).setVisible(nb_visible) - self.get_action(DirViewActions.Run).setVisible(only_modules) - - def _create_file_associations_actions(self, fnames=None): - """ - Create file association actions. - """ - if fnames is None: - fnames = self.get_selected_filenames() - - actions = [] - only_files = all([osp.isfile(fname) for fname in fnames]) - if only_files: - if len(fnames) == 1: - assoc = self.get_file_associations(fnames[0]) - elif len(fnames) > 1: - assoc = self.get_common_file_associations(fnames) - - if len(assoc) >= 1: - for app_name, fpath in assoc: - text = app_name - if not (os.path.isfile(fpath) or os.path.isdir(fpath)): - text += _(' (Application not found!)') - - try: - # Action might have been created already - open_assoc = self.open_association - open_with_action = self.create_action( - app_name, - text=text, - triggered=lambda x, y=fpath: open_assoc(y), - register_shortcut=False, - ) - except Exception: - open_with_action = self.get_action(app_name) - - # Disconnect previous signal in case the app path - # changed - try: - open_with_action.triggered.disconnect() - except Exception: - pass - - # Reconnect the trigger signal - open_with_action.triggered.connect( - lambda x, y=fpath: self.open_association(y) - ) - - if not (os.path.isfile(fpath) or os.path.isdir(fpath)): - open_with_action.setDisabled(True) - - actions.append(open_with_action) - - actions.append(self.open_external_action_2) - - return actions - - # ---- Qt overrides - # ------------------------------------------------------------------------ - def sortByColumn(self, column, order=Qt.AscendingOrder): - """Override Qt method.""" - header = self.header() - header.setSortIndicatorShown(True) - QTreeView.sortByColumn(self, column, order) - header.setSortIndicator(0, order) - self._last_column = column - self._last_order = not self._last_order - - def viewportEvent(self, event): - """Reimplement Qt method""" - - # Prevent Qt from crashing or showing warnings like: - # "QSortFilterProxyModel: index from wrong model passed to - # mapFromSource", probably due to the fact that the file system model - # is being built. See spyder-ide/spyder#1250. - # - # This workaround was inspired by the following KDE bug: - # https://bugs.kde.org/show_bug.cgi?id=172198 - # - # Apparently, this is a bug from Qt itself. - self.executeDelayedItemsLayout() - - return QTreeView.viewportEvent(self, event) - - def contextMenuEvent(self, event): - """Override Qt method""" - # Needed to handle not initialized menu. - # See spyder-ide/spyder#6975 - try: - fnames = self.get_selected_filenames() - if len(fnames) != 0: - self.context_menu.popup(event.globalPos()) - except AttributeError: - pass - - def keyPressEvent(self, event): - """Reimplement Qt method""" - if event.key() in (Qt.Key_Enter, Qt.Key_Return): - self.clicked() - elif event.key() == Qt.Key_F2: - self.rename() - elif event.key() == Qt.Key_Delete: - self.delete() - elif event.key() == Qt.Key_Backspace: - self.go_to_parent_directory() - else: - QTreeView.keyPressEvent(self, event) - - def mouseDoubleClickEvent(self, event): - """Handle double clicks.""" - super().mouseDoubleClickEvent(event) - if not self.get_conf('single_click_to_open'): - self.clicked(index=self.indexAt(event.pos())) - - def mousePressEvent(self, event): - """ - Detect when a directory was expanded or collapsed by clicking - on its arrow. - - Taken from https://stackoverflow.com/a/13142586/438386 - """ - clicked_index = self.indexAt(event.pos()) - if clicked_index.isValid(): - vrect = self.visualRect(clicked_index) - item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() - if event.pos().x() < item_identation: - self.expanded_or_colapsed_by_mouse = True - else: - self.expanded_or_colapsed_by_mouse = False - super().mousePressEvent(event) - - def mouseReleaseEvent(self, event): - """Handle single clicks.""" - super().mouseReleaseEvent(event) - if self.get_conf('single_click_to_open'): - self.clicked(index=self.indexAt(event.pos())) - - def mouseMoveEvent(self, event): - """Actions to take with mouse movements.""" - # To hide previous tooltip - QToolTip.hideText() - - index = self.indexAt(event.pos()) - if index.isValid(): - if self.get_conf('single_click_to_open'): - vrect = self.visualRect(index) - item_identation = ( - vrect.x() - self.visualRect(self.rootIndex()).x() - ) - - if event.pos().x() > item_identation: - # When hovering over directories or files - self.setCursor(Qt.PointingHandCursor) - else: - # On every other element - self.setCursor(Qt.ArrowCursor) - - self.setToolTip(self.get_filename(index)) - - super().mouseMoveEvent(event) - - def dragEnterEvent(self, event): - """Drag and Drop - Enter event""" - event.setAccepted(event.mimeData().hasFormat("text/plain")) - - def dragMoveEvent(self, event): - """Drag and Drop - Move event""" - if (event.mimeData().hasFormat("text/plain")): - event.setDropAction(Qt.MoveAction) - event.accept() - else: - event.ignore() - - def startDrag(self, dropActions): - """Reimplement Qt Method - handle drag event""" - data = QMimeData() - data.setUrls([QUrl(fname) for fname in self.get_selected_filenames()]) - drag = QDrag(self) - drag.setMimeData(data) - drag.exec_() - - # ---- Model - # ------------------------------------------------------------------------ - def setup_fs_model(self): - """Setup filesystem model""" - self.fsmodel = QFileSystemModel(self) - self.fsmodel.setNameFilterDisables(False) - - def install_model(self): - """Install filesystem model""" - self.setModel(self.fsmodel) - - def setup_view(self): - """Setup view""" - self.install_model() - self.fsmodel.directoryLoaded.connect( - lambda: self.resizeColumnToContents(0)) - self.setAnimated(False) - self.setSortingEnabled(True) - self.sortByColumn(0, Qt.AscendingOrder) - self.fsmodel.modelReset.connect(self.reset_icon_provider) - self.reset_icon_provider() - - # ---- File/Dir Helpers - # ------------------------------------------------------------------------ - def get_filename(self, index): - """Return filename associated with *index*""" - if index: - return osp.normpath(str(self.fsmodel.filePath(index))) - - def get_index(self, filename): - """Return index associated with filename""" - return self.fsmodel.index(filename) - - def get_selected_filenames(self): - """Return selected filenames""" - fnames = [] - if self.selectionMode() == self.ExtendedSelection: - if self.selectionModel() is not None: - fnames = [self.get_filename(idx) for idx in - self.selectionModel().selectedRows()] - else: - fnames = [self.get_filename(self.currentIndex())] - - return fnames - - def get_dirname(self, index): - """Return dirname associated with *index*""" - fname = self.get_filename(index) - if fname: - if osp.isdir(fname): - return fname - else: - return osp.dirname(fname) - - # ---- General actions API - # ------------------------------------------------------------------------ - def show_header_menu(self, pos): - """Display header menu.""" - self.header_menu.popup(self.mapToGlobal(pos)) - - def clicked(self, index=None): - """ - Selected item was single/double-clicked or enter/return was pressed. - """ - fnames = self.get_selected_filenames() - - # Don't do anything when clicking on the arrow next to a directory - # to expand/collapse it. If clicking on its name, use it as `fnames`. - if index and index.isValid(): - fname = self.get_filename(index) - if osp.isdir(fname): - if self.expanded_or_colapsed_by_mouse: - return - else: - fnames = [fname] - - # Open files or directories - for fname in fnames: - if osp.isdir(fname): - self.directory_clicked(fname, index) - else: - if len(fnames) == 1: - assoc = self.get_file_associations(fnames[0]) - elif len(fnames) > 1: - assoc = self.get_common_file_associations(fnames) - - if assoc: - self.open_association(assoc[0][-1]) - else: - self.open([fname]) - - def directory_clicked(self, dirname, index): - """ - Handle directories being clicked. - - Parameters - ---------- - dirname: str - Path to the clicked directory. - index: QModelIndex - Index of the directory. - """ - raise NotImplementedError('To be implemented by subclasses') - - @Slot() - def edit_filter(self): - """Edit name filters.""" - # Create Dialog - dialog = QDialog(self) - dialog.resize(500, 300) - dialog.setWindowTitle(_('Edit filter settings')) - - # Create dialog contents - description_label = QLabel( - _('Filter files by name, extension, or more using ' - 'glob' - ' patterns. Please enter the glob patterns of the files you ' - 'want to show, separated by commas.')) - description_label.setOpenExternalLinks(True) - description_label.setWordWrap(True) - filters = QTextEdit(", ".join(self.get_conf('name_filters')), - parent=self) - layout = QVBoxLayout() - layout.addWidget(description_label) - layout.addWidget(filters) - - def handle_ok(): - filter_text = filters.toPlainText() - filter_text = [f.strip() for f in str(filter_text).split(',')] - self.set_name_filters(filter_text) - dialog.accept() - - def handle_reset(): - self.set_name_filters(NAME_FILTERS) - filters.setPlainText(", ".join(self.get_conf('name_filters'))) - - # Dialog buttons - button_box = QDialogButtonBox(QDialogButtonBox.Reset | - QDialogButtonBox.Ok | - QDialogButtonBox.Cancel) - button_box.accepted.connect(handle_ok) - button_box.rejected.connect(dialog.reject) - button_box.button(QDialogButtonBox.Reset).clicked.connect(handle_reset) - layout.addWidget(button_box) - dialog.setLayout(layout) - dialog.show() - - @Slot() - def open(self, fnames=None): - """Open files with the appropriate application""" - if fnames is None: - fnames = self.get_selected_filenames() - for fname in fnames: - if osp.isfile(fname) and encoding.is_text_file(fname): - self.sig_open_file_requested.emit(fname) - else: - self.open_outside_spyder([fname]) - - @Slot() - def open_association(self, app_path): - """Open files with given application executable path.""" - if not (os.path.isdir(app_path) or os.path.isfile(app_path)): - return_codes = {app_path: 1} - app_path = None - else: - return_codes = {} - - if app_path: - fnames = self.get_selected_filenames() - return_codes = programs.open_files_with_application(app_path, - fnames) - self.check_launch_error_codes(return_codes) - - @Slot() - def open_external(self, fnames=None): - """Open files with default application""" - if fnames is None: - fnames = self.get_selected_filenames() - for fname in fnames: - self.open_outside_spyder([fname]) - - def open_outside_spyder(self, fnames): - """ - Open file outside Spyder with the appropriate application. - - If this does not work, opening unknown file in Spyder, as text file. - """ - for path in sorted(fnames): - path = file_uri(path) - ok = start_file(path) - if not ok and encoding.is_text_file(path): - self.sig_open_file_requested.emit(path) - - def remove_tree(self, dirname): - """ - Remove whole directory tree - - Reimplemented in project explorer widget - """ - while osp.exists(dirname): - try: - shutil.rmtree(dirname, onerror=misc.onerror) - except Exception as e: - # This handles a Windows problem with shutil.rmtree. - # See spyder-ide/spyder#8567. - if type(e).__name__ == "OSError": - error_path = str(e.filename) - shutil.rmtree(error_path, ignore_errors=True) - - def delete_file(self, fname, multiple, yes_to_all): - """Delete file""" - if multiple: - buttons = (QMessageBox.Yes | QMessageBox.YesToAll | - QMessageBox.No | QMessageBox.Cancel) - else: - buttons = QMessageBox.Yes | QMessageBox.No - if yes_to_all is None: - answer = QMessageBox.warning( - self, _("Delete"), - _("Do you really want to delete %s?" - ) % osp.basename(fname), buttons) - if answer == QMessageBox.No: - return yes_to_all - elif answer == QMessageBox.Cancel: - return False - elif answer == QMessageBox.YesToAll: - yes_to_all = True - try: - if osp.isfile(fname): - misc.remove_file(fname) - self.sig_removed.emit(fname) - else: - self.remove_tree(fname) - self.sig_tree_removed.emit(fname) - return yes_to_all - except EnvironmentError as error: - action_str = _('delete') - QMessageBox.critical( - self, _("Project Explorer"), - _("Unable to %s %s

Error message:
%s" - ) % (action_str, fname, str(error))) - return False - - @Slot() - def delete(self, fnames=None): - """Delete files""" - if fnames is None: - fnames = self.get_selected_filenames() - multiple = len(fnames) > 1 - yes_to_all = None - for fname in fnames: - spyproject_path = osp.join(fname, '.spyproject') - if osp.isdir(fname) and osp.exists(spyproject_path): - QMessageBox.information( - self, _('File Explorer'), - _("The current directory contains a project.

" - "If you want to delete the project, please go to " - "Projects » Delete Project")) - else: - yes_to_all = self.delete_file(fname, multiple, yes_to_all) - if yes_to_all is not None and not yes_to_all: - # Canceled - break - - def rename_file(self, fname): - """Rename file""" - path, valid = QInputDialog.getText( - self, _('Rename'), _('New name:'), QLineEdit.Normal, - osp.basename(fname)) - - if valid: - path = osp.join(osp.dirname(fname), str(path)) - if path == fname: - return - if osp.exists(path): - answer = QMessageBox.warning( - self, _("Rename"), - _("Do you really want to rename %s and " - "overwrite the existing file %s?" - ) % (osp.basename(fname), osp.basename(path)), - QMessageBox.Yes | QMessageBox.No) - if answer == QMessageBox.No: - return - try: - misc.rename_file(fname, path) - if osp.isfile(path): - self.sig_renamed.emit(fname, path) - else: - self.sig_tree_renamed.emit(fname, path) - return path - except EnvironmentError as error: - QMessageBox.critical( - self, _("Rename"), - _("Unable to rename file %s" - "

Error message:
%s" - ) % (osp.basename(fname), str(error))) - - @Slot() - def show_in_external_file_explorer(self, fnames=None): - """Show file in external file explorer""" - if fnames is None: - fnames = self.get_selected_filenames() - show_in_external_file_explorer(fnames) - - @Slot() - def rename(self, fnames=None): - """Rename files""" - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - for fname in fnames: - self.rename_file(fname) - - @Slot() - def move(self, fnames=None, directory=None): - """Move files/directories""" - if fnames is None: - fnames = self.get_selected_filenames() - orig = fixpath(osp.dirname(fnames[0])) - while True: - self.sig_redirect_stdio_requested.emit(False) - if directory is None: - folder = getexistingdirectory( - self, _("Select directory"), orig) - else: - folder = directory - self.sig_redirect_stdio_requested.emit(True) - if folder: - folder = fixpath(folder) - if folder != orig: - break - else: - return - for fname in fnames: - basename = osp.basename(fname) - try: - misc.move_file(fname, osp.join(folder, basename)) - except EnvironmentError as error: - QMessageBox.critical( - self, _("Error"), - _("Unable to move %s" - "

Error message:
%s" - ) % (basename, str(error))) - - def create_new_folder(self, current_path, title, subtitle, is_package): - """Create new folder""" - if current_path is None: - current_path = '' - if osp.isfile(current_path): - current_path = osp.dirname(current_path) - name, valid = QInputDialog.getText(self, title, subtitle, - QLineEdit.Normal, "") - if valid: - dirname = osp.join(current_path, str(name)) - try: - os.mkdir(dirname) - except EnvironmentError as error: - QMessageBox.critical( - self, title, - _("Unable to create folder %s" - "

Error message:
%s" - ) % (dirname, str(error))) - finally: - if is_package: - fname = osp.join(dirname, '__init__.py') - try: - with open(fname, 'wb') as f: - f.write(to_binary_string('#')) - return dirname - except EnvironmentError as error: - QMessageBox.critical( - self, title, - _("Unable to create file %s" - "

Error message:
%s" - ) % (fname, str(error))) - - def get_selected_dir(self): - """ Get selected dir - If file is selected the directory containing file is returned. - If multiple items are selected, first item is chosen. - """ - selected_path = self.get_selected_filenames()[0] - if osp.isfile(selected_path): - selected_path = osp.dirname(selected_path) - return fixpath(selected_path) - - def new_folder(self, basedir=None): - """New folder.""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _('New folder') - subtitle = _('Folder name:') - self.create_new_folder(basedir, title, subtitle, is_package=False) - - def create_new_file(self, current_path, title, filters, create_func): - """Create new file - Returns True if successful""" - if current_path is None: - current_path = '' - if osp.isfile(current_path): - current_path = osp.dirname(current_path) - self.sig_redirect_stdio_requested.emit(False) - fname, _selfilter = getsavefilename(self, title, current_path, filters) - self.sig_redirect_stdio_requested.emit(True) - if fname: - try: - create_func(fname) - return fname - except EnvironmentError as error: - QMessageBox.critical( - self, _("New file"), - _("Unable to create file %s" - "

Error message:
%s" - ) % (fname, str(error))) - - def new_file(self, basedir=None): - """New file""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _("New file") - filters = _("All files")+" (*)" - - def create_func(fname): - """File creation callback""" - if osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy'): - create_script(fname) - else: - with open(fname, 'wb') as f: - f.write(to_binary_string('')) - fname = self.create_new_file(basedir, title, filters, create_func) - if fname is not None: - self.open([fname]) - - @Slot() - def run(self, fnames=None): - """Run Python scripts""" - if fnames is None: - fnames = self.get_selected_filenames() - for fname in fnames: - self.sig_run_requested.emit(fname) - - def copy_path(self, fnames=None, method="absolute"): - """Copy absolute or relative path to given file(s)/folders(s).""" - cb = QApplication.clipboard() - explorer_dir = self.fsmodel.rootPath() - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - fnames = [_fn.replace(os.sep, "/") for _fn in fnames] - if len(fnames) > 1: - if method == "absolute": - clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in fnames) - elif method == "relative": - clipboard_files = ',\n'.join('"' + - osp.relpath(_fn, explorer_dir). - replace(os.sep, "/") + '"' - for _fn in fnames) - else: - if method == "absolute": - clipboard_files = fnames[0] - elif method == "relative": - clipboard_files = (osp.relpath(fnames[0], explorer_dir). - replace(os.sep, "/")) - copied_from = self._parent.__class__.__name__ - if copied_from == 'ProjectExplorerWidget' and method == 'relative': - clipboard_files = [path.strip(',"') for path in - clipboard_files.splitlines()] - clipboard_files = ['/'.join(path.strip('/').split('/')[1:]) for - path in clipboard_files] - if len(clipboard_files) > 1: - clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in - clipboard_files) - else: - clipboard_files = clipboard_files[0] - cb.setText(clipboard_files, mode=cb.Clipboard) - - @Slot() - def copy_absolute_path(self): - """Copy absolute paths of named files/directories to the clipboard.""" - self.copy_path(method="absolute") - - @Slot() - def copy_relative_path(self): - """Copy relative paths of named files/directories to the clipboard.""" - self.copy_path(method="relative") - - @Slot() - def copy_file_clipboard(self, fnames=None): - """Copy file(s)/folders(s) to clipboard.""" - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - try: - file_content = QMimeData() - file_content.setUrls([QUrl.fromLocalFile(_fn) for _fn in fnames]) - cb = QApplication.clipboard() - cb.setMimeData(file_content, mode=cb.Clipboard) - except Exception as e: - QMessageBox.critical( - self, _('File/Folder copy error'), - _("Cannot copy this type of file(s) or " - "folder(s). The error was:\n\n") + str(e)) - - @Slot() - def save_file_clipboard(self, fnames=None): - """Paste file from clipboard into file/project explorer directory.""" - if fnames is None: - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - if len(fnames) >= 1: - try: - selected_item = osp.commonpath(fnames) - except AttributeError: - # py2 does not have commonpath - if len(fnames) > 1: - selected_item = osp.normpath( - osp.dirname(osp.commonprefix(fnames))) - else: - selected_item = fnames[0] - if osp.isfile(selected_item): - parent_path = osp.dirname(selected_item) - else: - parent_path = osp.normpath(selected_item) - cb_data = QApplication.clipboard().mimeData() - if cb_data.hasUrls(): - urls = cb_data.urls() - for url in urls: - source_name = url.toLocalFile() - base_name = osp.basename(source_name) - if osp.isfile(source_name): - try: - while base_name in os.listdir(parent_path): - file_no_ext, file_ext = osp.splitext(base_name) - end_number = re.search(r'\d+$', file_no_ext) - if end_number: - new_number = int(end_number.group()) + 1 - else: - new_number = 1 - left_string = re.sub(r'\d+$', '', file_no_ext) - left_string += str(new_number) - base_name = left_string + file_ext - destination = osp.join(parent_path, base_name) - else: - destination = osp.join(parent_path, base_name) - shutil.copy(source_name, destination) - except Exception as e: - QMessageBox.critical(self, _('Error pasting file'), - _("Unsupported copy operation" - ". The error was:\n\n") - + str(e)) - else: - try: - while base_name in os.listdir(parent_path): - end_number = re.search(r'\d+$', base_name) - if end_number: - new_number = int(end_number.group()) + 1 - else: - new_number = 1 - left_string = re.sub(r'\d+$', '', base_name) - base_name = left_string + str(new_number) - destination = osp.join(parent_path, base_name) - else: - destination = osp.join(parent_path, base_name) - if osp.realpath(destination).startswith( - osp.realpath(source_name) + os.sep): - QMessageBox.critical(self, - _('Recursive copy'), - _("Source is an ancestor" - " of destination" - " folder.")) - continue - shutil.copytree(source_name, destination) - except Exception as e: - QMessageBox.critical(self, - _('Error pasting folder'), - _("Unsupported copy" - " operation. The error was:" - "\n\n") + str(e)) - else: - QMessageBox.critical(self, _("No file in clipboard"), - _("No file in the clipboard. Please copy" - " a file to the clipboard first.")) - else: - if QApplication.clipboard().mimeData().hasUrls(): - QMessageBox.critical(self, _('Blank area'), - _("Cannot paste in the blank area.")) - else: - pass - - def open_interpreter(self, fnames=None): - """Open interpreter""" - if fnames is None: - fnames = self.get_selected_filenames() - for path in sorted(fnames): - self.sig_open_interpreter_requested.emit(path) - - def filter_files(self, name_filters=None): - """Filter files given the defined list of filters.""" - if name_filters is None: - name_filters = self.get_conf('name_filters') - - if self.filter_on: - self.fsmodel.setNameFilters(name_filters) - else: - self.fsmodel.setNameFilters([]) - - # ---- File Associations - # ------------------------------------------------------------------------ - def get_common_file_associations(self, fnames): - """ - Return the list of common matching file associations for all fnames. - """ - all_values = [] - for fname in fnames: - values = self.get_file_associations(fname) - all_values.append(values) - - common = set(all_values[0]) - for index in range(1, len(all_values)): - common = common.intersection(all_values[index]) - return list(sorted(common)) - - def get_file_associations(self, fname): - """Return the list of matching file associations for `fname`.""" - for exts, values in self.get_conf('file_associations', {}).items(): - clean_exts = [ext.strip() for ext in exts.split(',')] - for ext in clean_exts: - if fname.endswith((ext, ext[1:])): - values = values - break - else: - continue # Only excecuted if the inner loop did not break - break # Only excecuted if the inner loop did break - else: - values = [] - - return values - - # ---- File/Directory actions - # ------------------------------------------------------------------------ - def check_launch_error_codes(self, return_codes): - """Check return codes and display message box if errors found.""" - errors = [cmd for cmd, code in return_codes.items() if code != 0] - if errors: - if len(errors) == 1: - msg = _('The following command did not launch successfully:') - else: - msg = _('The following commands did not launch successfully:') - - msg += '

' if len(errors) == 1 else '

    ' - for error in errors: - if len(errors) == 1: - msg += '{}'.format(error) - else: - msg += '
  • {}
  • '.format(error) - msg += '' if len(errors) == 1 else '
' - - QMessageBox.warning(self, 'Application', msg, QMessageBox.Ok) - - return not bool(errors) - - # ---- VCS actions - # ------------------------------------------------------------------------ - def vcs_command(self, action): - """VCS action (commit, browse)""" - fnames = self.get_selected_filenames() - - # Get dirname of selection - if osp.isdir(fnames[0]): - dirname = fnames[0] - else: - dirname = osp.dirname(fnames[0]) - - # Run action - try: - for path in sorted(fnames): - vcs.run_vcs_tool(dirname, action) - except vcs.ActionToolNotFound as error: - msg = _("For %s support, please install one of the
" - "following tools:

%s")\ - % (error.vcsname, ', '.join(error.tools)) - QMessageBox.critical( - self, _("Error"), - _("""Unable to find external program.

%s""" - ) % str(msg)) - - # ---- Settings - # ------------------------------------------------------------------------ - def get_scrollbar_position(self): - """Return scrollbar positions""" - return (self.horizontalScrollBar().value(), - self.verticalScrollBar().value()) - - def set_scrollbar_position(self, position): - """Set scrollbar positions""" - # Scrollbars will be restored after the expanded state - self._scrollbar_positions = position - if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: - self.restore_scrollbar_positions() - - def restore_scrollbar_positions(self): - """Restore scrollbar positions once tree is loaded""" - hor, ver = self._scrollbar_positions - self.horizontalScrollBar().setValue(hor) - self.verticalScrollBar().setValue(ver) - - def get_expanded_state(self): - """Return expanded state""" - self.save_expanded_state() - return self.__expanded_state - - def set_expanded_state(self, state): - """Set expanded state""" - self.__expanded_state = state - self.restore_expanded_state() - - def save_expanded_state(self): - """Save all items expanded state""" - model = self.model() - # If model is not installed, 'model' will be None: this happens when - # using the Project Explorer without having selected a workspace yet - if model is not None: - self.__expanded_state = [] - for idx in model.persistentIndexList(): - if self.isExpanded(idx): - self.__expanded_state.append(self.get_filename(idx)) - - def restore_directory_state(self, fname): - """Restore directory expanded state""" - root = osp.normpath(str(fname)) - if not osp.exists(root): - # Directory has been (re)moved outside Spyder - return - for basename in os.listdir(root): - path = osp.normpath(osp.join(root, basename)) - if osp.isdir(path) and path in self.__expanded_state: - self.__expanded_state.pop(self.__expanded_state.index(path)) - if self._to_be_loaded is None: - self._to_be_loaded = [] - self._to_be_loaded.append(path) - self.setExpanded(self.get_index(path), True) - if not self.__expanded_state: - self.fsmodel.directoryLoaded.disconnect( - self.restore_directory_state) - - def follow_directories_loaded(self, fname): - """Follow directories loaded during startup""" - if self._to_be_loaded is None: - return - path = osp.normpath(str(fname)) - if path in self._to_be_loaded: - self._to_be_loaded.remove(path) - if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: - self.fsmodel.directoryLoaded.disconnect( - self.follow_directories_loaded) - if self._scrollbar_positions is not None: - # The tree view need some time to render branches: - QTimer.singleShot(50, self.restore_scrollbar_positions) - - def restore_expanded_state(self): - """Restore all items expanded state""" - if self.__expanded_state is not None: - # In the old project explorer, the expanded state was a - # dictionary: - if isinstance(self.__expanded_state, list): - self.fsmodel.directoryLoaded.connect( - self.restore_directory_state) - self.fsmodel.directoryLoaded.connect( - self.follow_directories_loaded) - - # ---- Options - # ------------------------------------------------------------------------ - def set_single_click_to_open(self, value): - """Set single click to open items.""" - # Reset cursor shape - if not value: - self.unsetCursor() - - def set_file_associations(self, value): - """Set file associations open items.""" - self.set_conf('file_associations', value) - - def set_name_filters(self, name_filters): - """Set name filters""" - if self.get_conf('name_filters') == ['']: - self.set_conf('name_filters', []) - else: - self.set_conf('name_filters', name_filters) - - def set_show_hidden(self, state): - """Toggle 'show hidden files' state""" - filters = (QDir.AllDirs | QDir.Files | QDir.Drives | - QDir.NoDotAndDotDot) - if state: - filters = (QDir.AllDirs | QDir.Files | QDir.Drives | - QDir.NoDotAndDotDot | QDir.Hidden) - self.fsmodel.setFilter(filters) - - def reset_icon_provider(self): - """Reset file system model icon provider - The purpose of this is to refresh files/directories icons""" - self.fsmodel.setIconProvider(IconProvider(self)) - - def convert_notebook(self, fname): - """Convert an IPython notebook to a Python script in editor""" - try: - script = nbexporter().from_filename(fname)[0] - except Exception as e: - QMessageBox.critical( - self, _('Conversion error'), - _("It was not possible to convert this " - "notebook. The error is:\n\n") + str(e)) - return - self.sig_file_created.emit(script) - - def convert_notebooks(self): - """Convert IPython notebooks to Python scripts in editor""" - fnames = self.get_selected_filenames() - if not isinstance(fnames, (tuple, list)): - fnames = [fnames] - for fname in fnames: - self.convert_notebook(fname) - - def new_package(self, basedir=None): - """New package""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _('New package') - subtitle = _('Package name:') - self.create_new_folder(basedir, title, subtitle, is_package=True) - - def new_module(self, basedir=None): - """New module""" - - if basedir is None: - basedir = self.get_selected_dir() - - title = _("New module") - filters = _("Python files")+" (*.py *.pyw *.ipy)" - - def create_func(fname): - self.sig_module_created.emit(fname) - - self.create_new_file(basedir, title, filters, create_func) - - def go_to_parent_directory(self): - pass - - -class ExplorerTreeWidget(DirView): - """ - File/directory explorer tree widget. - """ - - sig_dir_opened = Signal(str) - """ - This signal is emitted when the current directory of the explorer tree - has changed. - - Parameters - ---------- - new_root_directory: str - The new root directory path. - - Notes - ----- - This happens when clicking (or double clicking depending on the option) - a folder, turning this folder in the new root parent of the tree. - """ - - def __init__(self, parent=None): - """Initialize the widget. - - Parameters - ---------- - parent: PluginMainWidget, optional - Parent widget of the explorer tree widget. - """ - super().__init__(parent=parent) - - # Attributes - self._parent = parent - self.__last_folder = None - self.__original_root_index = None - self.history = [] - self.histindex = None - - # Enable drag events - self.setDragEnabled(True) - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - def setup(self): - """ - Perform the setup of the widget. - """ - super().setup() - - # Actions - self.previous_action = self.create_action( - ExplorerTreeWidgetActions.Previous, - text=_("Previous"), - icon=self.create_icon('previous'), - triggered=self.go_to_previous_directory, - ) - self.next_action = self.create_action( - ExplorerTreeWidgetActions.Next, - text=_("Next"), - icon=self.create_icon('next'), - triggered=self.go_to_next_directory, - ) - self.create_action( - ExplorerTreeWidgetActions.Parent, - text=_("Parent"), - icon=self.create_icon('up'), - triggered=self.go_to_parent_directory - ) - - # Toolbuttons - self.filter_button = self.create_action( - ExplorerTreeWidgetActions.ToggleFilter, - text="", - icon=ima.icon('filter'), - toggled=self.change_filter_state - ) - self.filter_button.setCheckable(True) - - def update_actions(self): - """Update the widget actions.""" - super().update_actions() - - # ---- API - # ------------------------------------------------------------------------ - def change_filter_state(self): - """Handle the change of the filter state.""" - self.filter_on = not self.filter_on - self.filter_button.setChecked(self.filter_on) - self.filter_button.setToolTip(_("Filter filenames")) - self.filter_files() - - # ---- Refreshing widget - def set_current_folder(self, folder): - """ - Set current folder and return associated model index - - Parameters - ---------- - folder: str - New path to the selected folder. - """ - index = self.fsmodel.setRootPath(folder) - self.__last_folder = folder - self.setRootIndex(index) - return index - - def get_current_folder(self): - return self.__last_folder - - def refresh(self, new_path=None, force_current=False): - """ - Refresh widget - - Parameters - ---------- - new_path: str, optional - New path to refresh the widget. - force_current: bool, optional - If False, it won't refresh widget if path has not changed. - """ - if new_path is None: - new_path = getcwd_or_home() - if force_current: - index = self.set_current_folder(new_path) - self.expand(index) - self.setCurrentIndex(index) - - self.previous_action.setEnabled(False) - self.next_action.setEnabled(False) - - if self.histindex is not None: - self.previous_action.setEnabled(self.histindex > 0) - self.next_action.setEnabled(self.histindex < len(self.history) - 1) - - # ---- Events - def directory_clicked(self, dirname, index): - if dirname: - self.chdir(directory=dirname) - - # ---- Files/Directories Actions - @Slot() - def go_to_parent_directory(self): - """Go to parent directory""" - self.chdir(osp.abspath(osp.join(getcwd_or_home(), os.pardir))) - - @Slot() - def go_to_previous_directory(self): - """Back to previous directory""" - self.histindex -= 1 - self.chdir(browsing_history=True) - - @Slot() - def go_to_next_directory(self): - """Return to next directory""" - self.histindex += 1 - self.chdir(browsing_history=True) - - def update_history(self, directory): - """ - Update browse history. - - Parameters - ---------- - directory: str - The new working directory. - """ - try: - directory = osp.abspath(str(directory)) - if directory in self.history: - self.histindex = self.history.index(directory) - except Exception: - user_directory = get_home_dir() - self.chdir(directory=user_directory, browsing_history=True) - - def chdir(self, directory=None, browsing_history=False, emit=True): - """ - Set directory as working directory. - - Parameters - ---------- - directory: str - The new working directory. - browsing_history: bool, optional - Add the new `directory`to the browsing history. Default is False. - emit: bool, optional - Emit a signal when changing the working directpory. - Default is True. - """ - if directory is not None: - directory = osp.abspath(str(directory)) - if browsing_history: - directory = self.history[self.histindex] - elif directory in self.history: - self.histindex = self.history.index(directory) - else: - if self.histindex is None: - self.history = [] - else: - self.history = self.history[:self.histindex+1] - if len(self.history) == 0 or \ - (self.history and self.history[-1] != directory): - self.history.append(directory) - self.histindex = len(self.history)-1 - directory = str(directory) - - try: - os.chdir(directory) - self.refresh(new_path=directory, force_current=True) - if emit: - self.sig_dir_opened.emit(directory) - except PermissionError: - QMessageBox.critical(self._parent, "Error", - _("You don't have the right permissions to " - "open this directory")) - except FileNotFoundError: - # Handle renaming directories on the fly. - # See spyder-ide/spyder#5183 - self.history.pop(self.histindex) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Files and Directories Explorer""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +from __future__ import with_statement + +# Standard library imports +import os +import os.path as osp +import re +import shutil +import sys + +# Third party imports +from qtpy import PYQT5 +from qtpy.compat import getexistingdirectory, getsavefilename +from qtpy.QtCore import QDir, QMimeData, Qt, QTimer, QUrl, Signal, Slot +from qtpy.QtGui import QDrag +from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, + QFileSystemModel, QInputDialog, QLabel, QLineEdit, + QMessageBox, QProxyStyle, QStyle, QTextEdit, + QToolTip, QTreeView, QVBoxLayout) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import get_home_dir +from spyder.config.main import NAME_FILTERS +from spyder.plugins.explorer.widgets.utils import ( + create_script, fixpath, IconProvider, show_in_external_file_explorer) +from spyder.py3compat import to_binary_string +from spyder.utils import encoding +from spyder.utils.icon_manager import ima +from spyder.utils import misc, programs, vcs +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import file_uri, start_file + +try: + from nbconvert import PythonExporter as nbexporter +except: + nbexporter = None # analysis:ignore + + +# Localization +_ = get_translation('spyder') + + +# ---- Constants +# ---------------------------------------------------------------------------- +class DirViewColumns: + Size = 1 + Type = 2 + Date = 3 + + +class DirViewOpenWithSubMenuSections: + Main = 'Main' + + +class DirViewActions: + # Toggles + ToggleDateColumn = 'toggle_date_column_action' + ToggleSingleClick = 'toggle_single_click_to_open_action' + ToggleSizeColumn = 'toggle_size_column_action' + ToggleTypeColumn = 'toggle_type_column_action' + ToggleHiddenFiles = 'toggle_show_hidden_action' + + # Triggers + EditNameFilters = 'edit_name_filters_action' + NewFile = 'new_file_action' + NewModule = 'new_module_action' + NewFolder = 'new_folder_action' + NewPackage = 'new_package_action' + OpenWithSpyder = 'open_with_spyder_action' + OpenWithSystem = 'open_with_system_action' + OpenWithSystem2 = 'open_with_system_2_action' + Delete = 'delete_action' + Rename = 'rename_action' + Move = 'move_action' + Copy = 'copy_action' + Paste = 'paste_action' + CopyAbsolutePath = 'copy_absolute_path_action' + CopyRelativePath = 'copy_relative_path_action' + ShowInSystemExplorer = 'show_system_explorer_action' + VersionControlCommit = 'version_control_commit_action' + VersionControlBrowse = 'version_control_browse_action' + ConvertNotebook = 'convert_notebook_action' + + # TODO: Move this to the IPython Console + OpenInterpreter = 'open_interpreter_action' + Run = 'run_action' + + +class DirViewMenus: + Context = 'context_menu' + Header = 'header_menu' + New = 'new_menu' + OpenWith = 'open_with_menu' + + +class DirViewHeaderMenuSections: + Main = 'main_section' + + +class DirViewNewSubMenuSections: + General = 'general_section' + Language = 'language_section' + + +class DirViewContextMenuSections: + CopyPaste = 'copy_paste_section' + Extras = 'extras_section' + New = 'new_section' + System = 'system_section' + VersionControl = 'version_control_section' + + +class ExplorerTreeWidgetActions: + # Toggles + ToggleFilter = 'toggle_filter_files_action' + + # Triggers + Next = 'next_action' + Parent = 'parent_action' + Previous = 'previous_action' + + +# ---- Styles +# ---------------------------------------------------------------------------- +class DirViewStyle(QProxyStyle): + + def styleHint(self, hint, option=None, widget=None, return_data=None): + """ + To show tooltips with longer delays. + + From https://stackoverflow.com/a/59059919/438386 + """ + if hint == QStyle.SH_ToolTip_WakeUpDelay: + return 1000 # 1 sec + elif hint == QStyle.SH_ToolTip_FallAsleepDelay: + # This removes some flickering when showing tooltips + return 0 + + return super().styleHint(hint, option, widget, return_data) + + +# ---- Widgets +# ---------------------------------------------------------------------------- +class DirView(QTreeView, SpyderWidgetMixin): + """Base file/directory tree view.""" + + # Signals + sig_file_created = Signal(str) + """ + This signal is emitted when a file is created + + Parameters + ---------- + module: str + Path to the created file. + """ + + sig_open_interpreter_requested = Signal(str) + """ + This signal is emitted when the interpreter opened is requested + + Parameters + ---------- + module: str + Path to use as working directory of interpreter. + """ + + sig_module_created = Signal(str) + """ + This signal is emitted when a new python module is created. + + Parameters + ---------- + module: str + Path to the new module created. + """ + + sig_redirect_stdio_requested = Signal(bool) + """ + This signal is emitted when redirect stdio is requested. + + Parameters + ---------- + enable: bool + Enable/Disable standard input/output redirection. + """ + + sig_removed = Signal(str) + """ + This signal is emitted when a file is removed. + + Parameters + ---------- + path: str + File path removed. + """ + + sig_renamed = Signal(str, str) + """ + This signal is emitted when a file is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed file. + new_path: str + New path for renamed file. + """ + + sig_run_requested = Signal(str) + """ + This signal is emitted to request running a file. + + Parameters + ---------- + path: str + File path to run. + """ + + sig_tree_removed = Signal(str) + """ + This signal is emitted when a folder is removed. + + Parameters + ---------- + path: str + Folder to remove. + """ + + sig_tree_renamed = Signal(str, str) + """ + This signal is emitted when a folder is renamed. + + Parameters + ---------- + old_path: str + Old path for renamed folder. + new_path: str + New path for renamed folder. + """ + + sig_open_file_requested = Signal(str) + """ + This signal is emitted to request opening a new file with Spyder. + + Parameters + ---------- + path: str + File path to run. + """ + + def __init__(self, parent=None): + """Initialize the DirView. + + Parameters + ---------- + parent: QWidget + Parent QWidget of the widget. + """ + if PYQT5: + super().__init__(parent=parent, class_parent=parent) + else: + QTreeView.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + # Attributes + self._parent = parent + self._last_column = 0 + self._last_order = True + self._scrollbar_positions = None + self._to_be_loaded = None + self.__expanded_state = None + self.common_actions = None + self.filter_on = False + self.expanded_or_colapsed_by_mouse = False + + # Widgets + self.fsmodel = None + self.menu = None + self.header_menu = None + header = self.header() + + # Signals + header.customContextMenuRequested.connect(self.show_header_menu) + + # Style adjustments + self._style = DirViewStyle(None) + self._style.setParent(self) + self.setStyle(self._style) + + # Setup + self.setup_fs_model() + self.setSelectionMode(self.ExtendedSelection) + header.setContextMenuPolicy(Qt.CustomContextMenu) + + # Track mouse movements. This activates the mouseMoveEvent declared + # below. + self.setMouseTracking(True) + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + def setup(self): + self.setup_view() + + # New actions + new_file_action = self.create_action( + DirViewActions.NewFile, + text=_("File..."), + icon=self.create_icon('TextFileIcon'), + triggered=lambda: self.new_file(), + ) + + new_module_action = self.create_action( + DirViewActions.NewModule, + text=_("Python file..."), + icon=self.create_icon('python'), + triggered=lambda: self.new_module(), + ) + + new_folder_action = self.create_action( + DirViewActions.NewFolder, + text=_("Folder..."), + icon=self.create_icon('folder_new'), + triggered=lambda: self.new_folder(), + ) + + new_package_action = self.create_action( + DirViewActions.NewPackage, + text=_("Python Package..."), + icon=self.create_icon('package_new'), + triggered=lambda: self.new_package(), + ) + + # Open actions + self.open_with_spyder_action = self.create_action( + DirViewActions.OpenWithSpyder, + text=_("Open in Spyder"), + icon=self.create_icon('edit'), + triggered=lambda: self.open(), + ) + + self.open_external_action = self.create_action( + DirViewActions.OpenWithSystem, + text=_("Open externally"), + triggered=lambda: self.open_external(), + ) + + self.open_external_action_2 = self.create_action( + DirViewActions.OpenWithSystem2, + text=_("Default external application"), + triggered=lambda: self.open_external(), + register_shortcut=False, + ) + + # File management actions + delete_action = self.create_action( + DirViewActions.Delete, + text=_("Delete..."), + icon=self.create_icon('editdelete'), + triggered=lambda: self.delete(), + ) + + rename_action = self.create_action( + DirViewActions.Rename, + text=_("Rename..."), + icon=self.create_icon('rename'), + triggered=lambda: self.rename(), + ) + + self.move_action = self.create_action( + DirViewActions.Move, + text=_("Move..."), + icon=self.create_icon('move'), + triggered=lambda: self.move(), + ) + + # Copy/Paste actions + copy_action = self.create_action( + DirViewActions.Copy, + text=_("Copy"), + icon=self.create_icon('editcopy'), + triggered=lambda: self.copy_file_clipboard(), + ) + + self.paste_action = self.create_action( + DirViewActions.Paste, + text=_("Paste"), + icon=self.create_icon('editpaste'), + triggered=lambda: self.save_file_clipboard(), + ) + + copy_absolute_path_action = self.create_action( + DirViewActions.CopyAbsolutePath, + text=_("Copy Absolute Path"), + triggered=lambda: self.copy_absolute_path(), + ) + + copy_relative_path_action = self.create_action( + DirViewActions.CopyRelativePath, + text=_("Copy Relative Path"), + triggered=lambda: self.copy_relative_path(), + ) + + # Show actions + if sys.platform == 'darwin': + show_in_finder_text = _("Show in Finder") + else: + show_in_finder_text = _("Show in Folder") + + show_in_system_explorer_action = self.create_action( + DirViewActions.ShowInSystemExplorer, + text=show_in_finder_text, + triggered=lambda: self.show_in_external_file_explorer(), + ) + + # Version control actions + self.vcs_commit_action = self.create_action( + DirViewActions.VersionControlCommit, + text=_("Commit"), + icon=self.create_icon('vcs_commit'), + triggered=lambda: self.vcs_command('commit'), + ) + self.vcs_log_action = self.create_action( + DirViewActions.VersionControlBrowse, + text=_("Browse repository"), + icon=self.create_icon('vcs_browse'), + triggered=lambda: self.vcs_command('browse'), + ) + + # Common actions + self.hidden_action = self.create_action( + DirViewActions.ToggleHiddenFiles, + text=_("Show hidden files"), + toggled=True, + initial=self.get_conf('show_hidden'), + option='show_hidden' + ) + + self.filters_action = self.create_action( + DirViewActions.EditNameFilters, + text=_("Edit filter settings..."), + icon=self.create_icon('filter'), + triggered=lambda: self.edit_filter(), + ) + + self.create_action( + DirViewActions.ToggleSingleClick, + text=_("Single click to open"), + toggled=True, + initial=self.get_conf('single_click_to_open'), + option='single_click_to_open' + ) + + # IPython console actions + # TODO: Move this option to the ipython console setup + self.open_interpreter_action = self.create_action( + DirViewActions.OpenInterpreter, + text=_("Open IPython console here"), + triggered=lambda: self.open_interpreter(), + ) + + # TODO: Move this option to the ipython console setup + run_action = self.create_action( + DirViewActions.Run, + text=_("Run"), + icon=self.create_icon('run'), + triggered=lambda: self.run(), + ) + + # Notebook Actions + ipynb_convert_action = self.create_action( + DirViewActions.ConvertNotebook, + _("Convert to Python file"), + icon=ima.icon('python'), + triggered=lambda: self.convert_notebooks() + ) + + # Header Actions + size_column_action = self.create_action( + DirViewActions.ToggleSizeColumn, + text=_('Size'), + toggled=True, + initial=self.get_conf('size_column'), + register_shortcut=False, + option='size_column' + ) + type_column_action = self.create_action( + DirViewActions.ToggleTypeColumn, + text=_('Type') if sys.platform == 'darwin' else _('Type'), + toggled=True, + initial=self.get_conf('type_column'), + register_shortcut=False, + option='type_column' + ) + date_column_action = self.create_action( + DirViewActions.ToggleDateColumn, + text=_("Date modified"), + toggled=True, + initial=self.get_conf('date_column'), + register_shortcut=False, + option='date_column' + ) + + # Header Context Menu + self.header_menu = self.create_menu(DirViewMenus.Header) + for item in [size_column_action, type_column_action, + date_column_action]: + self.add_item_to_menu( + item, + menu=self.header_menu, + section=DirViewHeaderMenuSections.Main, + ) + + # New submenu + new_submenu = self.create_menu( + DirViewMenus.New, + _('New'), + ) + for item in [new_file_action, new_folder_action]: + self.add_item_to_menu( + item, + menu=new_submenu, + section=DirViewNewSubMenuSections.General, + ) + + for item in [new_module_action, new_package_action]: + self.add_item_to_menu( + item, + menu=new_submenu, + section=DirViewNewSubMenuSections.Language, + ) + + # Open with submenu + self.open_with_submenu = self.create_menu( + DirViewMenus.OpenWith, + _('Open with'), + ) + + # Context submenu + self.context_menu = self.create_menu(DirViewMenus.Context) + for item in [new_submenu, run_action, + self.open_with_spyder_action, + self.open_with_submenu, + self.open_external_action, + delete_action, rename_action, self.move_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.New, + ) + + # Copy/Paste section + for item in [copy_action, self.paste_action, copy_absolute_path_action, + copy_relative_path_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.CopyPaste, + ) + + self.add_item_to_menu( + show_in_system_explorer_action, + menu=self.context_menu, + section=DirViewContextMenuSections.System, + ) + + # Version control section + for item in [self.vcs_commit_action, self.vcs_log_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.VersionControl + ) + + for item in [self.open_interpreter_action, ipynb_convert_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=DirViewContextMenuSections.Extras, + ) + + # Signals + self.context_menu.aboutToShow.connect(self.update_actions) + + @on_conf_change(option=['size_column', 'type_column', 'date_column', + 'name_filters', 'show_hidden', + 'single_click_to_open']) + def on_conf_update(self, option, value): + if option == 'size_column': + self.setColumnHidden(DirViewColumns.Size, not value) + elif option == 'type_column': + self.setColumnHidden(DirViewColumns.Type, not value) + elif option == 'date_column': + self.setColumnHidden(DirViewColumns.Date, not value) + elif option == 'name_filters': + if self.filter_on: + self.filter_files(value) + elif option == 'show_hidden': + self.set_show_hidden(value) + elif option == 'single_click_to_open': + self.set_single_click_to_open(value) + + def update_actions(self): + fnames = self.get_selected_filenames() + if fnames: + if osp.isdir(fnames[0]): + dirname = fnames[0] + else: + dirname = osp.dirname(fnames[0]) + + basedir = fixpath(osp.dirname(fnames[0])) + only_dirs = fnames and all([osp.isdir(fname) for fname in fnames]) + only_files = all([osp.isfile(fname) for fname in fnames]) + only_valid = all([encoding.is_text_file(fna) for fna in fnames]) + else: + only_files = False + only_valid = False + only_dirs = False + dirname = '' + basedir = '' + + vcs_visible = vcs.is_vcs_repository(dirname) + + # Make actions visible conditionally + self.move_action.setVisible( + all([fixpath(osp.dirname(fname)) == basedir for fname in fnames])) + self.open_external_action.setVisible(False) + self.open_interpreter_action.setVisible(only_dirs) + self.open_with_spyder_action.setVisible(only_files and only_valid) + self.open_with_submenu.menuAction().setVisible(False) + clipboard = QApplication.clipboard() + has_urls = clipboard.mimeData().hasUrls() + self.paste_action.setDisabled(not has_urls) + + # VCS support is quite limited for now, so we are enabling the VCS + # related actions only when a single file/folder is selected: + self.vcs_commit_action.setVisible(vcs_visible) + self.vcs_log_action.setVisible(vcs_visible) + + if only_files: + if len(fnames) == 1: + assoc = self.get_file_associations(fnames[0]) + elif len(fnames) > 1: + assoc = self.get_common_file_associations(fnames) + + if len(assoc) >= 1: + actions = self._create_file_associations_actions() + self.open_with_submenu.menuAction().setVisible(True) + self.open_with_submenu.clear_actions() + for action in actions: + self.add_item_to_menu( + action, + menu=self.open_with_submenu, + section=DirViewOpenWithSubMenuSections.Main, + ) + else: + self.open_external_action.setVisible(True) + + fnames = self.get_selected_filenames() + only_notebooks = all([osp.splitext(fname)[1] == '.ipynb' + for fname in fnames]) + only_modules = all([osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy') + for fname in fnames]) + + nb_visible = only_notebooks and nbexporter is not None + self.get_action(DirViewActions.ConvertNotebook).setVisible(nb_visible) + self.get_action(DirViewActions.Run).setVisible(only_modules) + + def _create_file_associations_actions(self, fnames=None): + """ + Create file association actions. + """ + if fnames is None: + fnames = self.get_selected_filenames() + + actions = [] + only_files = all([osp.isfile(fname) for fname in fnames]) + if only_files: + if len(fnames) == 1: + assoc = self.get_file_associations(fnames[0]) + elif len(fnames) > 1: + assoc = self.get_common_file_associations(fnames) + + if len(assoc) >= 1: + for app_name, fpath in assoc: + text = app_name + if not (os.path.isfile(fpath) or os.path.isdir(fpath)): + text += _(' (Application not found!)') + + try: + # Action might have been created already + open_assoc = self.open_association + open_with_action = self.create_action( + app_name, + text=text, + triggered=lambda x, y=fpath: open_assoc(y), + register_shortcut=False, + ) + except Exception: + open_with_action = self.get_action(app_name) + + # Disconnect previous signal in case the app path + # changed + try: + open_with_action.triggered.disconnect() + except Exception: + pass + + # Reconnect the trigger signal + open_with_action.triggered.connect( + lambda x, y=fpath: self.open_association(y) + ) + + if not (os.path.isfile(fpath) or os.path.isdir(fpath)): + open_with_action.setDisabled(True) + + actions.append(open_with_action) + + actions.append(self.open_external_action_2) + + return actions + + # ---- Qt overrides + # ------------------------------------------------------------------------ + def sortByColumn(self, column, order=Qt.AscendingOrder): + """Override Qt method.""" + header = self.header() + header.setSortIndicatorShown(True) + QTreeView.sortByColumn(self, column, order) + header.setSortIndicator(0, order) + self._last_column = column + self._last_order = not self._last_order + + def viewportEvent(self, event): + """Reimplement Qt method""" + + # Prevent Qt from crashing or showing warnings like: + # "QSortFilterProxyModel: index from wrong model passed to + # mapFromSource", probably due to the fact that the file system model + # is being built. See spyder-ide/spyder#1250. + # + # This workaround was inspired by the following KDE bug: + # https://bugs.kde.org/show_bug.cgi?id=172198 + # + # Apparently, this is a bug from Qt itself. + self.executeDelayedItemsLayout() + + return QTreeView.viewportEvent(self, event) + + def contextMenuEvent(self, event): + """Override Qt method""" + # Needed to handle not initialized menu. + # See spyder-ide/spyder#6975 + try: + fnames = self.get_selected_filenames() + if len(fnames) != 0: + self.context_menu.popup(event.globalPos()) + except AttributeError: + pass + + def keyPressEvent(self, event): + """Reimplement Qt method""" + if event.key() in (Qt.Key_Enter, Qt.Key_Return): + self.clicked() + elif event.key() == Qt.Key_F2: + self.rename() + elif event.key() == Qt.Key_Delete: + self.delete() + elif event.key() == Qt.Key_Backspace: + self.go_to_parent_directory() + else: + QTreeView.keyPressEvent(self, event) + + def mouseDoubleClickEvent(self, event): + """Handle double clicks.""" + super().mouseDoubleClickEvent(event) + if not self.get_conf('single_click_to_open'): + self.clicked(index=self.indexAt(event.pos())) + + def mousePressEvent(self, event): + """ + Detect when a directory was expanded or collapsed by clicking + on its arrow. + + Taken from https://stackoverflow.com/a/13142586/438386 + """ + clicked_index = self.indexAt(event.pos()) + if clicked_index.isValid(): + vrect = self.visualRect(clicked_index) + item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() + if event.pos().x() < item_identation: + self.expanded_or_colapsed_by_mouse = True + else: + self.expanded_or_colapsed_by_mouse = False + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + """Handle single clicks.""" + super().mouseReleaseEvent(event) + if self.get_conf('single_click_to_open'): + self.clicked(index=self.indexAt(event.pos())) + + def mouseMoveEvent(self, event): + """Actions to take with mouse movements.""" + # To hide previous tooltip + QToolTip.hideText() + + index = self.indexAt(event.pos()) + if index.isValid(): + if self.get_conf('single_click_to_open'): + vrect = self.visualRect(index) + item_identation = ( + vrect.x() - self.visualRect(self.rootIndex()).x() + ) + + if event.pos().x() > item_identation: + # When hovering over directories or files + self.setCursor(Qt.PointingHandCursor) + else: + # On every other element + self.setCursor(Qt.ArrowCursor) + + self.setToolTip(self.get_filename(index)) + + super().mouseMoveEvent(event) + + def dragEnterEvent(self, event): + """Drag and Drop - Enter event""" + event.setAccepted(event.mimeData().hasFormat("text/plain")) + + def dragMoveEvent(self, event): + """Drag and Drop - Move event""" + if (event.mimeData().hasFormat("text/plain")): + event.setDropAction(Qt.MoveAction) + event.accept() + else: + event.ignore() + + def startDrag(self, dropActions): + """Reimplement Qt Method - handle drag event""" + data = QMimeData() + data.setUrls([QUrl(fname) for fname in self.get_selected_filenames()]) + drag = QDrag(self) + drag.setMimeData(data) + drag.exec_() + + # ---- Model + # ------------------------------------------------------------------------ + def setup_fs_model(self): + """Setup filesystem model""" + self.fsmodel = QFileSystemModel(self) + self.fsmodel.setNameFilterDisables(False) + + def install_model(self): + """Install filesystem model""" + self.setModel(self.fsmodel) + + def setup_view(self): + """Setup view""" + self.install_model() + self.fsmodel.directoryLoaded.connect( + lambda: self.resizeColumnToContents(0)) + self.setAnimated(False) + self.setSortingEnabled(True) + self.sortByColumn(0, Qt.AscendingOrder) + self.fsmodel.modelReset.connect(self.reset_icon_provider) + self.reset_icon_provider() + + # ---- File/Dir Helpers + # ------------------------------------------------------------------------ + def get_filename(self, index): + """Return filename associated with *index*""" + if index: + return osp.normpath(str(self.fsmodel.filePath(index))) + + def get_index(self, filename): + """Return index associated with filename""" + return self.fsmodel.index(filename) + + def get_selected_filenames(self): + """Return selected filenames""" + fnames = [] + if self.selectionMode() == self.ExtendedSelection: + if self.selectionModel() is not None: + fnames = [self.get_filename(idx) for idx in + self.selectionModel().selectedRows()] + else: + fnames = [self.get_filename(self.currentIndex())] + + return fnames + + def get_dirname(self, index): + """Return dirname associated with *index*""" + fname = self.get_filename(index) + if fname: + if osp.isdir(fname): + return fname + else: + return osp.dirname(fname) + + # ---- General actions API + # ------------------------------------------------------------------------ + def show_header_menu(self, pos): + """Display header menu.""" + self.header_menu.popup(self.mapToGlobal(pos)) + + def clicked(self, index=None): + """ + Selected item was single/double-clicked or enter/return was pressed. + """ + fnames = self.get_selected_filenames() + + # Don't do anything when clicking on the arrow next to a directory + # to expand/collapse it. If clicking on its name, use it as `fnames`. + if index and index.isValid(): + fname = self.get_filename(index) + if osp.isdir(fname): + if self.expanded_or_colapsed_by_mouse: + return + else: + fnames = [fname] + + # Open files or directories + for fname in fnames: + if osp.isdir(fname): + self.directory_clicked(fname, index) + else: + if len(fnames) == 1: + assoc = self.get_file_associations(fnames[0]) + elif len(fnames) > 1: + assoc = self.get_common_file_associations(fnames) + + if assoc: + self.open_association(assoc[0][-1]) + else: + self.open([fname]) + + def directory_clicked(self, dirname, index): + """ + Handle directories being clicked. + + Parameters + ---------- + dirname: str + Path to the clicked directory. + index: QModelIndex + Index of the directory. + """ + raise NotImplementedError('To be implemented by subclasses') + + @Slot() + def edit_filter(self): + """Edit name filters.""" + # Create Dialog + dialog = QDialog(self) + dialog.resize(500, 300) + dialog.setWindowTitle(_('Edit filter settings')) + + # Create dialog contents + description_label = QLabel( + _('Filter files by name, extension, or more using ' + 'glob' + ' patterns. Please enter the glob patterns of the files you ' + 'want to show, separated by commas.')) + description_label.setOpenExternalLinks(True) + description_label.setWordWrap(True) + filters = QTextEdit(", ".join(self.get_conf('name_filters')), + parent=self) + layout = QVBoxLayout() + layout.addWidget(description_label) + layout.addWidget(filters) + + def handle_ok(): + filter_text = filters.toPlainText() + filter_text = [f.strip() for f in str(filter_text).split(',')] + self.set_name_filters(filter_text) + dialog.accept() + + def handle_reset(): + self.set_name_filters(NAME_FILTERS) + filters.setPlainText(", ".join(self.get_conf('name_filters'))) + + # Dialog buttons + button_box = QDialogButtonBox(QDialogButtonBox.Reset | + QDialogButtonBox.Ok | + QDialogButtonBox.Cancel) + button_box.accepted.connect(handle_ok) + button_box.rejected.connect(dialog.reject) + button_box.button(QDialogButtonBox.Reset).clicked.connect(handle_reset) + layout.addWidget(button_box) + dialog.setLayout(layout) + dialog.show() + + @Slot() + def open(self, fnames=None): + """Open files with the appropriate application""" + if fnames is None: + fnames = self.get_selected_filenames() + for fname in fnames: + if osp.isfile(fname) and encoding.is_text_file(fname): + self.sig_open_file_requested.emit(fname) + else: + self.open_outside_spyder([fname]) + + @Slot() + def open_association(self, app_path): + """Open files with given application executable path.""" + if not (os.path.isdir(app_path) or os.path.isfile(app_path)): + return_codes = {app_path: 1} + app_path = None + else: + return_codes = {} + + if app_path: + fnames = self.get_selected_filenames() + return_codes = programs.open_files_with_application(app_path, + fnames) + self.check_launch_error_codes(return_codes) + + @Slot() + def open_external(self, fnames=None): + """Open files with default application""" + if fnames is None: + fnames = self.get_selected_filenames() + for fname in fnames: + self.open_outside_spyder([fname]) + + def open_outside_spyder(self, fnames): + """ + Open file outside Spyder with the appropriate application. + + If this does not work, opening unknown file in Spyder, as text file. + """ + for path in sorted(fnames): + path = file_uri(path) + ok = start_file(path) + if not ok and encoding.is_text_file(path): + self.sig_open_file_requested.emit(path) + + def remove_tree(self, dirname): + """ + Remove whole directory tree + + Reimplemented in project explorer widget + """ + while osp.exists(dirname): + try: + shutil.rmtree(dirname, onerror=misc.onerror) + except Exception as e: + # This handles a Windows problem with shutil.rmtree. + # See spyder-ide/spyder#8567. + if type(e).__name__ == "OSError": + error_path = str(e.filename) + shutil.rmtree(error_path, ignore_errors=True) + + def delete_file(self, fname, multiple, yes_to_all): + """Delete file""" + if multiple: + buttons = (QMessageBox.Yes | QMessageBox.YesToAll | + QMessageBox.No | QMessageBox.Cancel) + else: + buttons = QMessageBox.Yes | QMessageBox.No + if yes_to_all is None: + answer = QMessageBox.warning( + self, _("Delete"), + _("Do you really want to delete %s?" + ) % osp.basename(fname), buttons) + if answer == QMessageBox.No: + return yes_to_all + elif answer == QMessageBox.Cancel: + return False + elif answer == QMessageBox.YesToAll: + yes_to_all = True + try: + if osp.isfile(fname): + misc.remove_file(fname) + self.sig_removed.emit(fname) + else: + self.remove_tree(fname) + self.sig_tree_removed.emit(fname) + return yes_to_all + except EnvironmentError as error: + action_str = _('delete') + QMessageBox.critical( + self, _("Project Explorer"), + _("Unable to %s %s

Error message:
%s" + ) % (action_str, fname, str(error))) + return False + + @Slot() + def delete(self, fnames=None): + """Delete files""" + if fnames is None: + fnames = self.get_selected_filenames() + multiple = len(fnames) > 1 + yes_to_all = None + for fname in fnames: + spyproject_path = osp.join(fname, '.spyproject') + if osp.isdir(fname) and osp.exists(spyproject_path): + QMessageBox.information( + self, _('File Explorer'), + _("The current directory contains a project.

" + "If you want to delete the project, please go to " + "Projects » Delete Project")) + else: + yes_to_all = self.delete_file(fname, multiple, yes_to_all) + if yes_to_all is not None and not yes_to_all: + # Canceled + break + + def rename_file(self, fname): + """Rename file""" + path, valid = QInputDialog.getText( + self, _('Rename'), _('New name:'), QLineEdit.Normal, + osp.basename(fname)) + + if valid: + path = osp.join(osp.dirname(fname), str(path)) + if path == fname: + return + if osp.exists(path): + answer = QMessageBox.warning( + self, _("Rename"), + _("Do you really want to rename %s and " + "overwrite the existing file %s?" + ) % (osp.basename(fname), osp.basename(path)), + QMessageBox.Yes | QMessageBox.No) + if answer == QMessageBox.No: + return + try: + misc.rename_file(fname, path) + if osp.isfile(path): + self.sig_renamed.emit(fname, path) + else: + self.sig_tree_renamed.emit(fname, path) + return path + except EnvironmentError as error: + QMessageBox.critical( + self, _("Rename"), + _("Unable to rename file %s" + "

Error message:
%s" + ) % (osp.basename(fname), str(error))) + + @Slot() + def show_in_external_file_explorer(self, fnames=None): + """Show file in external file explorer""" + if fnames is None: + fnames = self.get_selected_filenames() + show_in_external_file_explorer(fnames) + + @Slot() + def rename(self, fnames=None): + """Rename files""" + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + for fname in fnames: + self.rename_file(fname) + + @Slot() + def move(self, fnames=None, directory=None): + """Move files/directories""" + if fnames is None: + fnames = self.get_selected_filenames() + orig = fixpath(osp.dirname(fnames[0])) + while True: + self.sig_redirect_stdio_requested.emit(False) + if directory is None: + folder = getexistingdirectory( + self, _("Select directory"), orig) + else: + folder = directory + self.sig_redirect_stdio_requested.emit(True) + if folder: + folder = fixpath(folder) + if folder != orig: + break + else: + return + for fname in fnames: + basename = osp.basename(fname) + try: + misc.move_file(fname, osp.join(folder, basename)) + except EnvironmentError as error: + QMessageBox.critical( + self, _("Error"), + _("Unable to move %s" + "

Error message:
%s" + ) % (basename, str(error))) + + def create_new_folder(self, current_path, title, subtitle, is_package): + """Create new folder""" + if current_path is None: + current_path = '' + if osp.isfile(current_path): + current_path = osp.dirname(current_path) + name, valid = QInputDialog.getText(self, title, subtitle, + QLineEdit.Normal, "") + if valid: + dirname = osp.join(current_path, str(name)) + try: + os.mkdir(dirname) + except EnvironmentError as error: + QMessageBox.critical( + self, title, + _("Unable to create folder %s" + "

Error message:
%s" + ) % (dirname, str(error))) + finally: + if is_package: + fname = osp.join(dirname, '__init__.py') + try: + with open(fname, 'wb') as f: + f.write(to_binary_string('#')) + return dirname + except EnvironmentError as error: + QMessageBox.critical( + self, title, + _("Unable to create file %s" + "

Error message:
%s" + ) % (fname, str(error))) + + def get_selected_dir(self): + """ Get selected dir + If file is selected the directory containing file is returned. + If multiple items are selected, first item is chosen. + """ + selected_path = self.get_selected_filenames()[0] + if osp.isfile(selected_path): + selected_path = osp.dirname(selected_path) + return fixpath(selected_path) + + def new_folder(self, basedir=None): + """New folder.""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _('New folder') + subtitle = _('Folder name:') + self.create_new_folder(basedir, title, subtitle, is_package=False) + + def create_new_file(self, current_path, title, filters, create_func): + """Create new file + Returns True if successful""" + if current_path is None: + current_path = '' + if osp.isfile(current_path): + current_path = osp.dirname(current_path) + self.sig_redirect_stdio_requested.emit(False) + fname, _selfilter = getsavefilename(self, title, current_path, filters) + self.sig_redirect_stdio_requested.emit(True) + if fname: + try: + create_func(fname) + return fname + except EnvironmentError as error: + QMessageBox.critical( + self, _("New file"), + _("Unable to create file %s" + "

Error message:
%s" + ) % (fname, str(error))) + + def new_file(self, basedir=None): + """New file""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _("New file") + filters = _("All files")+" (*)" + + def create_func(fname): + """File creation callback""" + if osp.splitext(fname)[1] in ('.py', '.pyw', '.ipy'): + create_script(fname) + else: + with open(fname, 'wb') as f: + f.write(to_binary_string('')) + fname = self.create_new_file(basedir, title, filters, create_func) + if fname is not None: + self.open([fname]) + + @Slot() + def run(self, fnames=None): + """Run Python scripts""" + if fnames is None: + fnames = self.get_selected_filenames() + for fname in fnames: + self.sig_run_requested.emit(fname) + + def copy_path(self, fnames=None, method="absolute"): + """Copy absolute or relative path to given file(s)/folders(s).""" + cb = QApplication.clipboard() + explorer_dir = self.fsmodel.rootPath() + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + fnames = [_fn.replace(os.sep, "/") for _fn in fnames] + if len(fnames) > 1: + if method == "absolute": + clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in fnames) + elif method == "relative": + clipboard_files = ',\n'.join('"' + + osp.relpath(_fn, explorer_dir). + replace(os.sep, "/") + '"' + for _fn in fnames) + else: + if method == "absolute": + clipboard_files = fnames[0] + elif method == "relative": + clipboard_files = (osp.relpath(fnames[0], explorer_dir). + replace(os.sep, "/")) + copied_from = self._parent.__class__.__name__ + if copied_from == 'ProjectExplorerWidget' and method == 'relative': + clipboard_files = [path.strip(',"') for path in + clipboard_files.splitlines()] + clipboard_files = ['/'.join(path.strip('/').split('/')[1:]) for + path in clipboard_files] + if len(clipboard_files) > 1: + clipboard_files = ',\n'.join('"' + _fn + '"' for _fn in + clipboard_files) + else: + clipboard_files = clipboard_files[0] + cb.setText(clipboard_files, mode=cb.Clipboard) + + @Slot() + def copy_absolute_path(self): + """Copy absolute paths of named files/directories to the clipboard.""" + self.copy_path(method="absolute") + + @Slot() + def copy_relative_path(self): + """Copy relative paths of named files/directories to the clipboard.""" + self.copy_path(method="relative") + + @Slot() + def copy_file_clipboard(self, fnames=None): + """Copy file(s)/folders(s) to clipboard.""" + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + try: + file_content = QMimeData() + file_content.setUrls([QUrl.fromLocalFile(_fn) for _fn in fnames]) + cb = QApplication.clipboard() + cb.setMimeData(file_content, mode=cb.Clipboard) + except Exception as e: + QMessageBox.critical( + self, _('File/Folder copy error'), + _("Cannot copy this type of file(s) or " + "folder(s). The error was:\n\n") + str(e)) + + @Slot() + def save_file_clipboard(self, fnames=None): + """Paste file from clipboard into file/project explorer directory.""" + if fnames is None: + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + if len(fnames) >= 1: + try: + selected_item = osp.commonpath(fnames) + except AttributeError: + # py2 does not have commonpath + if len(fnames) > 1: + selected_item = osp.normpath( + osp.dirname(osp.commonprefix(fnames))) + else: + selected_item = fnames[0] + if osp.isfile(selected_item): + parent_path = osp.dirname(selected_item) + else: + parent_path = osp.normpath(selected_item) + cb_data = QApplication.clipboard().mimeData() + if cb_data.hasUrls(): + urls = cb_data.urls() + for url in urls: + source_name = url.toLocalFile() + base_name = osp.basename(source_name) + if osp.isfile(source_name): + try: + while base_name in os.listdir(parent_path): + file_no_ext, file_ext = osp.splitext(base_name) + end_number = re.search(r'\d+$', file_no_ext) + if end_number: + new_number = int(end_number.group()) + 1 + else: + new_number = 1 + left_string = re.sub(r'\d+$', '', file_no_ext) + left_string += str(new_number) + base_name = left_string + file_ext + destination = osp.join(parent_path, base_name) + else: + destination = osp.join(parent_path, base_name) + shutil.copy(source_name, destination) + except Exception as e: + QMessageBox.critical(self, _('Error pasting file'), + _("Unsupported copy operation" + ". The error was:\n\n") + + str(e)) + else: + try: + while base_name in os.listdir(parent_path): + end_number = re.search(r'\d+$', base_name) + if end_number: + new_number = int(end_number.group()) + 1 + else: + new_number = 1 + left_string = re.sub(r'\d+$', '', base_name) + base_name = left_string + str(new_number) + destination = osp.join(parent_path, base_name) + else: + destination = osp.join(parent_path, base_name) + if osp.realpath(destination).startswith( + osp.realpath(source_name) + os.sep): + QMessageBox.critical(self, + _('Recursive copy'), + _("Source is an ancestor" + " of destination" + " folder.")) + continue + shutil.copytree(source_name, destination) + except Exception as e: + QMessageBox.critical(self, + _('Error pasting folder'), + _("Unsupported copy" + " operation. The error was:" + "\n\n") + str(e)) + else: + QMessageBox.critical(self, _("No file in clipboard"), + _("No file in the clipboard. Please copy" + " a file to the clipboard first.")) + else: + if QApplication.clipboard().mimeData().hasUrls(): + QMessageBox.critical(self, _('Blank area'), + _("Cannot paste in the blank area.")) + else: + pass + + def open_interpreter(self, fnames=None): + """Open interpreter""" + if fnames is None: + fnames = self.get_selected_filenames() + for path in sorted(fnames): + self.sig_open_interpreter_requested.emit(path) + + def filter_files(self, name_filters=None): + """Filter files given the defined list of filters.""" + if name_filters is None: + name_filters = self.get_conf('name_filters') + + if self.filter_on: + self.fsmodel.setNameFilters(name_filters) + else: + self.fsmodel.setNameFilters([]) + + # ---- File Associations + # ------------------------------------------------------------------------ + def get_common_file_associations(self, fnames): + """ + Return the list of common matching file associations for all fnames. + """ + all_values = [] + for fname in fnames: + values = self.get_file_associations(fname) + all_values.append(values) + + common = set(all_values[0]) + for index in range(1, len(all_values)): + common = common.intersection(all_values[index]) + return list(sorted(common)) + + def get_file_associations(self, fname): + """Return the list of matching file associations for `fname`.""" + for exts, values in self.get_conf('file_associations', {}).items(): + clean_exts = [ext.strip() for ext in exts.split(',')] + for ext in clean_exts: + if fname.endswith((ext, ext[1:])): + values = values + break + else: + continue # Only excecuted if the inner loop did not break + break # Only excecuted if the inner loop did break + else: + values = [] + + return values + + # ---- File/Directory actions + # ------------------------------------------------------------------------ + def check_launch_error_codes(self, return_codes): + """Check return codes and display message box if errors found.""" + errors = [cmd for cmd, code in return_codes.items() if code != 0] + if errors: + if len(errors) == 1: + msg = _('The following command did not launch successfully:') + else: + msg = _('The following commands did not launch successfully:') + + msg += '

' if len(errors) == 1 else '

    ' + for error in errors: + if len(errors) == 1: + msg += '{}'.format(error) + else: + msg += '
  • {}
  • '.format(error) + msg += '' if len(errors) == 1 else '
' + + QMessageBox.warning(self, 'Application', msg, QMessageBox.Ok) + + return not bool(errors) + + # ---- VCS actions + # ------------------------------------------------------------------------ + def vcs_command(self, action): + """VCS action (commit, browse)""" + fnames = self.get_selected_filenames() + + # Get dirname of selection + if osp.isdir(fnames[0]): + dirname = fnames[0] + else: + dirname = osp.dirname(fnames[0]) + + # Run action + try: + for path in sorted(fnames): + vcs.run_vcs_tool(dirname, action) + except vcs.ActionToolNotFound as error: + msg = _("For %s support, please install one of the
" + "following tools:

%s")\ + % (error.vcsname, ', '.join(error.tools)) + QMessageBox.critical( + self, _("Error"), + _("""Unable to find external program.

%s""" + ) % str(msg)) + + # ---- Settings + # ------------------------------------------------------------------------ + def get_scrollbar_position(self): + """Return scrollbar positions""" + return (self.horizontalScrollBar().value(), + self.verticalScrollBar().value()) + + def set_scrollbar_position(self, position): + """Set scrollbar positions""" + # Scrollbars will be restored after the expanded state + self._scrollbar_positions = position + if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: + self.restore_scrollbar_positions() + + def restore_scrollbar_positions(self): + """Restore scrollbar positions once tree is loaded""" + hor, ver = self._scrollbar_positions + self.horizontalScrollBar().setValue(hor) + self.verticalScrollBar().setValue(ver) + + def get_expanded_state(self): + """Return expanded state""" + self.save_expanded_state() + return self.__expanded_state + + def set_expanded_state(self, state): + """Set expanded state""" + self.__expanded_state = state + self.restore_expanded_state() + + def save_expanded_state(self): + """Save all items expanded state""" + model = self.model() + # If model is not installed, 'model' will be None: this happens when + # using the Project Explorer without having selected a workspace yet + if model is not None: + self.__expanded_state = [] + for idx in model.persistentIndexList(): + if self.isExpanded(idx): + self.__expanded_state.append(self.get_filename(idx)) + + def restore_directory_state(self, fname): + """Restore directory expanded state""" + root = osp.normpath(str(fname)) + if not osp.exists(root): + # Directory has been (re)moved outside Spyder + return + for basename in os.listdir(root): + path = osp.normpath(osp.join(root, basename)) + if osp.isdir(path) and path in self.__expanded_state: + self.__expanded_state.pop(self.__expanded_state.index(path)) + if self._to_be_loaded is None: + self._to_be_loaded = [] + self._to_be_loaded.append(path) + self.setExpanded(self.get_index(path), True) + if not self.__expanded_state: + self.fsmodel.directoryLoaded.disconnect( + self.restore_directory_state) + + def follow_directories_loaded(self, fname): + """Follow directories loaded during startup""" + if self._to_be_loaded is None: + return + path = osp.normpath(str(fname)) + if path in self._to_be_loaded: + self._to_be_loaded.remove(path) + if self._to_be_loaded is not None and len(self._to_be_loaded) == 0: + self.fsmodel.directoryLoaded.disconnect( + self.follow_directories_loaded) + if self._scrollbar_positions is not None: + # The tree view need some time to render branches: + QTimer.singleShot(50, self.restore_scrollbar_positions) + + def restore_expanded_state(self): + """Restore all items expanded state""" + if self.__expanded_state is not None: + # In the old project explorer, the expanded state was a + # dictionary: + if isinstance(self.__expanded_state, list): + self.fsmodel.directoryLoaded.connect( + self.restore_directory_state) + self.fsmodel.directoryLoaded.connect( + self.follow_directories_loaded) + + # ---- Options + # ------------------------------------------------------------------------ + def set_single_click_to_open(self, value): + """Set single click to open items.""" + # Reset cursor shape + if not value: + self.unsetCursor() + + def set_file_associations(self, value): + """Set file associations open items.""" + self.set_conf('file_associations', value) + + def set_name_filters(self, name_filters): + """Set name filters""" + if self.get_conf('name_filters') == ['']: + self.set_conf('name_filters', []) + else: + self.set_conf('name_filters', name_filters) + + def set_show_hidden(self, state): + """Toggle 'show hidden files' state""" + filters = (QDir.AllDirs | QDir.Files | QDir.Drives | + QDir.NoDotAndDotDot) + if state: + filters = (QDir.AllDirs | QDir.Files | QDir.Drives | + QDir.NoDotAndDotDot | QDir.Hidden) + self.fsmodel.setFilter(filters) + + def reset_icon_provider(self): + """Reset file system model icon provider + The purpose of this is to refresh files/directories icons""" + self.fsmodel.setIconProvider(IconProvider(self)) + + def convert_notebook(self, fname): + """Convert an IPython notebook to a Python script in editor""" + try: + script = nbexporter().from_filename(fname)[0] + except Exception as e: + QMessageBox.critical( + self, _('Conversion error'), + _("It was not possible to convert this " + "notebook. The error is:\n\n") + str(e)) + return + self.sig_file_created.emit(script) + + def convert_notebooks(self): + """Convert IPython notebooks to Python scripts in editor""" + fnames = self.get_selected_filenames() + if not isinstance(fnames, (tuple, list)): + fnames = [fnames] + for fname in fnames: + self.convert_notebook(fname) + + def new_package(self, basedir=None): + """New package""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _('New package') + subtitle = _('Package name:') + self.create_new_folder(basedir, title, subtitle, is_package=True) + + def new_module(self, basedir=None): + """New module""" + + if basedir is None: + basedir = self.get_selected_dir() + + title = _("New module") + filters = _("Python files")+" (*.py *.pyw *.ipy)" + + def create_func(fname): + self.sig_module_created.emit(fname) + + self.create_new_file(basedir, title, filters, create_func) + + def go_to_parent_directory(self): + pass + + +class ExplorerTreeWidget(DirView): + """ + File/directory explorer tree widget. + """ + + sig_dir_opened = Signal(str) + """ + This signal is emitted when the current directory of the explorer tree + has changed. + + Parameters + ---------- + new_root_directory: str + The new root directory path. + + Notes + ----- + This happens when clicking (or double clicking depending on the option) + a folder, turning this folder in the new root parent of the tree. + """ + + def __init__(self, parent=None): + """Initialize the widget. + + Parameters + ---------- + parent: PluginMainWidget, optional + Parent widget of the explorer tree widget. + """ + super().__init__(parent=parent) + + # Attributes + self._parent = parent + self.__last_folder = None + self.__original_root_index = None + self.history = [] + self.histindex = None + + # Enable drag events + self.setDragEnabled(True) + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + def setup(self): + """ + Perform the setup of the widget. + """ + super().setup() + + # Actions + self.previous_action = self.create_action( + ExplorerTreeWidgetActions.Previous, + text=_("Previous"), + icon=self.create_icon('previous'), + triggered=self.go_to_previous_directory, + ) + self.next_action = self.create_action( + ExplorerTreeWidgetActions.Next, + text=_("Next"), + icon=self.create_icon('next'), + triggered=self.go_to_next_directory, + ) + self.create_action( + ExplorerTreeWidgetActions.Parent, + text=_("Parent"), + icon=self.create_icon('up'), + triggered=self.go_to_parent_directory + ) + + # Toolbuttons + self.filter_button = self.create_action( + ExplorerTreeWidgetActions.ToggleFilter, + text="", + icon=ima.icon('filter'), + toggled=self.change_filter_state + ) + self.filter_button.setCheckable(True) + + def update_actions(self): + """Update the widget actions.""" + super().update_actions() + + # ---- API + # ------------------------------------------------------------------------ + def change_filter_state(self): + """Handle the change of the filter state.""" + self.filter_on = not self.filter_on + self.filter_button.setChecked(self.filter_on) + self.filter_button.setToolTip(_("Filter filenames")) + self.filter_files() + + # ---- Refreshing widget + def set_current_folder(self, folder): + """ + Set current folder and return associated model index + + Parameters + ---------- + folder: str + New path to the selected folder. + """ + index = self.fsmodel.setRootPath(folder) + self.__last_folder = folder + self.setRootIndex(index) + return index + + def get_current_folder(self): + return self.__last_folder + + def refresh(self, new_path=None, force_current=False): + """ + Refresh widget + + Parameters + ---------- + new_path: str, optional + New path to refresh the widget. + force_current: bool, optional + If False, it won't refresh widget if path has not changed. + """ + if new_path is None: + new_path = getcwd_or_home() + if force_current: + index = self.set_current_folder(new_path) + self.expand(index) + self.setCurrentIndex(index) + + self.previous_action.setEnabled(False) + self.next_action.setEnabled(False) + + if self.histindex is not None: + self.previous_action.setEnabled(self.histindex > 0) + self.next_action.setEnabled(self.histindex < len(self.history) - 1) + + # ---- Events + def directory_clicked(self, dirname, index): + if dirname: + self.chdir(directory=dirname) + + # ---- Files/Directories Actions + @Slot() + def go_to_parent_directory(self): + """Go to parent directory""" + self.chdir(osp.abspath(osp.join(getcwd_or_home(), os.pardir))) + + @Slot() + def go_to_previous_directory(self): + """Back to previous directory""" + self.histindex -= 1 + self.chdir(browsing_history=True) + + @Slot() + def go_to_next_directory(self): + """Return to next directory""" + self.histindex += 1 + self.chdir(browsing_history=True) + + def update_history(self, directory): + """ + Update browse history. + + Parameters + ---------- + directory: str + The new working directory. + """ + try: + directory = osp.abspath(str(directory)) + if directory in self.history: + self.histindex = self.history.index(directory) + except Exception: + user_directory = get_home_dir() + self.chdir(directory=user_directory, browsing_history=True) + + def chdir(self, directory=None, browsing_history=False, emit=True): + """ + Set directory as working directory. + + Parameters + ---------- + directory: str + The new working directory. + browsing_history: bool, optional + Add the new `directory`to the browsing history. Default is False. + emit: bool, optional + Emit a signal when changing the working directpory. + Default is True. + """ + if directory is not None: + directory = osp.abspath(str(directory)) + if browsing_history: + directory = self.history[self.histindex] + elif directory in self.history: + self.histindex = self.history.index(directory) + else: + if self.histindex is None: + self.history = [] + else: + self.history = self.history[:self.histindex+1] + if len(self.history) == 0 or \ + (self.history and self.history[-1] != directory): + self.history.append(directory) + self.histindex = len(self.history)-1 + directory = str(directory) + + try: + os.chdir(directory) + self.refresh(new_path=directory, force_current=True) + if emit: + self.sig_dir_opened.emit(directory) + except PermissionError: + QMessageBox.critical(self._parent, "Error", + _("You don't have the right permissions to " + "open this directory")) + except FileNotFoundError: + # Handle renaming directories on the fly. + # See spyder-ide/spyder#5183 + self.history.pop(self.histindex) diff --git a/spyder/plugins/findinfiles/api.py b/spyder/plugins/findinfiles/api.py index 3de4062ed07..c913b183ab7 100644 --- a/spyder/plugins/findinfiles/api.py +++ b/spyder/plugins/findinfiles/api.py @@ -1,14 +1,14 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Find in files widget API. -""" - -# Local imports -from spyder.plugins.findinfiles.plugin import FindInFilesActions # noqa -from spyder.plugins.findinfiles.widgets.main_widget import ( # noqa - FindInFilesWidgetActions) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Find in files widget API. +""" + +# Local imports +from spyder.plugins.findinfiles.plugin import FindInFilesActions # noqa +from spyder.plugins.findinfiles.widgets.main_widget import ( # noqa + FindInFilesWidgetActions) diff --git a/spyder/plugins/findinfiles/plugin.py b/spyder/plugins/findinfiles/plugin.py index 95ef03a47db..a362a4d223e 100644 --- a/spyder/plugins/findinfiles/plugin.py +++ b/spyder/plugins/findinfiles/plugin.py @@ -1,213 +1,213 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -""" -Find in Files Plugin. -""" - -# Third party imports -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QApplication - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.findinfiles.widgets.main_widget import FindInFilesWidget -from spyder.plugins.mainmenu.api import ApplicationMenus -from spyder.utils.misc import getcwd_or_home - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -class FindInFilesActions: - FindInFiles = 'find in files' - - -# --- Plugin -# ---------------------------------------------------------------------------- -class FindInFiles(SpyderDockablePlugin): - """ - Find in files DockWidget. - """ - NAME = 'find_in_files' - REQUIRES = [] - OPTIONAL = [Plugins.Editor, Plugins.Projects, Plugins.MainMenu] - TABIFY = [Plugins.VariableExplorer] - WIDGET_CLASS = FindInFilesWidget - CONF_SECTION = NAME - CONF_FILE = False - RAISE_AND_FOCUS = True - - # --- SpyderDocakblePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Find") - - def get_description(self): - return _("Search for strings of text in files.") - - def get_icon(self): - return self.create_icon('findf') - - def on_initialize(self): - self.create_action( - FindInFilesActions.FindInFiles, - text=_("Find in files"), - tip=_("Search text in multiple files"), - triggered=self.find, - register_shortcut=True, - context=Qt.WindowShortcut - ) - self.refresh_search_directory() - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - widget.sig_edit_goto_requested.connect( - lambda filename, lineno, search_text, colno, colend: editor.load( - filename, lineno, start_column=colno, end_column=colend)) - editor.sig_file_opened_closed_or_updated.connect( - self.set_current_opened_file) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.connect(self.set_project_path) - projects.sig_project_closed.connect(self.unset_project_path) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - findinfiles_action = self.get_action(FindInFilesActions.FindInFiles) - - mainmenu.add_item_to_application_menu( - findinfiles_action, - menu_id=ApplicationMenus.Search, - ) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - widget.sig_edit_goto_requested.disconnect() - editor.sig_file_opened_closed_or_updated.disconnect( - self.set_current_opened_file) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardon_plugin_teardown(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self.set_project_path) - projects.sig_project_closed.disconnect(self.unset_project_path) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - - mainmenu.remove_item_from_application_menu( - FindInFilesActions.FindInFiles, - menu_id=ApplicationMenus.Search, - ) - - def on_close(self, cancelable=False): - self.get_widget()._update_options() - if self.get_widget().running: - self.get_widget()._stop_and_reset_thread(ignore_results=True) - return True - - # --- Public API - # ------------------------------------------------------------------------ - def refresh_search_directory(self): - """ - Refresh search directory. - """ - self.get_widget().set_directory(getcwd_or_home()) - - def set_current_opened_file(self, path, _language): - """ - Set path of current opened file in editor. - - Parameters - ---------- - path: str - Path of editor file. - """ - self.get_widget().set_file_path(path) - - def set_project_path(self, path): - """ - Set and refresh current project path. - - Parameters - ---------- - path: str - Opened project path. - """ - self.get_widget().set_project_path(path) - - def set_max_results(self, value=None): - """ - Set maximum amount of results to add to the result browser. - - Parameters - ---------- - value: int, optional - Number of results. If None an input dialog will be used. - Default is None. - """ - self.get_widget().set_max_results(value) - - def unset_project_path(self): - """ - Unset current project path. - """ - self.get_widget().disable_project_search() - - def find(self): - """ - Search text in multiple files. - - Notes - ----- - Find in files using the currently selected text of the focused widget. - """ - focus_widget = QApplication.focusWidget() - text = '' - try: - if focus_widget.has_selected_text(): - text = focus_widget.get_selected_text() - except AttributeError: - # This is not a text widget deriving from TextEditBaseWidget - pass - - self.switch_to_plugin() - widget = self.get_widget() - - if text: - widget.set_search_text(text) - - widget.find() - - -def test(): - import sys - - from spyder.config.manager import CONF - from spyder.utils.qthelpers import qapplication - - app = qapplication() - widget = FindInFiles(None, CONF) - widget.show() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +""" +Find in Files Plugin. +""" + +# Third party imports +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.findinfiles.widgets.main_widget import FindInFilesWidget +from spyder.plugins.mainmenu.api import ApplicationMenus +from spyder.utils.misc import getcwd_or_home + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +class FindInFilesActions: + FindInFiles = 'find in files' + + +# --- Plugin +# ---------------------------------------------------------------------------- +class FindInFiles(SpyderDockablePlugin): + """ + Find in files DockWidget. + """ + NAME = 'find_in_files' + REQUIRES = [] + OPTIONAL = [Plugins.Editor, Plugins.Projects, Plugins.MainMenu] + TABIFY = [Plugins.VariableExplorer] + WIDGET_CLASS = FindInFilesWidget + CONF_SECTION = NAME + CONF_FILE = False + RAISE_AND_FOCUS = True + + # --- SpyderDocakblePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Find") + + def get_description(self): + return _("Search for strings of text in files.") + + def get_icon(self): + return self.create_icon('findf') + + def on_initialize(self): + self.create_action( + FindInFilesActions.FindInFiles, + text=_("Find in files"), + tip=_("Search text in multiple files"), + triggered=self.find, + register_shortcut=True, + context=Qt.WindowShortcut + ) + self.refresh_search_directory() + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + widget.sig_edit_goto_requested.connect( + lambda filename, lineno, search_text, colno, colend: editor.load( + filename, lineno, start_column=colno, end_column=colend)) + editor.sig_file_opened_closed_or_updated.connect( + self.set_current_opened_file) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self.set_project_path) + projects.sig_project_closed.connect(self.unset_project_path) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + findinfiles_action = self.get_action(FindInFilesActions.FindInFiles) + + mainmenu.add_item_to_application_menu( + findinfiles_action, + menu_id=ApplicationMenus.Search, + ) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + widget.sig_edit_goto_requested.disconnect() + editor.sig_file_opened_closed_or_updated.disconnect( + self.set_current_opened_file) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardon_plugin_teardown(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self.set_project_path) + projects.sig_project_closed.disconnect(self.unset_project_path) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + + mainmenu.remove_item_from_application_menu( + FindInFilesActions.FindInFiles, + menu_id=ApplicationMenus.Search, + ) + + def on_close(self, cancelable=False): + self.get_widget()._update_options() + if self.get_widget().running: + self.get_widget()._stop_and_reset_thread(ignore_results=True) + return True + + # --- Public API + # ------------------------------------------------------------------------ + def refresh_search_directory(self): + """ + Refresh search directory. + """ + self.get_widget().set_directory(getcwd_or_home()) + + def set_current_opened_file(self, path, _language): + """ + Set path of current opened file in editor. + + Parameters + ---------- + path: str + Path of editor file. + """ + self.get_widget().set_file_path(path) + + def set_project_path(self, path): + """ + Set and refresh current project path. + + Parameters + ---------- + path: str + Opened project path. + """ + self.get_widget().set_project_path(path) + + def set_max_results(self, value=None): + """ + Set maximum amount of results to add to the result browser. + + Parameters + ---------- + value: int, optional + Number of results. If None an input dialog will be used. + Default is None. + """ + self.get_widget().set_max_results(value) + + def unset_project_path(self): + """ + Unset current project path. + """ + self.get_widget().disable_project_search() + + def find(self): + """ + Search text in multiple files. + + Notes + ----- + Find in files using the currently selected text of the focused widget. + """ + focus_widget = QApplication.focusWidget() + text = '' + try: + if focus_widget.has_selected_text(): + text = focus_widget.get_selected_text() + except AttributeError: + # This is not a text widget deriving from TextEditBaseWidget + pass + + self.switch_to_plugin() + widget = self.get_widget() + + if text: + widget.set_search_text(text) + + widget.find() + + +def test(): + import sys + + from spyder.config.manager import CONF + from spyder.utils.qthelpers import qapplication + + app = qapplication() + widget = FindInFiles(None, CONF) + widget.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/findinfiles/widgets/results_browser.py b/spyder/plugins/findinfiles/widgets/results_browser.py index d53f3fccdc5..c9a4080a90c 100644 --- a/spyder/plugins/findinfiles/widgets/results_browser.py +++ b/spyder/plugins/findinfiles/widgets/results_browser.py @@ -1,337 +1,337 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Results browser.""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import QPoint, QSize, Qt, Signal, Slot -from qtpy.QtGui import (QAbstractTextDocumentLayout, QColor, QBrush, - QFontMetrics, QPalette, QTextDocument) -from qtpy.QtWidgets import (QApplication, QStyle, QStyledItemDelegate, - QStyleOptionViewItem, QTreeWidgetItem) - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.gui import get_font -from spyder.plugins.findinfiles.widgets.search_thread import ( - ELLIPSIS, MAX_RESULT_LENGTH) -from spyder.utils import icon_manager as ima -from spyder.utils.palette import QStylePalette -from spyder.widgets.onecolumntree import OneColumnTree - -# Localization -_ = get_translation('spyder') - - -# ---- Constants -# ---------------------------------------------------------------------------- -ON = 'on' -OFF = 'off' - - -# ---- Items -# ---------------------------------------------------------------------------- -class LineMatchItem(QTreeWidgetItem): - - def __init__(self, parent, lineno, colno, match, font, text_color): - self.lineno = lineno - self.colno = colno - self.match = match['formatted_text'] - self.plain_match = match['text'] - self.text_color = text_color - self.font = font - super().__init__(parent, [self.__repr__()], QTreeWidgetItem.Type) - - def __repr__(self): - match = str(self.match).rstrip() - _str = ( - f"" - f"

" - f'  ' - f"{self.lineno} ({self.colno}): " - f"{match}

" - ) - return _str - - def __unicode__(self): - return self.__repr__() - - def __str__(self): - return self.__repr__() - - def __lt__(self, x): - return self.lineno < x.lineno - - def __ge__(self, x): - return self.lineno >= x.lineno - - -class FileMatchItem(QTreeWidgetItem): - - def __init__(self, parent, path, filename, sorting, text_color): - - self.sorting = sorting - self.filename = osp.basename(filename) - - # Get relative dirname according to the path we're searching in. - dirname = osp.dirname(filename) - rel_dirname = dirname.split(path)[1] - if rel_dirname.startswith(osp.sep): - rel_dirname = rel_dirname[1:] - self.rel_dirname = rel_dirname - - title = ( - f'' - f'{osp.basename(filename)}' - f'   ' - f'' - f'{self.rel_dirname}' - f'' - ) - - super().__init__(parent, [title], QTreeWidgetItem.Type) - - self.setIcon(0, ima.get_icon_by_extension_or_type(filename, 1.0)) - self.setToolTip(0, filename) - - def __lt__(self, x): - if self.sorting['status'] == ON: - return self.filename < x.filename - else: - return False - - def __ge__(self, x): - if self.sorting['status'] == ON: - return self.filename >= x.filename - else: - return False - - -# ---- Browser -# ---------------------------------------------------------------------------- -class ItemDelegate(QStyledItemDelegate): - - def __init__(self, parent): - super().__init__(parent) - self._margin = None - self._background_color = QColor(QStylePalette.COLOR_BACKGROUND_3) - self.width = 0 - - def paint(self, painter, option, index): - options = QStyleOptionViewItem(option) - self.initStyleOption(options, index) - style = (QApplication.style() if options.widget is None - else options.widget.style()) - - # Set background color for selected and hovered items. - # Inspired by: - # - https://stackoverflow.com/a/43253004/438386 - # - https://stackoverflow.com/a/27274233/438386 - - # This is commented for now until we find a way to correctly colorize - # the entire line with a single color. - # if options.state & QStyle.State_Selected: - # # This only applies when the selected item doesn't have focus - # if not (options.state & QStyle.State_HasFocus): - # options.palette.setBrush( - # QPalette.Highlight, - # QBrush(self._background_color) - # ) - - if options.state & QStyle.State_MouseOver: - painter.fillRect(option.rect, self._background_color) - - # Set text - doc = QTextDocument() - text = options.text - doc.setHtml(text) - doc.setDocumentMargin(0) - - # This needs to be an empty string to avoid overlapping the - # normal text of the QTreeWidgetItem - options.text = "" - style.drawControl(QStyle.CE_ItemViewItem, options, painter) - - ctx = QAbstractTextDocumentLayout.PaintContext() - - textRect = style.subElementRect(QStyle.SE_ItemViewItemText, - options, None) - painter.save() - - painter.translate(textRect.topLeft() + QPoint(0, 4)) - doc.documentLayout().draw(painter, ctx) - painter.restore() - - def sizeHint(self, option, index): - options = QStyleOptionViewItem(option) - self.initStyleOption(options, index) - doc = QTextDocument() - doc.setHtml(options.text) - doc.setTextWidth(options.rect.width()) - size = QSize(self.width, int(doc.size().height())) - return size - - -class ResultsBrowser(OneColumnTree): - sig_edit_goto_requested = Signal(str, int, str, int, int) - sig_max_results_reached = Signal() - - def __init__(self, parent, text_color, max_results=1000): - super().__init__(parent) - self.search_text = None - self.results = None - self.max_results = max_results - self.total_matches = None - self.error_flag = None - self.completed = None - self.sorting = {} - self.font = get_font() - self.data = None - self.files = None - self.root_items = None - self.text_color = text_color - self.path = None - self.longest_file_item = '' - self.longest_line_item = '' - - # Setup - self.set_title('') - self.set_sorting(OFF) - self.setSortingEnabled(False) - self.setItemDelegate(ItemDelegate(self)) - self.setUniformRowHeights(True) # Needed for performance - self.sortByColumn(0, Qt.AscendingOrder) - - # Only show the actions for collaps/expand all entries in the widget - # For further information see spyder-ide/spyder#13178 - self.common_actions = self.common_actions[:2] - - # Signals - self.header().sectionClicked.connect(self.sort_section) - - def activated(self, item): - """Double-click event.""" - itemdata = self.data.get(id(self.currentItem())) - if itemdata is not None: - filename, lineno, colno, colend = itemdata - self.sig_edit_goto_requested.emit( - filename, lineno, self.search_text, colno, colend - colno) - - def set_sorting(self, flag): - """Enable result sorting after search is complete.""" - self.sorting['status'] = flag - self.header().setSectionsClickable(flag == ON) - - @Slot(int) - def sort_section(self, idx): - self.setSortingEnabled(True) - - def clicked(self, item): - """Click event.""" - if isinstance(item, FileMatchItem): - if item.isExpanded(): - self.collapseItem(item) - else: - self.expandItem(item) - else: - self.activated(item) - - def clear_title(self, search_text): - self.font = get_font() - self.clear() - self.setSortingEnabled(False) - self.num_files = 0 - self.data = {} - self.files = {} - self.set_sorting(OFF) - self.search_text = search_text - title = "'%s' - " % search_text - text = _('String not found') - self.set_title(title + text) - - @Slot(object) - def append_file_result(self, filename): - """Real-time update of file items.""" - if len(self.data) < self.max_results: - self.files[filename] = item = FileMatchItem( - self, - self.path, - filename, - self.sorting, - self.text_color - ) - - item.setExpanded(True) - self.num_files += 1 - - item_text = osp.join(item.rel_dirname, item.filename) - if len(item_text) > len(self.longest_file_item): - self.longest_file_item = item_text - - @Slot(object, object) - def append_result(self, items, title): - """Real-time update of line items.""" - if len(self.data) >= self.max_results: - self.set_title(_('Maximum number of results reached! Try ' - 'narrowing the search.')) - self.sig_max_results_reached.emit() - return - - available = self.max_results - len(self.data) - if available < len(items): - items = items[:available] - - self.setUpdatesEnabled(False) - self.set_title(title) - for item in items: - filename, lineno, colno, line, match_end = item - file_item = self.files.get(filename, None) - if file_item: - item = LineMatchItem(file_item, lineno, colno, line, - self.font, self.text_color) - self.data[id(item)] = (filename, lineno, colno, match_end) - - if len(item.plain_match) > len(self.longest_line_item): - self.longest_line_item = item.plain_match - - self.setUpdatesEnabled(True) - - def set_max_results(self, value): - """Set maximum amount of results to add.""" - self.max_results = value - - def set_path(self, path): - """Set path where the search is performed.""" - self.path = path - - def set_width(self): - """Set widget width according to its longest item.""" - # File item width - file_item_size = self.fontMetrics().size( - Qt.TextSingleLine, - self.longest_file_item - ) - file_item_width = file_item_size.width() - - # Line item width - metrics = QFontMetrics(self.font) - line_item_chars = len(self.longest_line_item) - if line_item_chars >= MAX_RESULT_LENGTH: - line_item_chars = MAX_RESULT_LENGTH + len(ELLIPSIS) + 1 - line_item_width = line_item_chars * metrics.width('W') - - # Select width - if file_item_width > line_item_width: - width = file_item_width - else: - width = line_item_width - - # Increase width a bit to not be too near to the edge - self.itemDelegate().width = width + 10 +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Results browser.""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import QPoint, QSize, Qt, Signal, Slot +from qtpy.QtGui import (QAbstractTextDocumentLayout, QColor, QBrush, + QFontMetrics, QPalette, QTextDocument) +from qtpy.QtWidgets import (QApplication, QStyle, QStyledItemDelegate, + QStyleOptionViewItem, QTreeWidgetItem) + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.gui import get_font +from spyder.plugins.findinfiles.widgets.search_thread import ( + ELLIPSIS, MAX_RESULT_LENGTH) +from spyder.utils import icon_manager as ima +from spyder.utils.palette import QStylePalette +from spyder.widgets.onecolumntree import OneColumnTree + +# Localization +_ = get_translation('spyder') + + +# ---- Constants +# ---------------------------------------------------------------------------- +ON = 'on' +OFF = 'off' + + +# ---- Items +# ---------------------------------------------------------------------------- +class LineMatchItem(QTreeWidgetItem): + + def __init__(self, parent, lineno, colno, match, font, text_color): + self.lineno = lineno + self.colno = colno + self.match = match['formatted_text'] + self.plain_match = match['text'] + self.text_color = text_color + self.font = font + super().__init__(parent, [self.__repr__()], QTreeWidgetItem.Type) + + def __repr__(self): + match = str(self.match).rstrip() + _str = ( + f"" + f"

" + f'  ' + f"{self.lineno} ({self.colno}): " + f"{match}

" + ) + return _str + + def __unicode__(self): + return self.__repr__() + + def __str__(self): + return self.__repr__() + + def __lt__(self, x): + return self.lineno < x.lineno + + def __ge__(self, x): + return self.lineno >= x.lineno + + +class FileMatchItem(QTreeWidgetItem): + + def __init__(self, parent, path, filename, sorting, text_color): + + self.sorting = sorting + self.filename = osp.basename(filename) + + # Get relative dirname according to the path we're searching in. + dirname = osp.dirname(filename) + rel_dirname = dirname.split(path)[1] + if rel_dirname.startswith(osp.sep): + rel_dirname = rel_dirname[1:] + self.rel_dirname = rel_dirname + + title = ( + f'' + f'{osp.basename(filename)}' + f'   ' + f'' + f'{self.rel_dirname}' + f'' + ) + + super().__init__(parent, [title], QTreeWidgetItem.Type) + + self.setIcon(0, ima.get_icon_by_extension_or_type(filename, 1.0)) + self.setToolTip(0, filename) + + def __lt__(self, x): + if self.sorting['status'] == ON: + return self.filename < x.filename + else: + return False + + def __ge__(self, x): + if self.sorting['status'] == ON: + return self.filename >= x.filename + else: + return False + + +# ---- Browser +# ---------------------------------------------------------------------------- +class ItemDelegate(QStyledItemDelegate): + + def __init__(self, parent): + super().__init__(parent) + self._margin = None + self._background_color = QColor(QStylePalette.COLOR_BACKGROUND_3) + self.width = 0 + + def paint(self, painter, option, index): + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + style = (QApplication.style() if options.widget is None + else options.widget.style()) + + # Set background color for selected and hovered items. + # Inspired by: + # - https://stackoverflow.com/a/43253004/438386 + # - https://stackoverflow.com/a/27274233/438386 + + # This is commented for now until we find a way to correctly colorize + # the entire line with a single color. + # if options.state & QStyle.State_Selected: + # # This only applies when the selected item doesn't have focus + # if not (options.state & QStyle.State_HasFocus): + # options.palette.setBrush( + # QPalette.Highlight, + # QBrush(self._background_color) + # ) + + if options.state & QStyle.State_MouseOver: + painter.fillRect(option.rect, self._background_color) + + # Set text + doc = QTextDocument() + text = options.text + doc.setHtml(text) + doc.setDocumentMargin(0) + + # This needs to be an empty string to avoid overlapping the + # normal text of the QTreeWidgetItem + options.text = "" + style.drawControl(QStyle.CE_ItemViewItem, options, painter) + + ctx = QAbstractTextDocumentLayout.PaintContext() + + textRect = style.subElementRect(QStyle.SE_ItemViewItemText, + options, None) + painter.save() + + painter.translate(textRect.topLeft() + QPoint(0, 4)) + doc.documentLayout().draw(painter, ctx) + painter.restore() + + def sizeHint(self, option, index): + options = QStyleOptionViewItem(option) + self.initStyleOption(options, index) + doc = QTextDocument() + doc.setHtml(options.text) + doc.setTextWidth(options.rect.width()) + size = QSize(self.width, int(doc.size().height())) + return size + + +class ResultsBrowser(OneColumnTree): + sig_edit_goto_requested = Signal(str, int, str, int, int) + sig_max_results_reached = Signal() + + def __init__(self, parent, text_color, max_results=1000): + super().__init__(parent) + self.search_text = None + self.results = None + self.max_results = max_results + self.total_matches = None + self.error_flag = None + self.completed = None + self.sorting = {} + self.font = get_font() + self.data = None + self.files = None + self.root_items = None + self.text_color = text_color + self.path = None + self.longest_file_item = '' + self.longest_line_item = '' + + # Setup + self.set_title('') + self.set_sorting(OFF) + self.setSortingEnabled(False) + self.setItemDelegate(ItemDelegate(self)) + self.setUniformRowHeights(True) # Needed for performance + self.sortByColumn(0, Qt.AscendingOrder) + + # Only show the actions for collaps/expand all entries in the widget + # For further information see spyder-ide/spyder#13178 + self.common_actions = self.common_actions[:2] + + # Signals + self.header().sectionClicked.connect(self.sort_section) + + def activated(self, item): + """Double-click event.""" + itemdata = self.data.get(id(self.currentItem())) + if itemdata is not None: + filename, lineno, colno, colend = itemdata + self.sig_edit_goto_requested.emit( + filename, lineno, self.search_text, colno, colend - colno) + + def set_sorting(self, flag): + """Enable result sorting after search is complete.""" + self.sorting['status'] = flag + self.header().setSectionsClickable(flag == ON) + + @Slot(int) + def sort_section(self, idx): + self.setSortingEnabled(True) + + def clicked(self, item): + """Click event.""" + if isinstance(item, FileMatchItem): + if item.isExpanded(): + self.collapseItem(item) + else: + self.expandItem(item) + else: + self.activated(item) + + def clear_title(self, search_text): + self.font = get_font() + self.clear() + self.setSortingEnabled(False) + self.num_files = 0 + self.data = {} + self.files = {} + self.set_sorting(OFF) + self.search_text = search_text + title = "'%s' - " % search_text + text = _('String not found') + self.set_title(title + text) + + @Slot(object) + def append_file_result(self, filename): + """Real-time update of file items.""" + if len(self.data) < self.max_results: + self.files[filename] = item = FileMatchItem( + self, + self.path, + filename, + self.sorting, + self.text_color + ) + + item.setExpanded(True) + self.num_files += 1 + + item_text = osp.join(item.rel_dirname, item.filename) + if len(item_text) > len(self.longest_file_item): + self.longest_file_item = item_text + + @Slot(object, object) + def append_result(self, items, title): + """Real-time update of line items.""" + if len(self.data) >= self.max_results: + self.set_title(_('Maximum number of results reached! Try ' + 'narrowing the search.')) + self.sig_max_results_reached.emit() + return + + available = self.max_results - len(self.data) + if available < len(items): + items = items[:available] + + self.setUpdatesEnabled(False) + self.set_title(title) + for item in items: + filename, lineno, colno, line, match_end = item + file_item = self.files.get(filename, None) + if file_item: + item = LineMatchItem(file_item, lineno, colno, line, + self.font, self.text_color) + self.data[id(item)] = (filename, lineno, colno, match_end) + + if len(item.plain_match) > len(self.longest_line_item): + self.longest_line_item = item.plain_match + + self.setUpdatesEnabled(True) + + def set_max_results(self, value): + """Set maximum amount of results to add.""" + self.max_results = value + + def set_path(self, path): + """Set path where the search is performed.""" + self.path = path + + def set_width(self): + """Set widget width according to its longest item.""" + # File item width + file_item_size = self.fontMetrics().size( + Qt.TextSingleLine, + self.longest_file_item + ) + file_item_width = file_item_size.width() + + # Line item width + metrics = QFontMetrics(self.font) + line_item_chars = len(self.longest_line_item) + if line_item_chars >= MAX_RESULT_LENGTH: + line_item_chars = MAX_RESULT_LENGTH + len(ELLIPSIS) + 1 + line_item_width = line_item_chars * metrics.width('W') + + # Select width + if file_item_width > line_item_width: + width = file_item_width + else: + width = line_item_width + + # Increase width a bit to not be too near to the edge + self.itemDelegate().width = width + 10 diff --git a/spyder/plugins/help/api.py b/spyder/plugins/help/api.py index 6efb03dac53..c1e31cd399a 100644 --- a/spyder/plugins/help/api.py +++ b/spyder/plugins/help/api.py @@ -1,15 +1,15 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Help Plugin API. -""" - -# Local imports -from spyder.plugins.help.plugin import HelpActions -from spyder.plugins.help.widgets import (HelpWidgetActions, - HelpWidgetMainToolbarSections, - HelpWidgetOptionsMenuSections) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Help Plugin API. +""" + +# Local imports +from spyder.plugins.help.plugin import HelpActions +from spyder.plugins.help.widgets import (HelpWidgetActions, + HelpWidgetMainToolbarSections, + HelpWidgetOptionsMenuSections) diff --git a/spyder/plugins/help/plugin.py b/spyder/plugins/help/plugin.py index 7c3aa4e30fe..e9fc5d67484 100644 --- a/spyder/plugins/help/plugin.py +++ b/spyder/plugins/help/plugin.py @@ -1,375 +1,375 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Help Plugin. -""" - -# Standard library imports -import os - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.plugins.help.confpage import HelpConfigPage -from spyder.plugins.help.widgets import HelpWidget - -# Localization -_ = get_translation('spyder') - - -class HelpActions: - # Documentation related - ShowSpyderTutorialAction = "spyder_tutorial_action" - - -class Help(SpyderDockablePlugin): - """ - Docstrings viewer widget. - """ - NAME = 'help' - REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Editor] - OPTIONAL = [Plugins.IPythonConsole, Plugins.Shortcuts, Plugins.MainMenu] - TABIFY = Plugins.VariableExplorer - WIDGET_CLASS = HelpWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = HelpConfigPage - CONF_FILE = False - LOG_PATH = get_conf_path(CONF_SECTION) - FONT_SIZE_DELTA = DEFAULT_SMALL_DELTA - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # Signals - sig_focus_changed = Signal() # TODO: What triggers this? - - sig_render_started = Signal() - """This signal is emitted to inform a help text rendering has started.""" - - sig_render_finished = Signal() - """This signal is emitted to inform a help text rendering has finished.""" - - # --- SpyderDocakblePlugin API - # ----------------------------------------------------------------------- - @staticmethod - def get_name(): - return _('Help') - - def get_description(self): - return _( - 'Get rich text documentation from the editor and the console') - - def get_icon(self): - return self.create_icon('help') - - def on_initialize(self): - widget = self.get_widget() - - # Expose widget signals on the plugin - widget.sig_render_started.connect(self.sig_render_started) - widget.sig_render_finished.connect(self.sig_render_finished) - - # self.sig_focus_changed.connect(self.main.plugin_focus_changed) - widget.set_history(self.load_history()) - widget.sig_item_found.connect(self.save_history) - - self.tutorial_action = self.create_action( - HelpActions.ShowSpyderTutorialAction, - text=_("Spyder tutorial"), - triggered=self.show_tutorial, - register_shortcut=False, - ) - - @on_plugin_available(plugin=Plugins.Console) - def on_console_available(self): - widget = self.get_widget() - internal_console = self.get_plugin(Plugins.Console) - internal_console.sig_help_requested.connect(self.set_object_text) - widget.set_internal_console(internal_console) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_help_requested.connect(self.set_editor_doc) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipython_console_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) - ipyconsole.sig_shellwidget_created.connect(self.set_shellwidget) - ipyconsole.sig_render_plain_text_requested.connect( - self.show_plain_text) - ipyconsole.sig_render_rich_text_requested.connect( - self.show_rich_text) - - ipyconsole.sig_help_requested.connect(self.set_object_text) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Shortcuts) - def on_shortcuts_available(self): - shortcuts = self.get_plugin(Plugins.Shortcuts) - - # See: spyder-ide/spyder#6992 - shortcuts.sig_shortcuts_updated.connect(self.show_intro_message) - - if self.is_plugin_available(Plugins.MainMenu): - self._setup_menus() - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - if self.is_plugin_enabled(Plugins.Shortcuts): - if self.is_plugin_available(Plugins.Shortcuts): - self._setup_menus() - else: - self._setup_menus() - - @on_plugin_teardown(plugin=Plugins.Console) - def on_console_teardown(self): - widget = self.get_widget() - internal_console = self.get_plugin(Plugins.Console) - internal_console.sig_help_requested.disconnect(self.set_object_text) - widget.set_internal_console(None) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_help_requested.disconnect(self.set_editor_doc) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipython_console_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) - ipyconsole.sig_shellwidget_created.disconnect( - self.set_shellwidget) - ipyconsole.sig_render_plain_text_requested.disconnect( - self.show_plain_text) - ipyconsole.sig_render_rich_text_requested.disconnect( - self.show_rich_text) - - ipyconsole.sig_help_requested.disconnect(self.set_object_text) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Shortcuts) - def on_shortcuts_teardown(self): - shortcuts = self.get_plugin(Plugins.Shortcuts) - shortcuts.sig_shortcuts_updated.disconnect(self.show_intro_message) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - self._remove_menus() - - def update_font(self): - color_scheme = self.get_color_scheme() - font = self.get_font() - rich_font = self.get_font(rich_text=True) - - widget = self.get_widget() - widget.set_plain_text_font(font, color_scheme=color_scheme) - widget.set_rich_text_font(rich_font, font) - widget.set_plain_text_color_scheme(color_scheme) - - def on_close(self, cancelable=False): - self.save_history() - return True - - def apply_conf(self, options_set, notify=False): - super().apply_conf(options_set) - - # To make auto-connection changes take place instantly - try: - editor = self.get_plugin(Plugins.Editor) - editor.apply_plugin_settings({'connect_to_oi'}) - except SpyderAPIError: - pass - - def on_mainwindow_visible(self): - # Raise plugin the first time Spyder starts - if self.get_conf('show_first_time', default=True): - self.dockwidget.raise_() - self.set_conf('show_first_time', False) - - # --- Private API - # ------------------------------------------------------------------------ - def _setup_menus(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - shortcuts = self.get_plugin(Plugins.Shortcuts) - shortcuts_summary_action = None - if shortcuts: - from spyder.plugins.shortcuts.plugin import ShortcutActions - shortcuts_summary_action = ShortcutActions.ShortcutSummaryAction - if mainmenu: - from spyder.plugins.mainmenu.api import ( - ApplicationMenus, HelpMenuSections) - # Documentation actions - mainmenu.add_item_to_application_menu( - self.tutorial_action, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.Documentation, - before=shortcuts_summary_action, - before_section=HelpMenuSections.Support) - - def _remove_menus(self): - from spyder.plugins.mainmenu.api import ApplicationMenus - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - HelpActions.ShowSpyderTutorialAction, - menu_id=ApplicationMenus.Help) - - # --- Public API - # ------------------------------------------------------------------------ - def set_shellwidget(self, shellwidget): - """ - Set IPython Console `shelwidget` as the current shellwidget. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shell widget that is going to be connected to Help. - """ - shellwidget._control.set_help_enabled( - self.get_conf('connect/ipython_console')) - self.get_widget().set_shell(shellwidget) - - def load_history(self, obj=None): - """ - Load history from a text file in the user configuration directory. - """ - if os.path.isfile(self.LOG_PATH): - with open(self.LOG_PATH, 'r') as fh: - lines = fh.read().split('\n') - - history = [line.replace('\n', '') for line in lines] - else: - history = [] - - return history - - def save_history(self): - """ - Save history to a text file in the user configuration directory. - """ - # Don't fail when saving search history to disk - # See spyder-ide/spyder#8878 and spyder-ide/spyder#6864 - try: - search_history = '\n'.join(self.get_widget().get_history()) - with open(self.LOG_PATH, 'w') as fh: - fh.write(search_history) - except (UnicodeEncodeError, UnicodeDecodeError, EnvironmentError): - pass - - def show_tutorial(self): - """Show the Spyder tutorial.""" - self.switch_to_plugin() - self.get_widget().show_tutorial() - - def show_intro_message(self): - """Show the IPython introduction message.""" - self.get_widget().show_intro_message() - - def show_rich_text(self, text, collapse=False, img_path=''): - """ - Show help in rich mode. - - Parameters - ---------- - text: str - Plain text to display. - collapse: bool, optional - Show collapsable sections as collapsed/expanded. Default is False. - img_path: str, optional - Path to folder with additional images needed to correctly - display the rich text help. Default is ''. - """ - self.switch_to_plugin() - self.get_widget().show_rich_text(text, collapse=collapse, - img_path=img_path) - - def show_plain_text(self, text): - """ - Show help in plain mode. - - Parameters - ---------- - text: str - Plain text to display. - """ - self.switch_to_plugin() - self.get_widget().show_plain_text(text) - - def set_object_text(self, options_dict): - """ - Set object's name in Help's combobox. - - Parameters - ---------- - options_dict: dict - Dictionary of data. See the example for the expected keys. - - Examples - -------- - >>> help_data = { - 'name': str, - 'force_refresh': bool, - } - - See Also - -------- - :py:meth:spyder.widgets.mixins.GetHelpMixin.show_object_info - """ - self.switch_to_plugin() - self.get_widget().set_object_text( - options_dict['name'], - ignore_unknown=options_dict['ignore_unknown'], - ) - - def set_editor_doc(self, help_data): - """ - Set content for help data sent from the editor. - - Parameters - ---------- - help_data: dict - Dictionary of data. See the example for the expected keys. - - Examples - -------- - >>> help_data = { - 'obj_text': str, - 'name': str, - 'argspec': str, - 'note': str, - 'docstring': str, - 'force_refresh': bool, - 'path': str, - } - - See Also - -------- - :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help - """ - force_refresh = help_data.pop('force_refresh', False) - self.switch_to_plugin() - self.get_widget().set_editor_doc( - help_data, - force_refresh=force_refresh, - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Help Plugin. +""" + +# Standard library imports +import os + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.plugins.help.confpage import HelpConfigPage +from spyder.plugins.help.widgets import HelpWidget + +# Localization +_ = get_translation('spyder') + + +class HelpActions: + # Documentation related + ShowSpyderTutorialAction = "spyder_tutorial_action" + + +class Help(SpyderDockablePlugin): + """ + Docstrings viewer widget. + """ + NAME = 'help' + REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Editor] + OPTIONAL = [Plugins.IPythonConsole, Plugins.Shortcuts, Plugins.MainMenu] + TABIFY = Plugins.VariableExplorer + WIDGET_CLASS = HelpWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = HelpConfigPage + CONF_FILE = False + LOG_PATH = get_conf_path(CONF_SECTION) + FONT_SIZE_DELTA = DEFAULT_SMALL_DELTA + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # Signals + sig_focus_changed = Signal() # TODO: What triggers this? + + sig_render_started = Signal() + """This signal is emitted to inform a help text rendering has started.""" + + sig_render_finished = Signal() + """This signal is emitted to inform a help text rendering has finished.""" + + # --- SpyderDocakblePlugin API + # ----------------------------------------------------------------------- + @staticmethod + def get_name(): + return _('Help') + + def get_description(self): + return _( + 'Get rich text documentation from the editor and the console') + + def get_icon(self): + return self.create_icon('help') + + def on_initialize(self): + widget = self.get_widget() + + # Expose widget signals on the plugin + widget.sig_render_started.connect(self.sig_render_started) + widget.sig_render_finished.connect(self.sig_render_finished) + + # self.sig_focus_changed.connect(self.main.plugin_focus_changed) + widget.set_history(self.load_history()) + widget.sig_item_found.connect(self.save_history) + + self.tutorial_action = self.create_action( + HelpActions.ShowSpyderTutorialAction, + text=_("Spyder tutorial"), + triggered=self.show_tutorial, + register_shortcut=False, + ) + + @on_plugin_available(plugin=Plugins.Console) + def on_console_available(self): + widget = self.get_widget() + internal_console = self.get_plugin(Plugins.Console) + internal_console.sig_help_requested.connect(self.set_object_text) + widget.set_internal_console(internal_console) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_help_requested.connect(self.set_editor_doc) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipython_console_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) + ipyconsole.sig_shellwidget_created.connect(self.set_shellwidget) + ipyconsole.sig_render_plain_text_requested.connect( + self.show_plain_text) + ipyconsole.sig_render_rich_text_requested.connect( + self.show_rich_text) + + ipyconsole.sig_help_requested.connect(self.set_object_text) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Shortcuts) + def on_shortcuts_available(self): + shortcuts = self.get_plugin(Plugins.Shortcuts) + + # See: spyder-ide/spyder#6992 + shortcuts.sig_shortcuts_updated.connect(self.show_intro_message) + + if self.is_plugin_available(Plugins.MainMenu): + self._setup_menus() + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + if self.is_plugin_enabled(Plugins.Shortcuts): + if self.is_plugin_available(Plugins.Shortcuts): + self._setup_menus() + else: + self._setup_menus() + + @on_plugin_teardown(plugin=Plugins.Console) + def on_console_teardown(self): + widget = self.get_widget() + internal_console = self.get_plugin(Plugins.Console) + internal_console.sig_help_requested.disconnect(self.set_object_text) + widget.set_internal_console(None) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_help_requested.disconnect(self.set_editor_doc) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipython_console_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) + ipyconsole.sig_shellwidget_created.disconnect( + self.set_shellwidget) + ipyconsole.sig_render_plain_text_requested.disconnect( + self.show_plain_text) + ipyconsole.sig_render_rich_text_requested.disconnect( + self.show_rich_text) + + ipyconsole.sig_help_requested.disconnect(self.set_object_text) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Shortcuts) + def on_shortcuts_teardown(self): + shortcuts = self.get_plugin(Plugins.Shortcuts) + shortcuts.sig_shortcuts_updated.disconnect(self.show_intro_message) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + self._remove_menus() + + def update_font(self): + color_scheme = self.get_color_scheme() + font = self.get_font() + rich_font = self.get_font(rich_text=True) + + widget = self.get_widget() + widget.set_plain_text_font(font, color_scheme=color_scheme) + widget.set_rich_text_font(rich_font, font) + widget.set_plain_text_color_scheme(color_scheme) + + def on_close(self, cancelable=False): + self.save_history() + return True + + def apply_conf(self, options_set, notify=False): + super().apply_conf(options_set) + + # To make auto-connection changes take place instantly + try: + editor = self.get_plugin(Plugins.Editor) + editor.apply_plugin_settings({'connect_to_oi'}) + except SpyderAPIError: + pass + + def on_mainwindow_visible(self): + # Raise plugin the first time Spyder starts + if self.get_conf('show_first_time', default=True): + self.dockwidget.raise_() + self.set_conf('show_first_time', False) + + # --- Private API + # ------------------------------------------------------------------------ + def _setup_menus(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + shortcuts = self.get_plugin(Plugins.Shortcuts) + shortcuts_summary_action = None + if shortcuts: + from spyder.plugins.shortcuts.plugin import ShortcutActions + shortcuts_summary_action = ShortcutActions.ShortcutSummaryAction + if mainmenu: + from spyder.plugins.mainmenu.api import ( + ApplicationMenus, HelpMenuSections) + # Documentation actions + mainmenu.add_item_to_application_menu( + self.tutorial_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Documentation, + before=shortcuts_summary_action, + before_section=HelpMenuSections.Support) + + def _remove_menus(self): + from spyder.plugins.mainmenu.api import ApplicationMenus + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + HelpActions.ShowSpyderTutorialAction, + menu_id=ApplicationMenus.Help) + + # --- Public API + # ------------------------------------------------------------------------ + def set_shellwidget(self, shellwidget): + """ + Set IPython Console `shelwidget` as the current shellwidget. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shell widget that is going to be connected to Help. + """ + shellwidget._control.set_help_enabled( + self.get_conf('connect/ipython_console')) + self.get_widget().set_shell(shellwidget) + + def load_history(self, obj=None): + """ + Load history from a text file in the user configuration directory. + """ + if os.path.isfile(self.LOG_PATH): + with open(self.LOG_PATH, 'r') as fh: + lines = fh.read().split('\n') + + history = [line.replace('\n', '') for line in lines] + else: + history = [] + + return history + + def save_history(self): + """ + Save history to a text file in the user configuration directory. + """ + # Don't fail when saving search history to disk + # See spyder-ide/spyder#8878 and spyder-ide/spyder#6864 + try: + search_history = '\n'.join(self.get_widget().get_history()) + with open(self.LOG_PATH, 'w') as fh: + fh.write(search_history) + except (UnicodeEncodeError, UnicodeDecodeError, EnvironmentError): + pass + + def show_tutorial(self): + """Show the Spyder tutorial.""" + self.switch_to_plugin() + self.get_widget().show_tutorial() + + def show_intro_message(self): + """Show the IPython introduction message.""" + self.get_widget().show_intro_message() + + def show_rich_text(self, text, collapse=False, img_path=''): + """ + Show help in rich mode. + + Parameters + ---------- + text: str + Plain text to display. + collapse: bool, optional + Show collapsable sections as collapsed/expanded. Default is False. + img_path: str, optional + Path to folder with additional images needed to correctly + display the rich text help. Default is ''. + """ + self.switch_to_plugin() + self.get_widget().show_rich_text(text, collapse=collapse, + img_path=img_path) + + def show_plain_text(self, text): + """ + Show help in plain mode. + + Parameters + ---------- + text: str + Plain text to display. + """ + self.switch_to_plugin() + self.get_widget().show_plain_text(text) + + def set_object_text(self, options_dict): + """ + Set object's name in Help's combobox. + + Parameters + ---------- + options_dict: dict + Dictionary of data. See the example for the expected keys. + + Examples + -------- + >>> help_data = { + 'name': str, + 'force_refresh': bool, + } + + See Also + -------- + :py:meth:spyder.widgets.mixins.GetHelpMixin.show_object_info + """ + self.switch_to_plugin() + self.get_widget().set_object_text( + options_dict['name'], + ignore_unknown=options_dict['ignore_unknown'], + ) + + def set_editor_doc(self, help_data): + """ + Set content for help data sent from the editor. + + Parameters + ---------- + help_data: dict + Dictionary of data. See the example for the expected keys. + + Examples + -------- + >>> help_data = { + 'obj_text': str, + 'name': str, + 'argspec': str, + 'note': str, + 'docstring': str, + 'force_refresh': bool, + 'path': str, + } + + See Also + -------- + :py:meth:spyder.plugins.editor.widgets.editor.EditorStack.send_to_help + """ + force_refresh = help_data.pop('force_refresh', False) + self.switch_to_plugin() + self.get_widget().set_editor_doc( + help_data, + force_refresh=force_refresh, + ) diff --git a/spyder/plugins/help/utils/__init__.py b/spyder/plugins/help/utils/__init__.py index b40f03ab67e..ab744c1b77d 100644 --- a/spyder/plugins/help/utils/__init__.py +++ b/spyder/plugins/help/utils/__init__.py @@ -1,20 +1,20 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors and others (see LICENSE.txt) -# -# Licensed under the terms of the MIT and other licenses where noted -# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) -# ----------------------------------------------------------------------------- - -""" -spyder.plugins.help.utils -================= - -Configuration files for the Help plugin rich text mode. - -See their headers, LICENSE.txt in this directory or NOTICE.txt for licenses. -""" - -import sys -from spyder.config.base import get_module_source_path -sys.path.insert(0, get_module_source_path(__name__)) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors and others (see LICENSE.txt) +# +# Licensed under the terms of the MIT and other licenses where noted +# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) +# ----------------------------------------------------------------------------- + +""" +spyder.plugins.help.utils +================= + +Configuration files for the Help plugin rich text mode. + +See their headers, LICENSE.txt in this directory or NOTICE.txt for licenses. +""" + +import sys +from spyder.config.base import get_module_source_path +sys.path.insert(0, get_module_source_path(__name__)) diff --git a/spyder/plugins/history/plugin.py b/spyder/plugins/history/plugin.py index 6d67a9fb356..91dc86aade0 100644 --- a/spyder/plugins/history/plugin.py +++ b/spyder/plugins/history/plugin.py @@ -1,137 +1,137 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Console History Plugin. -""" - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.plugins.history.confpage import HistoryConfigPage -from spyder.plugins.history.widgets import HistoryWidget - -# Localization -_ = get_translation('spyder') - - -class HistoryLog(SpyderDockablePlugin): - """ - History log plugin. - """ - - NAME = 'historylog' - REQUIRES = [Plugins.Preferences, Plugins.Console] - OPTIONAL = [Plugins.IPythonConsole] - TABIFY = Plugins.IPythonConsole - WIDGET_CLASS = HistoryWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = HistoryConfigPage - CONF_FILE = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_focus_changed = Signal() - """ - This signal is emitted when the focus of the code editor storing history - changes. - """ - - def __init__(self, parent=None, configuration=None): - """Initialization.""" - super().__init__(parent, configuration) - self.add_history(get_conf_path('history.py')) - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('History') - - def get_description(self): - return _('Provide command history for IPython Consoles') - - def get_icon(self): - return self.create_icon('history') - - def on_initialize(self): - widget = self.get_widget() - widget.sig_focus_changed.connect(self.sig_focus_changed) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Console) - def on_console_available(self): - console = self.get_plugin(Plugins.Console) - console.sig_refreshed.connect(self.refresh) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipyconsole_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - ipyconsole.sig_append_to_history_requested.connect( - self.append_to_history) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Console) - def on_console_teardown(self): - console = self.get_plugin(Plugins.Console) - console.sig_refreshed.disconnect(self.refresh) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipyconsole_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - ipyconsole.sig_append_to_history_requested.disconnect( - self.append_to_history) - - def update_font(self): - color_scheme = self.get_color_scheme() - font = self.get_font() - self.get_widget().update_font(font, color_scheme) - - # --- Plubic API - # ------------------------------------------------------------------------ - def refresh(self): - """ - Refresh main widget. - """ - self.get_widget().refresh() - - def add_history(self, filename): - """ - Create history file. - - Parameters - ---------- - filename: str - History file. - """ - self.get_widget().add_history(filename) - - def append_to_history(self, filename, command): - """ - Append command to history file. - - Parameters - ---------- - filename: str - History file. - command: str - Command to append to history file. - """ - self.get_widget().append_to_history(filename, command) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Console History Plugin. +""" + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.plugins.history.confpage import HistoryConfigPage +from spyder.plugins.history.widgets import HistoryWidget + +# Localization +_ = get_translation('spyder') + + +class HistoryLog(SpyderDockablePlugin): + """ + History log plugin. + """ + + NAME = 'historylog' + REQUIRES = [Plugins.Preferences, Plugins.Console] + OPTIONAL = [Plugins.IPythonConsole] + TABIFY = Plugins.IPythonConsole + WIDGET_CLASS = HistoryWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = HistoryConfigPage + CONF_FILE = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_focus_changed = Signal() + """ + This signal is emitted when the focus of the code editor storing history + changes. + """ + + def __init__(self, parent=None, configuration=None): + """Initialization.""" + super().__init__(parent, configuration) + self.add_history(get_conf_path('history.py')) + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('History') + + def get_description(self): + return _('Provide command history for IPython Consoles') + + def get_icon(self): + return self.create_icon('history') + + def on_initialize(self): + widget = self.get_widget() + widget.sig_focus_changed.connect(self.sig_focus_changed) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Console) + def on_console_available(self): + console = self.get_plugin(Plugins.Console) + console.sig_refreshed.connect(self.refresh) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipyconsole_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + ipyconsole.sig_append_to_history_requested.connect( + self.append_to_history) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Console) + def on_console_teardown(self): + console = self.get_plugin(Plugins.Console) + console.sig_refreshed.disconnect(self.refresh) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipyconsole_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + ipyconsole.sig_append_to_history_requested.disconnect( + self.append_to_history) + + def update_font(self): + color_scheme = self.get_color_scheme() + font = self.get_font() + self.get_widget().update_font(font, color_scheme) + + # --- Plubic API + # ------------------------------------------------------------------------ + def refresh(self): + """ + Refresh main widget. + """ + self.get_widget().refresh() + + def add_history(self, filename): + """ + Create history file. + + Parameters + ---------- + filename: str + History file. + """ + self.get_widget().add_history(filename) + + def append_to_history(self, filename, command): + """ + Append command to history file. + + Parameters + ---------- + filename: str + History file. + command: str + Command to append to history file. + """ + self.get_widget().append_to_history(filename, command) diff --git a/spyder/plugins/io_dcm/plugin.py b/spyder/plugins/io_dcm/plugin.py index d32d6697edf..5b5edc84694 100644 --- a/spyder/plugins/io_dcm/plugin.py +++ b/spyder/plugins/io_dcm/plugin.py @@ -1,36 +1,36 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - - -"""Example of I/O plugin for loading DICOM files.""" - - -# Standard library imports -import os.path as osp - - -try: - try: - # pydicom 0.9 - import dicom as dicomio - except ImportError: - # pydicom 1.0 - from pydicom import dicomio - def load_dicom(filename): - try: - name = osp.splitext(osp.basename(filename))[0] - try: - data = dicomio.read_file(filename, force=True) - except TypeError: - data = dicomio.read_file(filename) - arr = data.pixel_array - return {name: arr}, None - except Exception as error: - return None, str(error) -except ImportError: - load_dicom = None +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + + +"""Example of I/O plugin for loading DICOM files.""" + + +# Standard library imports +import os.path as osp + + +try: + try: + # pydicom 0.9 + import dicom as dicomio + except ImportError: + # pydicom 1.0 + from pydicom import dicomio + def load_dicom(filename): + try: + name = osp.splitext(osp.basename(filename))[0] + try: + data = dicomio.read_file(filename, force=True) + except TypeError: + data = dicomio.read_file(filename) + arr = data.pixel_array + return {name: arr}, None + except Exception as error: + return None, str(error) +except ImportError: + load_dicom = None diff --git a/spyder/plugins/io_hdf5/plugin.py b/spyder/plugins/io_hdf5/plugin.py index 186f15c2d20..228e154156a 100644 --- a/spyder/plugins/io_hdf5/plugin.py +++ b/spyder/plugins/io_hdf5/plugin.py @@ -1,82 +1,82 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - - -""" -I/O plugin for loading/saving HDF5 files. - -Note that this is a fairly dumb implementation which reads the whole HDF5 file into -Spyder's variable explorer. Since HDF5 files are designed for storing very large -data-sets, it may be much better to work directly with the HDF5 objects, thus keeping -the data on disk. Nonetheless, this plugin gives quick and dirty but convenient -access to HDF5 files. - -There is no support for creating files with compression, chunking etc, although -these can be read without problem. - -All datatypes to be saved must be convertible to a numpy array, otherwise an exception -will be raised. - -Data attributes are currently ignored. - -When reading an HDF5 file with sub-groups, groups in the HDF5 file will -correspond to dictionaries with the same layout. However, when saving -data, dictionaries are not turned into HDF5 groups. - -TODO: Look for the pytables library if h5py is not found?? -TODO: Check issues with valid python names vs valid h5f5 names -""" - -from __future__ import print_function - -import importlib -# Do not import h5py here because it will try to import IPython, -# and this is freezing the Spyder GUI - -import numpy as np - -if importlib.util.find_spec('h5py'): - def load_hdf5(filename): - import h5py - def get_group(group): - contents = {} - for name, obj in list(group.items()): - if isinstance(obj, h5py.Dataset): - contents[name] = np.array(obj) - elif isinstance(obj, h5py.Group): - # it is a group, so call self recursively - contents[name] = get_group(obj) - # other objects such as links are ignored - return contents - - try: - f = h5py.File(filename, 'r') - contents = get_group(f) - f.close() - return contents, None - except Exception as error: - return None, str(error) - - def save_hdf5(data, filename): - import h5py - try: - f = h5py.File(filename, 'w') - for key, value in list(data.items()): - f[key] = np.array(value) - f.close() - except Exception as error: - return str(error) -else: - load_hdf5 = None - save_hdf5 = None - - -if __name__ == "__main__": - data = {'a' : [1, 2, 3, 4], 'b' : 4.5} - print(save_hdf5(data, "test.h5")) # spyder: test-skip - print(load_hdf5("test.h5")) # spyder: test-skip +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + + +""" +I/O plugin for loading/saving HDF5 files. + +Note that this is a fairly dumb implementation which reads the whole HDF5 file into +Spyder's variable explorer. Since HDF5 files are designed for storing very large +data-sets, it may be much better to work directly with the HDF5 objects, thus keeping +the data on disk. Nonetheless, this plugin gives quick and dirty but convenient +access to HDF5 files. + +There is no support for creating files with compression, chunking etc, although +these can be read without problem. + +All datatypes to be saved must be convertible to a numpy array, otherwise an exception +will be raised. + +Data attributes are currently ignored. + +When reading an HDF5 file with sub-groups, groups in the HDF5 file will +correspond to dictionaries with the same layout. However, when saving +data, dictionaries are not turned into HDF5 groups. + +TODO: Look for the pytables library if h5py is not found?? +TODO: Check issues with valid python names vs valid h5f5 names +""" + +from __future__ import print_function + +import importlib +# Do not import h5py here because it will try to import IPython, +# and this is freezing the Spyder GUI + +import numpy as np + +if importlib.util.find_spec('h5py'): + def load_hdf5(filename): + import h5py + def get_group(group): + contents = {} + for name, obj in list(group.items()): + if isinstance(obj, h5py.Dataset): + contents[name] = np.array(obj) + elif isinstance(obj, h5py.Group): + # it is a group, so call self recursively + contents[name] = get_group(obj) + # other objects such as links are ignored + return contents + + try: + f = h5py.File(filename, 'r') + contents = get_group(f) + f.close() + return contents, None + except Exception as error: + return None, str(error) + + def save_hdf5(data, filename): + import h5py + try: + f = h5py.File(filename, 'w') + for key, value in list(data.items()): + f[key] = np.array(value) + f.close() + except Exception as error: + return str(error) +else: + load_hdf5 = None + save_hdf5 = None + + +if __name__ == "__main__": + data = {'a' : [1, 2, 3, 4], 'b' : 4.5} + print(save_hdf5(data, "test.h5")) # spyder: test-skip + print(load_hdf5("test.h5")) # spyder: test-skip diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index 37e91ee0f7d..17df1a897a5 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -1,887 +1,887 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -IPython Console plugin based on QtConsole. -""" - -# Standard library imports -import os -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal, Slot - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.ipythonconsole.confpage import IPythonConsoleConfigPage -from spyder.plugins.ipythonconsole.widgets.main_widget import ( - IPythonConsoleWidget, IPythonConsoleWidgetOptionsMenus) -from spyder.plugins.mainmenu.api import ( - ApplicationMenus, ConsolesMenuSections, HelpMenuSections) -from spyder.utils.programs import get_temp_dir - -# Localization -_ = get_translation('spyder') - - -class IPythonConsole(SpyderDockablePlugin): - """ - IPython Console plugin - - This is a widget with tabs where each one is a ClientWidget - """ - - # This is required for the new API - NAME = 'ipython_console' - REQUIRES = [Plugins.Console, Plugins.Preferences] - OPTIONAL = [Plugins.Editor, Plugins.History, Plugins.MainMenu, - Plugins.Projects, Plugins.WorkingDirectory] - TABIFY = [Plugins.History] - WIDGET_CLASS = IPythonConsoleWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = IPythonConsoleConfigPage - CONF_FILE = False - DISABLE_ACTIONS_WHEN_HIDDEN = False - RAISE_AND_FOCUS = True - - # Signals - sig_append_to_history_requested = Signal(str, str) - """ - This signal is emitted when the plugin requires to add commands to a - history file. - - Parameters - ---------- - filename: str - History file filename. - text: str - Text to append to the history file. - """ - - sig_history_requested = Signal(str) - """ - This signal is emitted when the plugin wants a specific history file - to be shown. - - Parameters - ---------- - path: str - Path to history file. - """ - - sig_focus_changed = Signal() - """ - This signal is emitted when the plugin focus changes. - """ - - sig_edit_goto_requested = Signal((str, int, str), (str, int, str, bool)) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - processevents: bool - True if the code editor need to process qt events when loading the - requested file. - """ - - sig_edit_new = Signal(str) - """ - This signal will request to create a new file in a code editor. - - Parameters - ---------- - path: str - Path to file. - """ - - sig_pdb_state_changed = Signal(bool, dict) - """ - This signal is emitted when the debugging state changes. - - Parameters - ---------- - waiting_pdb_input: bool - If the debugging session is waiting for input. - pdb_last_step: dict - Dictionary with the information of the last step done - in the debugging session. - """ - - sig_shellwidget_created = Signal(object) - """ - This signal is emitted when a shellwidget is connected to - a kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet. - """ - - sig_shellwidget_deleted = Signal(object) - """ - This signal is emitted when a shellwidget is disconnected from - a kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet. - """ - - sig_shellwidget_changed = Signal(object) - """ - This signal is emitted when the current shellwidget changes. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet. - """ - - sig_external_spyder_kernel_connected = Signal(object) - """ - This signal is emitted when we connect to an external Spyder kernel. - - Parameters - ---------- - shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget - The shellwigdet that was connected to the kernel. - """ - - sig_render_plain_text_requested = Signal(str) - """ - This signal is emitted to request a plain text help render. - - Parameters - ---------- - plain_text: str - The plain text to render. - """ - - sig_render_rich_text_requested = Signal(str, bool) - """ - This signal is emitted to request a rich text help render. - - Parameters - ---------- - rich_text: str - The rich text. - collapse: bool - If the text contains collapsed sections, show them closed (True) or - open (False). - """ - - sig_help_requested = Signal(dict) - """ - This signal is emitted to request help on a given object `name`. - - Parameters - ---------- - help_data: dict - Example `{'name': str, 'ignore_unknown': bool}`. - """ - - sig_current_directory_changed = Signal(str) - """ - This signal is emitted when the current directory of the active shell - widget has changed. - - Parameters - ---------- - working_directory: str - The new working directory path. - """ - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------- - @staticmethod - def get_name(): - return _('IPython console') - - def get_description(self): - return _('IPython console') - - def get_icon(self): - return self.create_icon('ipython_console') - - def on_initialize(self): - widget = self.get_widget() - widget.sig_append_to_history_requested.connect( - self.sig_append_to_history_requested) - widget.sig_focus_changed.connect(self.sig_focus_changed) - widget.sig_switch_to_plugin_requested.connect(self.switch_to_plugin) - widget.sig_history_requested.connect(self.sig_history_requested) - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_edit_goto_requested[str, int, str, bool].connect( - self.sig_edit_goto_requested[str, int, str, bool]) - widget.sig_edit_new.connect(self.sig_edit_new) - widget.sig_pdb_state_changed.connect(self.sig_pdb_state_changed) - widget.sig_shellwidget_created.connect(self.sig_shellwidget_created) - widget.sig_shellwidget_deleted.connect(self.sig_shellwidget_deleted) - widget.sig_shellwidget_changed.connect(self.sig_shellwidget_changed) - widget.sig_external_spyder_kernel_connected.connect( - self.sig_external_spyder_kernel_connected) - widget.sig_render_plain_text_requested.connect( - self.sig_render_plain_text_requested) - widget.sig_render_rich_text_requested.connect( - self.sig_render_rich_text_requested) - widget.sig_help_requested.connect(self.sig_help_requested) - widget.sig_current_directory_changed.connect( - self.sig_current_directory_changed) - widget.sig_exception_occurred.connect(self.sig_exception_occurred) - - # Update kernels if python path is changed - self.main.sig_pythonpath_changed.connect(self.update_path) - - self.sig_focus_changed.connect(self.main.plugin_focus_changed) - self._remove_old_std_files() - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - # Register conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - widget = self.get_widget() - mainmenu = self.get_plugin(Plugins.MainMenu) - - # Add signal to update actions state before showing the menu - console_menu = mainmenu.get_application_menu( - ApplicationMenus.Consoles) - console_menu.aboutToShow.connect( - widget.update_actions) - - # Main menu actions for the IPython Console - new_consoles_actions = [ - widget.create_client_action, - widget.special_console_menu, - widget.connect_to_kernel_action - ] - - restart_connect_consoles_actions = [ - widget.interrupt_action, - widget.restart_action, - widget.reset_action - ] - - # Console menu - for console_new_action in new_consoles_actions: - mainmenu.add_item_to_application_menu( - console_new_action, - menu_id=ApplicationMenus.Consoles, - section=ConsolesMenuSections.New, - ) - - for console_action in restart_connect_consoles_actions: - mainmenu.add_item_to_application_menu( - console_action, - menu_id=ApplicationMenus.Consoles, - section=ConsolesMenuSections.Restart, - ) - - # IPython documentation - mainmenu.add_item_to_application_menu( - self.get_widget().ipython_menu, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.ExternalDocumentation, - before_section=HelpMenuSections.About, - ) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - self.sig_edit_goto_requested.connect(editor.load) - self.sig_edit_goto_requested[str, int, str, bool].connect( - self._load_file_in_editor) - self.sig_edit_new.connect(editor.new) - editor.breakpoints_saved.connect(self.set_spyder_breakpoints) - editor.run_in_current_ipyclient.connect(self.run_script) - editor.run_cell_in_ipyclient.connect(self.run_cell) - editor.debug_cell_in_ipyclient.connect(self.debug_cell) - - # Connect Editor debug action with Console - self.sig_pdb_state_changed.connect(editor.update_pdb_state) - editor.exec_in_extconsole.connect(self.execute_code_and_focus_editor) - editor.sig_file_debug_message_requested.connect( - self.print_debug_file_msg) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.connect(self._on_project_loaded) - projects.sig_project_closed.connect(self._on_project_closed) - - @on_plugin_available(plugin=Plugins.WorkingDirectory) - def on_working_directory_available(self): - working_directory = self.get_plugin(Plugins.WorkingDirectory) - working_directory.sig_current_directory_changed.connect( - self._set_working_directory) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - # Register conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_application_menu(ApplicationMenus.Consoles) - - # IPython documentation menu - mainmenu.remove_item_from_application_menu( - IPythonConsoleWidgetOptionsMenus.Documentation, - menu_id=ApplicationMenus.Help - ) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - self.sig_edit_goto_requested.disconnect(editor.load) - self.sig_edit_goto_requested[str, int, str, bool].disconnect( - self._load_file_in_editor) - self.sig_edit_new.disconnect(editor.new) - editor.breakpoints_saved.disconnect(self.set_spyder_breakpoints) - editor.run_in_current_ipyclient.disconnect(self.run_script) - editor.run_cell_in_ipyclient.disconnect(self.run_cell) - editor.debug_cell_in_ipyclient.disconnect(self.debug_cell) - - # Connect Editor debug action with Console - self.sig_pdb_state_changed.disconnect(editor.update_pdb_state) - editor.exec_in_extconsole.disconnect( - self.execute_code_and_focus_editor) - editor.sig_file_debug_message_requested.disconnect( - self.print_debug_file_msg) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardown(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self._on_project_loaded) - projects.sig_project_closed.disconnect(self._on_project_closed) - - @on_plugin_teardown(plugin=Plugins.WorkingDirectory) - def on_working_directory_teardown(self): - working_directory = self.get_plugin(Plugins.WorkingDirectory) - working_directory.sig_current_directory_changed.disconnect( - self._set_working_directory) - - def update_font(self): - """Update font from Preferences""" - font = self.get_font() - rich_font = self.get_font(rich_text=True) - self.get_widget().update_font(font, rich_font) - - def on_close(self, cancelable=False): - """Perform actions when plugin is closed""" - self.get_widget().mainwindow_close = True - return self.get_widget().close_clients() - - def on_mainwindow_visible(self): - self.create_new_client(give_focus=False) - - # Raise plugin the first time Spyder starts - if self.get_conf('show_first_time', default=True): - self.dockwidget.raise_() - self.set_conf('show_first_time', False) - - # ---- Private methods - # ------------------------------------------------------------------------- - def _load_file_in_editor(self, fname, lineno, word, processevents): - editor = self.get_plugin(Plugins.Editor) - editor.load(fname, lineno, word, processevents=processevents) - - def _switch_to_editor(self): - editor = self.get_plugin(Plugins.Editor) - editor.switch_to_plugin() - - def _on_project_loaded(self): - projects = self.get_plugin(Plugins.Projects) - self.get_widget().update_active_project_path( - projects.get_active_project_path()) - - def _on_project_closed(self): - self.get_widget().update_active_project_path(None) - - def _remove_old_std_files(self): - """ - Remove std files left by previous Spyder instances. - - This is only required on Windows because we can't - clean up std files while Spyder is running on that - platform. - """ - if os.name == 'nt': - tmpdir = get_temp_dir() - for fname in os.listdir(tmpdir): - if osp.splitext(fname)[1] in ('.stderr', '.stdout', '.fault'): - try: - os.remove(osp.join(tmpdir, fname)) - except Exception: - pass - - @Slot(str) - def _set_working_directory(self, new_dir): - """Set current working directory on the main widget.""" - self.get_widget().set_working_directory(new_dir) - - # ---- Public API - # ------------------------------------------------------------------------- - - # ---- Spyder Kernels handlers registry functionality - def register_spyder_kernel_call_handler(self, handler_id, handler): - """ - Register a callback for it to be available for the kernels of new - clients. - - Parameters - ---------- - handler_id : str - Handler name to be registered and that will be used to - call the respective handler in the Spyder kernel. - handler : func - Callback function that will be called when the kernel calls - the handler. - - Returns - ------- - None. - """ - self.get_widget().register_spyder_kernel_call_handler( - handler_id, handler) - - def unregister_spyder_kernel_call_handler(self, handler_id): - """ - Unregister/remove a handler for not be added to new clients kernels - - Parameters - ---------- - handler_id : str - Handler name that was registered and that will be removed - from the Spyder kernel available handlers. - - Returns - ------- - None. - """ - self.get_widget().unregister_spyder_kernel_call_handler(handler_id) - - # ---- For client widgets - def get_clients(self): - """Return clients list""" - return self.get_widget().clients - - def get_focus_client(self): - """Return current client with focus, if any""" - return self.get_widget().get_focus_client() - - def get_current_client(self): - """Return the currently selected client""" - return self.get_widget().get_current_client() - - def get_current_shellwidget(self): - """Return the shellwidget of the current client""" - return self.get_widget().get_current_shellwidget() - - def rename_client_tab(self, client, given_name): - """ - Rename a client's tab. - - Parameters - ---------- - client: spyder.plugins.ipythonconsole.widgets.client.ClientWidget - Client to rename. - given_name: str - New name to be given to the client's tab. - - Returns - ------- - None. - """ - self.get_widget().rename_client_tab(client, given_name) - - def create_new_client(self, give_focus=True, filename='', is_cython=False, - is_pylab=False, is_sympy=False, given_name=None): - """ - Create a new client. - - Parameters - ---------- - give_focus : bool, optional - True if the new client should gain the window - focus, False otherwise. The default is True. - filename : str, optional - Filename associated with the client. The default is ''. - is_cython : bool, optional - True if the client is expected to preload Cython support, - False otherwise. The default is False. - is_pylab : bool, optional - True if the client is expected to preload PyLab support, - False otherwise. The default is False. - is_sympy : bool, optional - True if the client is expected to preload Sympy support, - False otherwise. The default is False. - given_name : str, optional - Initial name displayed in the tab of the client. - The default is None. - - Returns - ------- - None. - """ - self.get_widget().create_new_client( - give_focus=give_focus, - filename=filename, - is_cython=is_cython, - is_pylab=is_pylab, - is_sympy=is_sympy, - given_name=given_name) - - def create_client_for_file(self, filename, is_cython=False): - """ - Create a client widget to execute code related to a file. - - Parameters - ---------- - filename : str - File to be executed. - is_cython : bool, optional - If the execution is for a Cython file. The default is False. - - Returns - ------- - None. - """ - self.get_widget().create_client_for_file(filename, is_cython=is_cython) - - def create_client_for_kernel(self, connection_file, hostname=None, - sshkey=None, password=None): - """ - Create a client connected to an existing kernel. - - Parameters - ---------- - connection_file: str - Json file that has the kernel's connection info. - hostname: str, optional - Name or IP address of the remote machine where the kernel was - started. When this is provided, it's also necessary to pass either - the ``sshkey`` or ``password`` arguments. - sshkey: str, optional - SSH key file to connect to the remote machine where the kernel is - running. - password: str, optional - Password to authenticate to the remote machine where the kernel is - running. - - Returns - ------- - None. - """ - self.get_widget().create_client_for_kernel( - connection_file, hostname, sshkey, password) - - def get_client_for_file(self, filename): - """Get client associated with a given file name.""" - return self.get_widget().get_client_for_file(filename) - - def create_client_from_path(self, path): - """ - Create a new console with `path` set as the current working directory. - - Parameters - ---------- - path: str - Path to use as working directory in new console. - """ - self.get_widget().create_client_from_path(path) - - def close_client(self, index=None, client=None, ask_recursive=True): - """Close client tab from index or client (or close current tab)""" - self.get_widget().close_client(index=index, client=client, - ask_recursive=ask_recursive) - - # ---- For execution and debugging - def run_script(self, filename, wdir, args, debug, post_mortem, - current_client, clear_variables, console_namespace): - """ - Run script in current or dedicated client. - - Parameters - ---------- - filename : str - Path to file that will be run. - wdir : str - Working directory from where the file should be run. - args : str - Arguments defined to run the file. - debug : bool - True if the run if for debugging the file, - False for just running it. - post_mortem : bool - True if in case of error the execution should enter in - post-mortem mode, False otherwise. - current_client : bool - True if the execution should be done in the current client, - False if the execution needs to be done in a dedicated client. - clear_variables : bool - True if all the variables should be removed before execution, - False otherwise. - console_namespace : bool - True if the console namespace should be used, False otherwise. - - Returns - ------- - None. - """ - self.get_widget().run_script( - filename, - wdir, - args, - debug, - post_mortem, - current_client, - clear_variables, - console_namespace) - - def run_cell(self, code, cell_name, filename, run_cell_copy, - focus_to_editor, function='runcell'): - """ - Run cell in current or dedicated client. - - Parameters - ---------- - code : str - Piece of code to run that corresponds to a cell in case - `run_cell_copy` is True. - cell_name : str or int - Cell name or index. - filename : str - Path of the file where the cell to execute is located. - run_cell_copy : bool - True if the cell should be executed line by line, - False if the provided `function` should be used. - focus_to_editor: bool - Whether to give focus to the editor after running the cell. If - False, focus is given to the console. - function : str, optional - Name handler of the kernel function to be used to execute the cell - in case `run_cell_copy` is False. - The default is 'runcell'. - - Returns - ------- - None. - """ - self.get_widget().run_cell( - code, cell_name, filename, run_cell_copy, focus_to_editor, - function=function) - - def debug_cell(self, code, cell_name, filename, run_cell_copy, - focus_to_editor): - """ - Debug current cell. - - Parameters - ---------- - code : str - Piece of code to run that corresponds to a cell in case - `run_cell_copy` is True. - cell_name : str or int - Cell name or index. - filename : str - Path of the file where the cell to execute is located. - run_cell_copy : bool - True if the cell should be executed line by line, - False if the `debugcell` kernel function should be used. - focus_to_editor: bool - Whether to give focus to the editor after debugging the cell. If - False, focus is given to the console. - - Returns - ------- - None. - """ - self.get_widget().debug_cell(code, cell_name, filename, run_cell_copy, - focus_to_editor) - - def execute_code(self, lines, current_client=True, clear_variables=False): - """ - Execute code instructions. - - Parameters - ---------- - lines : str - Code lines to execute. - current_client : bool, optional - True if the execution should be done in the current client. - The default is True. - clear_variables : bool, optional - True if before the execution the variables should be cleared. - The default is False. - - Returns - ------- - None. - """ - self.get_widget().execute_code( - lines, - current_client=current_client, - clear_variables=clear_variables) - - def execute_code_and_focus_editor(self, lines, focus_to_editor=True): - """ - Execute lines in IPython console and eventually set focus - to the Editor. - """ - self.execute_code(lines) - if focus_to_editor and self.get_plugin(Plugins.Editor): - self._switch_to_editor() - else: - self.switch_to_plugin() - - def stop_debugging(self): - """Stop debugging in the current console.""" - self.get_widget().stop_debugging() - - def get_pdb_state(self): - """Get debugging state of the current console.""" - return self.get_widget().get_pdb_state() - - def get_pdb_last_step(self): - """Get last pdb step of the current console.""" - return self.get_widget().get_pdb_last_step() - - def pdb_execute_command(self, command): - """ - Send command to the pdb kernel if possible. - - Parameters - ---------- - command : str - Command to execute by the pdb kernel. - - Returns - ------- - None. - """ - self.get_widget().pdb_execute_command(command) - - def print_debug_file_msg(self): - """ - Print message in the current console when a file can't be closed. - - Returns - ------- - None. - """ - self.get_widget().print_debug_file_msg() - - # ---- For working directory and path management - def set_current_client_working_directory(self, directory): - """ - Set current client working directory. - - Parameters - ---------- - directory : str - Path for the new current working directory. - - Returns - ------- - None. - """ - self.get_widget().set_current_client_working_directory(directory) - - def set_working_directory(self, dirname): - """ - Set current working directory for the `workingdirectory` and `explorer` - plugins. - - Parameters - ---------- - dirname : str - Path to the new current working directory. - - Returns - ------- - None. - """ - self.get_widget().set_working_directory(dirname) - - def update_working_directory(self): - """Update working directory to console current working directory.""" - self.get_widget().update_working_directory() - - def update_path(self, path_dict, new_path_dict): - """ - Update path on consoles. - - Both parameters have as keys paths and as value if the path - should be used/is active (True) or not (False) - - Parameters - ---------- - path_dict : dict - Corresponds to the previous state of the PYTHONPATH. - new_path_dict : dict - Corresponds to the new state of the PYTHONPATH. - - Returns - ------- - None. - """ - self.get_widget().update_path(path_dict, new_path_dict) - - def set_spyder_breakpoints(self): - """Set Spyder breakpoints into all clients""" - self.get_widget().set_spyder_breakpoints() - - def restart(self): - """ - Restart the console. - - This is needed when we switch projects to update PYTHONPATH - and the selected interpreter. - """ - self.get_widget().restart() - - def restart_kernel(self): - """ - Restart the current client's kernel. - - Returns - ------- - None. - """ - self.get_widget().restart_kernel() - - # ---- For documentation and help - def show_intro(self): - """Show intro to IPython help.""" - self.get_widget().show_intro() - - def show_guiref(self): - """Show qtconsole help.""" - self.get_widget().show_guiref() - - def show_quickref(self): - """Show IPython Cheat Sheet.""" - self.get_widget().show_quickref() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +IPython Console plugin based on QtConsole. +""" + +# Standard library imports +import os +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal, Slot + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.ipythonconsole.confpage import IPythonConsoleConfigPage +from spyder.plugins.ipythonconsole.widgets.main_widget import ( + IPythonConsoleWidget, IPythonConsoleWidgetOptionsMenus) +from spyder.plugins.mainmenu.api import ( + ApplicationMenus, ConsolesMenuSections, HelpMenuSections) +from spyder.utils.programs import get_temp_dir + +# Localization +_ = get_translation('spyder') + + +class IPythonConsole(SpyderDockablePlugin): + """ + IPython Console plugin + + This is a widget with tabs where each one is a ClientWidget + """ + + # This is required for the new API + NAME = 'ipython_console' + REQUIRES = [Plugins.Console, Plugins.Preferences] + OPTIONAL = [Plugins.Editor, Plugins.History, Plugins.MainMenu, + Plugins.Projects, Plugins.WorkingDirectory] + TABIFY = [Plugins.History] + WIDGET_CLASS = IPythonConsoleWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = IPythonConsoleConfigPage + CONF_FILE = False + DISABLE_ACTIONS_WHEN_HIDDEN = False + RAISE_AND_FOCUS = True + + # Signals + sig_append_to_history_requested = Signal(str, str) + """ + This signal is emitted when the plugin requires to add commands to a + history file. + + Parameters + ---------- + filename: str + History file filename. + text: str + Text to append to the history file. + """ + + sig_history_requested = Signal(str) + """ + This signal is emitted when the plugin wants a specific history file + to be shown. + + Parameters + ---------- + path: str + Path to history file. + """ + + sig_focus_changed = Signal() + """ + This signal is emitted when the plugin focus changes. + """ + + sig_edit_goto_requested = Signal((str, int, str), (str, int, str, bool)) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + processevents: bool + True if the code editor need to process qt events when loading the + requested file. + """ + + sig_edit_new = Signal(str) + """ + This signal will request to create a new file in a code editor. + + Parameters + ---------- + path: str + Path to file. + """ + + sig_pdb_state_changed = Signal(bool, dict) + """ + This signal is emitted when the debugging state changes. + + Parameters + ---------- + waiting_pdb_input: bool + If the debugging session is waiting for input. + pdb_last_step: dict + Dictionary with the information of the last step done + in the debugging session. + """ + + sig_shellwidget_created = Signal(object) + """ + This signal is emitted when a shellwidget is connected to + a kernel. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + + sig_shellwidget_deleted = Signal(object) + """ + This signal is emitted when a shellwidget is disconnected from + a kernel. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + + sig_shellwidget_changed = Signal(object) + """ + This signal is emitted when the current shellwidget changes. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + + sig_external_spyder_kernel_connected = Signal(object) + """ + This signal is emitted when we connect to an external Spyder kernel. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet that was connected to the kernel. + """ + + sig_render_plain_text_requested = Signal(str) + """ + This signal is emitted to request a plain text help render. + + Parameters + ---------- + plain_text: str + The plain text to render. + """ + + sig_render_rich_text_requested = Signal(str, bool) + """ + This signal is emitted to request a rich text help render. + + Parameters + ---------- + rich_text: str + The rich text. + collapse: bool + If the text contains collapsed sections, show them closed (True) or + open (False). + """ + + sig_help_requested = Signal(dict) + """ + This signal is emitted to request help on a given object `name`. + + Parameters + ---------- + help_data: dict + Example `{'name': str, 'ignore_unknown': bool}`. + """ + + sig_current_directory_changed = Signal(str) + """ + This signal is emitted when the current directory of the active shell + widget has changed. + + Parameters + ---------- + working_directory: str + The new working directory path. + """ + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------- + @staticmethod + def get_name(): + return _('IPython console') + + def get_description(self): + return _('IPython console') + + def get_icon(self): + return self.create_icon('ipython_console') + + def on_initialize(self): + widget = self.get_widget() + widget.sig_append_to_history_requested.connect( + self.sig_append_to_history_requested) + widget.sig_focus_changed.connect(self.sig_focus_changed) + widget.sig_switch_to_plugin_requested.connect(self.switch_to_plugin) + widget.sig_history_requested.connect(self.sig_history_requested) + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_edit_goto_requested[str, int, str, bool].connect( + self.sig_edit_goto_requested[str, int, str, bool]) + widget.sig_edit_new.connect(self.sig_edit_new) + widget.sig_pdb_state_changed.connect(self.sig_pdb_state_changed) + widget.sig_shellwidget_created.connect(self.sig_shellwidget_created) + widget.sig_shellwidget_deleted.connect(self.sig_shellwidget_deleted) + widget.sig_shellwidget_changed.connect(self.sig_shellwidget_changed) + widget.sig_external_spyder_kernel_connected.connect( + self.sig_external_spyder_kernel_connected) + widget.sig_render_plain_text_requested.connect( + self.sig_render_plain_text_requested) + widget.sig_render_rich_text_requested.connect( + self.sig_render_rich_text_requested) + widget.sig_help_requested.connect(self.sig_help_requested) + widget.sig_current_directory_changed.connect( + self.sig_current_directory_changed) + widget.sig_exception_occurred.connect(self.sig_exception_occurred) + + # Update kernels if python path is changed + self.main.sig_pythonpath_changed.connect(self.update_path) + + self.sig_focus_changed.connect(self.main.plugin_focus_changed) + self._remove_old_std_files() + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + widget = self.get_widget() + mainmenu = self.get_plugin(Plugins.MainMenu) + + # Add signal to update actions state before showing the menu + console_menu = mainmenu.get_application_menu( + ApplicationMenus.Consoles) + console_menu.aboutToShow.connect( + widget.update_actions) + + # Main menu actions for the IPython Console + new_consoles_actions = [ + widget.create_client_action, + widget.special_console_menu, + widget.connect_to_kernel_action + ] + + restart_connect_consoles_actions = [ + widget.interrupt_action, + widget.restart_action, + widget.reset_action + ] + + # Console menu + for console_new_action in new_consoles_actions: + mainmenu.add_item_to_application_menu( + console_new_action, + menu_id=ApplicationMenus.Consoles, + section=ConsolesMenuSections.New, + ) + + for console_action in restart_connect_consoles_actions: + mainmenu.add_item_to_application_menu( + console_action, + menu_id=ApplicationMenus.Consoles, + section=ConsolesMenuSections.Restart, + ) + + # IPython documentation + mainmenu.add_item_to_application_menu( + self.get_widget().ipython_menu, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.ExternalDocumentation, + before_section=HelpMenuSections.About, + ) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + self.sig_edit_goto_requested.connect(editor.load) + self.sig_edit_goto_requested[str, int, str, bool].connect( + self._load_file_in_editor) + self.sig_edit_new.connect(editor.new) + editor.breakpoints_saved.connect(self.set_spyder_breakpoints) + editor.run_in_current_ipyclient.connect(self.run_script) + editor.run_cell_in_ipyclient.connect(self.run_cell) + editor.debug_cell_in_ipyclient.connect(self.debug_cell) + + # Connect Editor debug action with Console + self.sig_pdb_state_changed.connect(editor.update_pdb_state) + editor.exec_in_extconsole.connect(self.execute_code_and_focus_editor) + editor.sig_file_debug_message_requested.connect( + self.print_debug_file_msg) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self._on_project_loaded) + projects.sig_project_closed.connect(self._on_project_closed) + + @on_plugin_available(plugin=Plugins.WorkingDirectory) + def on_working_directory_available(self): + working_directory = self.get_plugin(Plugins.WorkingDirectory) + working_directory.sig_current_directory_changed.connect( + self._set_working_directory) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_application_menu(ApplicationMenus.Consoles) + + # IPython documentation menu + mainmenu.remove_item_from_application_menu( + IPythonConsoleWidgetOptionsMenus.Documentation, + menu_id=ApplicationMenus.Help + ) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + self.sig_edit_goto_requested.disconnect(editor.load) + self.sig_edit_goto_requested[str, int, str, bool].disconnect( + self._load_file_in_editor) + self.sig_edit_new.disconnect(editor.new) + editor.breakpoints_saved.disconnect(self.set_spyder_breakpoints) + editor.run_in_current_ipyclient.disconnect(self.run_script) + editor.run_cell_in_ipyclient.disconnect(self.run_cell) + editor.debug_cell_in_ipyclient.disconnect(self.debug_cell) + + # Connect Editor debug action with Console + self.sig_pdb_state_changed.disconnect(editor.update_pdb_state) + editor.exec_in_extconsole.disconnect( + self.execute_code_and_focus_editor) + editor.sig_file_debug_message_requested.disconnect( + self.print_debug_file_msg) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardown(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self._on_project_loaded) + projects.sig_project_closed.disconnect(self._on_project_closed) + + @on_plugin_teardown(plugin=Plugins.WorkingDirectory) + def on_working_directory_teardown(self): + working_directory = self.get_plugin(Plugins.WorkingDirectory) + working_directory.sig_current_directory_changed.disconnect( + self._set_working_directory) + + def update_font(self): + """Update font from Preferences""" + font = self.get_font() + rich_font = self.get_font(rich_text=True) + self.get_widget().update_font(font, rich_font) + + def on_close(self, cancelable=False): + """Perform actions when plugin is closed""" + self.get_widget().mainwindow_close = True + return self.get_widget().close_clients() + + def on_mainwindow_visible(self): + self.create_new_client(give_focus=False) + + # Raise plugin the first time Spyder starts + if self.get_conf('show_first_time', default=True): + self.dockwidget.raise_() + self.set_conf('show_first_time', False) + + # ---- Private methods + # ------------------------------------------------------------------------- + def _load_file_in_editor(self, fname, lineno, word, processevents): + editor = self.get_plugin(Plugins.Editor) + editor.load(fname, lineno, word, processevents=processevents) + + def _switch_to_editor(self): + editor = self.get_plugin(Plugins.Editor) + editor.switch_to_plugin() + + def _on_project_loaded(self): + projects = self.get_plugin(Plugins.Projects) + self.get_widget().update_active_project_path( + projects.get_active_project_path()) + + def _on_project_closed(self): + self.get_widget().update_active_project_path(None) + + def _remove_old_std_files(self): + """ + Remove std files left by previous Spyder instances. + + This is only required on Windows because we can't + clean up std files while Spyder is running on that + platform. + """ + if os.name == 'nt': + tmpdir = get_temp_dir() + for fname in os.listdir(tmpdir): + if osp.splitext(fname)[1] in ('.stderr', '.stdout', '.fault'): + try: + os.remove(osp.join(tmpdir, fname)) + except Exception: + pass + + @Slot(str) + def _set_working_directory(self, new_dir): + """Set current working directory on the main widget.""" + self.get_widget().set_working_directory(new_dir) + + # ---- Public API + # ------------------------------------------------------------------------- + + # ---- Spyder Kernels handlers registry functionality + def register_spyder_kernel_call_handler(self, handler_id, handler): + """ + Register a callback for it to be available for the kernels of new + clients. + + Parameters + ---------- + handler_id : str + Handler name to be registered and that will be used to + call the respective handler in the Spyder kernel. + handler : func + Callback function that will be called when the kernel calls + the handler. + + Returns + ------- + None. + """ + self.get_widget().register_spyder_kernel_call_handler( + handler_id, handler) + + def unregister_spyder_kernel_call_handler(self, handler_id): + """ + Unregister/remove a handler for not be added to new clients kernels + + Parameters + ---------- + handler_id : str + Handler name that was registered and that will be removed + from the Spyder kernel available handlers. + + Returns + ------- + None. + """ + self.get_widget().unregister_spyder_kernel_call_handler(handler_id) + + # ---- For client widgets + def get_clients(self): + """Return clients list""" + return self.get_widget().clients + + def get_focus_client(self): + """Return current client with focus, if any""" + return self.get_widget().get_focus_client() + + def get_current_client(self): + """Return the currently selected client""" + return self.get_widget().get_current_client() + + def get_current_shellwidget(self): + """Return the shellwidget of the current client""" + return self.get_widget().get_current_shellwidget() + + def rename_client_tab(self, client, given_name): + """ + Rename a client's tab. + + Parameters + ---------- + client: spyder.plugins.ipythonconsole.widgets.client.ClientWidget + Client to rename. + given_name: str + New name to be given to the client's tab. + + Returns + ------- + None. + """ + self.get_widget().rename_client_tab(client, given_name) + + def create_new_client(self, give_focus=True, filename='', is_cython=False, + is_pylab=False, is_sympy=False, given_name=None): + """ + Create a new client. + + Parameters + ---------- + give_focus : bool, optional + True if the new client should gain the window + focus, False otherwise. The default is True. + filename : str, optional + Filename associated with the client. The default is ''. + is_cython : bool, optional + True if the client is expected to preload Cython support, + False otherwise. The default is False. + is_pylab : bool, optional + True if the client is expected to preload PyLab support, + False otherwise. The default is False. + is_sympy : bool, optional + True if the client is expected to preload Sympy support, + False otherwise. The default is False. + given_name : str, optional + Initial name displayed in the tab of the client. + The default is None. + + Returns + ------- + None. + """ + self.get_widget().create_new_client( + give_focus=give_focus, + filename=filename, + is_cython=is_cython, + is_pylab=is_pylab, + is_sympy=is_sympy, + given_name=given_name) + + def create_client_for_file(self, filename, is_cython=False): + """ + Create a client widget to execute code related to a file. + + Parameters + ---------- + filename : str + File to be executed. + is_cython : bool, optional + If the execution is for a Cython file. The default is False. + + Returns + ------- + None. + """ + self.get_widget().create_client_for_file(filename, is_cython=is_cython) + + def create_client_for_kernel(self, connection_file, hostname=None, + sshkey=None, password=None): + """ + Create a client connected to an existing kernel. + + Parameters + ---------- + connection_file: str + Json file that has the kernel's connection info. + hostname: str, optional + Name or IP address of the remote machine where the kernel was + started. When this is provided, it's also necessary to pass either + the ``sshkey`` or ``password`` arguments. + sshkey: str, optional + SSH key file to connect to the remote machine where the kernel is + running. + password: str, optional + Password to authenticate to the remote machine where the kernel is + running. + + Returns + ------- + None. + """ + self.get_widget().create_client_for_kernel( + connection_file, hostname, sshkey, password) + + def get_client_for_file(self, filename): + """Get client associated with a given file name.""" + return self.get_widget().get_client_for_file(filename) + + def create_client_from_path(self, path): + """ + Create a new console with `path` set as the current working directory. + + Parameters + ---------- + path: str + Path to use as working directory in new console. + """ + self.get_widget().create_client_from_path(path) + + def close_client(self, index=None, client=None, ask_recursive=True): + """Close client tab from index or client (or close current tab)""" + self.get_widget().close_client(index=index, client=client, + ask_recursive=ask_recursive) + + # ---- For execution and debugging + def run_script(self, filename, wdir, args, debug, post_mortem, + current_client, clear_variables, console_namespace): + """ + Run script in current or dedicated client. + + Parameters + ---------- + filename : str + Path to file that will be run. + wdir : str + Working directory from where the file should be run. + args : str + Arguments defined to run the file. + debug : bool + True if the run if for debugging the file, + False for just running it. + post_mortem : bool + True if in case of error the execution should enter in + post-mortem mode, False otherwise. + current_client : bool + True if the execution should be done in the current client, + False if the execution needs to be done in a dedicated client. + clear_variables : bool + True if all the variables should be removed before execution, + False otherwise. + console_namespace : bool + True if the console namespace should be used, False otherwise. + + Returns + ------- + None. + """ + self.get_widget().run_script( + filename, + wdir, + args, + debug, + post_mortem, + current_client, + clear_variables, + console_namespace) + + def run_cell(self, code, cell_name, filename, run_cell_copy, + focus_to_editor, function='runcell'): + """ + Run cell in current or dedicated client. + + Parameters + ---------- + code : str + Piece of code to run that corresponds to a cell in case + `run_cell_copy` is True. + cell_name : str or int + Cell name or index. + filename : str + Path of the file where the cell to execute is located. + run_cell_copy : bool + True if the cell should be executed line by line, + False if the provided `function` should be used. + focus_to_editor: bool + Whether to give focus to the editor after running the cell. If + False, focus is given to the console. + function : str, optional + Name handler of the kernel function to be used to execute the cell + in case `run_cell_copy` is False. + The default is 'runcell'. + + Returns + ------- + None. + """ + self.get_widget().run_cell( + code, cell_name, filename, run_cell_copy, focus_to_editor, + function=function) + + def debug_cell(self, code, cell_name, filename, run_cell_copy, + focus_to_editor): + """ + Debug current cell. + + Parameters + ---------- + code : str + Piece of code to run that corresponds to a cell in case + `run_cell_copy` is True. + cell_name : str or int + Cell name or index. + filename : str + Path of the file where the cell to execute is located. + run_cell_copy : bool + True if the cell should be executed line by line, + False if the `debugcell` kernel function should be used. + focus_to_editor: bool + Whether to give focus to the editor after debugging the cell. If + False, focus is given to the console. + + Returns + ------- + None. + """ + self.get_widget().debug_cell(code, cell_name, filename, run_cell_copy, + focus_to_editor) + + def execute_code(self, lines, current_client=True, clear_variables=False): + """ + Execute code instructions. + + Parameters + ---------- + lines : str + Code lines to execute. + current_client : bool, optional + True if the execution should be done in the current client. + The default is True. + clear_variables : bool, optional + True if before the execution the variables should be cleared. + The default is False. + + Returns + ------- + None. + """ + self.get_widget().execute_code( + lines, + current_client=current_client, + clear_variables=clear_variables) + + def execute_code_and_focus_editor(self, lines, focus_to_editor=True): + """ + Execute lines in IPython console and eventually set focus + to the Editor. + """ + self.execute_code(lines) + if focus_to_editor and self.get_plugin(Plugins.Editor): + self._switch_to_editor() + else: + self.switch_to_plugin() + + def stop_debugging(self): + """Stop debugging in the current console.""" + self.get_widget().stop_debugging() + + def get_pdb_state(self): + """Get debugging state of the current console.""" + return self.get_widget().get_pdb_state() + + def get_pdb_last_step(self): + """Get last pdb step of the current console.""" + return self.get_widget().get_pdb_last_step() + + def pdb_execute_command(self, command): + """ + Send command to the pdb kernel if possible. + + Parameters + ---------- + command : str + Command to execute by the pdb kernel. + + Returns + ------- + None. + """ + self.get_widget().pdb_execute_command(command) + + def print_debug_file_msg(self): + """ + Print message in the current console when a file can't be closed. + + Returns + ------- + None. + """ + self.get_widget().print_debug_file_msg() + + # ---- For working directory and path management + def set_current_client_working_directory(self, directory): + """ + Set current client working directory. + + Parameters + ---------- + directory : str + Path for the new current working directory. + + Returns + ------- + None. + """ + self.get_widget().set_current_client_working_directory(directory) + + def set_working_directory(self, dirname): + """ + Set current working directory for the `workingdirectory` and `explorer` + plugins. + + Parameters + ---------- + dirname : str + Path to the new current working directory. + + Returns + ------- + None. + """ + self.get_widget().set_working_directory(dirname) + + def update_working_directory(self): + """Update working directory to console current working directory.""" + self.get_widget().update_working_directory() + + def update_path(self, path_dict, new_path_dict): + """ + Update path on consoles. + + Both parameters have as keys paths and as value if the path + should be used/is active (True) or not (False) + + Parameters + ---------- + path_dict : dict + Corresponds to the previous state of the PYTHONPATH. + new_path_dict : dict + Corresponds to the new state of the PYTHONPATH. + + Returns + ------- + None. + """ + self.get_widget().update_path(path_dict, new_path_dict) + + def set_spyder_breakpoints(self): + """Set Spyder breakpoints into all clients""" + self.get_widget().set_spyder_breakpoints() + + def restart(self): + """ + Restart the console. + + This is needed when we switch projects to update PYTHONPATH + and the selected interpreter. + """ + self.get_widget().restart() + + def restart_kernel(self): + """ + Restart the current client's kernel. + + Returns + ------- + None. + """ + self.get_widget().restart_kernel() + + # ---- For documentation and help + def show_intro(self): + """Show intro to IPython help.""" + self.get_widget().show_intro() + + def show_guiref(self): + """Show qtconsole help.""" + self.get_widget().show_guiref() + + def show_quickref(self): + """Show IPython Cheat Sheet.""" + self.get_widget().show_quickref() diff --git a/spyder/plugins/ipythonconsole/utils/manager.py b/spyder/plugins/ipythonconsole/utils/manager.py index 1bc420fae4a..987629ed715 100644 --- a/spyder/plugins/ipythonconsole/utils/manager.py +++ b/spyder/plugins/ipythonconsole/utils/manager.py @@ -1,120 +1,120 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -"""Kernel Manager subclass.""" - -# Standard library imports -import os -import signal - -# Third party imports -from jupyter_client.utils import run_sync -import psutil -from qtconsole.manager import QtKernelManager - - -class SpyderKernelManager(QtKernelManager): - """ - Spyder kernels that live in a conda environment are now properly activated - with custom activation scripts located at plugins/ipythonconsole/scripts. - - However, on windows the batch script is terminated but not the kernel it - started so this subclass overrides the `_kill_kernel` method to properly - kill the started kernels by using psutil. - """ - - def __init__(self, *args, **kwargs): - self.shutting_down = False - return QtKernelManager.__init__(self, *args, **kwargs) - - @staticmethod - async def kill_proc_tree(pid, sig=signal.SIGTERM, include_parent=True, - timeout=None, on_terminate=None): - """ - Kill a process tree (including grandchildren) with sig and return a - (gone, still_alive) tuple. - - "on_terminate", if specified, is a callabck function which is called - as soon as a child terminates. - - This is an new method not present in QtKernelManager. - """ - assert pid != os.getpid() # Won't kill myself! - - # This is necessary to avoid showing an error when restarting the - # kernel after it failed to start in the first place. - # Fixes spyder-ide/spyder#11872 - try: - parent = psutil.Process(pid) - except psutil.NoSuchProcess: - return ([], []) - - children = parent.children(recursive=True) - - if include_parent: - children.append(parent) - - for child_process in children: - # This is necessary to avoid an error when restarting the - # kernel that started a PyQt5 application in the background. - # Fixes spyder-ide/spyder#13999 - try: - child_process.send_signal(sig) - except psutil.AccessDenied: - return ([], []) - - gone, alive = psutil.wait_procs( - children, - timeout=timeout, - callback=on_terminate, - ) - - return (gone, alive) - - async def _async_kill_kernel(self, restart: bool = False) -> None: - """Kill the running kernel. - Override private method of jupyter_client 7 to be able to correctly - close kernel that was started via a batch/bash script for correct conda - env activation. - """ - if self.has_kernel: - assert self.provisioner is not None - - # This is the additional line that was added to properly - # kill the kernel started by Spyder. - await self.kill_proc_tree(self.provisioner.process.pid) - - await self.provisioner.kill(restart=restart) - - # Wait until the kernel terminates. - import asyncio - try: - await asyncio.wait_for(self._async_wait(), timeout=5.0) - except asyncio.TimeoutError: - # Wait timed out, just log warning but continue - # - not much more we can do. - self.log.warning("Wait for final termination of kernel timed" - " out - continuing...") - pass - else: - # Process is no longer alive, wait and clear - if self.has_kernel: - await self.provisioner.wait() - - _kill_kernel = run_sync(_async_kill_kernel) - - async def _async_send_kernel_sigterm(self, restart: bool = False) -> None: - """similar to _kill_kernel, but with sigterm (not sigkill), but do not block""" - if self.has_kernel: - assert self.provisioner is not None - - # This is the line that was added to properly kill kernels started - # by Spyder. - await self.kill_proc_tree(self.provisioner.process.pid) - - _send_kernel_sigterm = run_sync(_async_send_kernel_sigterm) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +"""Kernel Manager subclass.""" + +# Standard library imports +import os +import signal + +# Third party imports +from jupyter_client.utils import run_sync +import psutil +from qtconsole.manager import QtKernelManager + + +class SpyderKernelManager(QtKernelManager): + """ + Spyder kernels that live in a conda environment are now properly activated + with custom activation scripts located at plugins/ipythonconsole/scripts. + + However, on windows the batch script is terminated but not the kernel it + started so this subclass overrides the `_kill_kernel` method to properly + kill the started kernels by using psutil. + """ + + def __init__(self, *args, **kwargs): + self.shutting_down = False + return QtKernelManager.__init__(self, *args, **kwargs) + + @staticmethod + async def kill_proc_tree(pid, sig=signal.SIGTERM, include_parent=True, + timeout=None, on_terminate=None): + """ + Kill a process tree (including grandchildren) with sig and return a + (gone, still_alive) tuple. + + "on_terminate", if specified, is a callabck function which is called + as soon as a child terminates. + + This is an new method not present in QtKernelManager. + """ + assert pid != os.getpid() # Won't kill myself! + + # This is necessary to avoid showing an error when restarting the + # kernel after it failed to start in the first place. + # Fixes spyder-ide/spyder#11872 + try: + parent = psutil.Process(pid) + except psutil.NoSuchProcess: + return ([], []) + + children = parent.children(recursive=True) + + if include_parent: + children.append(parent) + + for child_process in children: + # This is necessary to avoid an error when restarting the + # kernel that started a PyQt5 application in the background. + # Fixes spyder-ide/spyder#13999 + try: + child_process.send_signal(sig) + except psutil.AccessDenied: + return ([], []) + + gone, alive = psutil.wait_procs( + children, + timeout=timeout, + callback=on_terminate, + ) + + return (gone, alive) + + async def _async_kill_kernel(self, restart: bool = False) -> None: + """Kill the running kernel. + Override private method of jupyter_client 7 to be able to correctly + close kernel that was started via a batch/bash script for correct conda + env activation. + """ + if self.has_kernel: + assert self.provisioner is not None + + # This is the additional line that was added to properly + # kill the kernel started by Spyder. + await self.kill_proc_tree(self.provisioner.process.pid) + + await self.provisioner.kill(restart=restart) + + # Wait until the kernel terminates. + import asyncio + try: + await asyncio.wait_for(self._async_wait(), timeout=5.0) + except asyncio.TimeoutError: + # Wait timed out, just log warning but continue + # - not much more we can do. + self.log.warning("Wait for final termination of kernel timed" + " out - continuing...") + pass + else: + # Process is no longer alive, wait and clear + if self.has_kernel: + await self.provisioner.wait() + + _kill_kernel = run_sync(_async_kill_kernel) + + async def _async_send_kernel_sigterm(self, restart: bool = False) -> None: + """similar to _kill_kernel, but with sigterm (not sigkill), but do not block""" + if self.has_kernel: + assert self.provisioner is not None + + # This is the line that was added to properly kill kernels started + # by Spyder. + await self.kill_proc_tree(self.provisioner.process.pid) + + _send_kernel_sigterm = run_sync(_async_send_kernel_sigterm) diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 86efdf9626c..846f84497f4 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -1,937 +1,937 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - - -""" -Client widget for the IPython Console. - -This is the widget used on all its tabs. -""" - -# Standard library imports. -import logging -import os -import os.path as osp -import re -from string import Template -import time - -# Third party imports (qtpy) -from qtpy.QtCore import QUrl, QTimer, Signal, Slot, QThread -from qtpy.QtWidgets import (QMessageBox, QVBoxLayout, QWidget) - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import ( - get_home_dir, get_module_source_path, running_under_pytest) -from spyder.utils.icon_manager import ima -from spyder.utils import sourcecode -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.installers import InstallerIPythonKernelError -from spyder.utils.encoding import get_coding -from spyder.utils.environ import RemoteEnvDialog -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import add_actions, DialogManager -from spyder.py3compat import to_text_string -from spyder.plugins.ipythonconsole.widgets import ShellWidget -from spyder.widgets.collectionseditor import CollectionsEditor -from spyder.widgets.mixins import SaveHistoryMixin - - -# Localization and logging -_ = get_translation('spyder') -logger = logging.getLogger(__name__) - -# ----------------------------------------------------------------------------- -# Templates -# ----------------------------------------------------------------------------- -# Using the same css file from the Help plugin for now. Maybe -# later it'll be a good idea to create a new one. -PLUGINS_PATH = get_module_source_path('spyder', 'plugins') - -CSS_PATH = osp.join(PLUGINS_PATH, 'help', 'utils', 'static', 'css') -TEMPLATES_PATH = osp.join( - PLUGINS_PATH, 'ipythonconsole', 'assets', 'templates') - -BLANK = open(osp.join(TEMPLATES_PATH, 'blank.html')).read() -LOADING = open(osp.join(TEMPLATES_PATH, 'loading.html')).read() -KERNEL_ERROR = open(osp.join(TEMPLATES_PATH, 'kernel_error.html')).read() - -try: - time.monotonic # time.monotonic new in 3.3 -except AttributeError: - time.monotonic = time.time - -# ---------------------------------------------------------------------------- -# Client widget -# ---------------------------------------------------------------------------- -class ClientWidget(QWidget, SaveHistoryMixin, SpyderWidgetMixin): - """ - Client widget for the IPython Console - - This widget is necessary to handle the interaction between the - plugin and each shell widget. - """ - - sig_append_to_history_requested = Signal(str, str) - sig_execution_state_changed = Signal() - - CONF_SECTION = 'ipython_console' - SEPARATOR = '{0}## ---({1})---'.format(os.linesep*2, time.ctime()) - INITHISTORY = ['# -*- coding: utf-8 -*-', - '# *** Spyder Python Console History Log ***', ] - - def __init__(self, parent, id_, - history_filename, config_options, - additional_options, interpreter_versions, - connection_file=None, hostname=None, - context_menu_actions=(), - menu_actions=None, - is_external_kernel=False, - is_spyder_kernel=True, - given_name=None, - give_focus=True, - options_button=None, - time_label=None, - show_elapsed_time=False, - reset_warning=True, - ask_before_restart=True, - ask_before_closing=False, - css_path=None, - handlers={}, - stderr_obj=None, - stdout_obj=None, - fault_obj=None): - super(ClientWidget, self).__init__(parent) - SaveHistoryMixin.__init__(self, history_filename) - - # --- Init attrs - self.container = parent - self.id_ = id_ - self.connection_file = connection_file - self.hostname = hostname - self.menu_actions = menu_actions - self.is_external_kernel = is_external_kernel - self.given_name = given_name - self.show_elapsed_time = show_elapsed_time - self.reset_warning = reset_warning - self.ask_before_restart = ask_before_restart - self.ask_before_closing = ask_before_closing - - # --- Other attrs - self.context_menu_actions = context_menu_actions - self.time_label = time_label - self.options_button = options_button - self.history = [] - self.allow_rename = True - self.is_error_shown = False - self.error_text = None - self.restart_thread = None - self.give_focus = give_focus - - if css_path is None: - self.css_path = CSS_PATH - else: - self.css_path = css_path - - # --- Widgets - self.shellwidget = ShellWidget( - config=config_options, - ipyclient=self, - additional_options=additional_options, - interpreter_versions=interpreter_versions, - is_external_kernel=is_external_kernel, - is_spyder_kernel=is_spyder_kernel, - handlers=handlers, - local_kernel=True - ) - self.infowidget = self.container.infowidget - self.blank_page = self._create_blank_page() - self.loading_page = self._create_loading_page() - # To keep a reference to the page to be displayed - # in infowidget - self.info_page = None - self._before_prompt_is_ready() - - # Elapsed time - self.t0 = time.monotonic() - self.timer = QTimer(self) - - # --- Layout - self.layout = QVBoxLayout() - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.addWidget(self.shellwidget) - if self.infowidget is not None: - self.layout.addWidget(self.infowidget) - self.setLayout(self.layout) - - # --- Exit function - self.exit_callback = lambda: self.container.close_client(client=self) - - # --- Dialog manager - self.dialog_manager = DialogManager() - - # --- Standard files handling - self.stderr_obj = stderr_obj - self.stdout_obj = stdout_obj - self.fault_obj = fault_obj - self.std_poll_timer = None - if self.stderr_obj is not None or self.stdout_obj is not None: - self.std_poll_timer = QTimer(self) - self.std_poll_timer.timeout.connect(self.poll_std_file_change) - self.std_poll_timer.setInterval(1000) - self.std_poll_timer.start() - self.shellwidget.executed.connect(self.poll_std_file_change) - - self.start_successful = False - - def __del__(self): - """Close threads to avoid segfault.""" - if (self.restart_thread is not None - and self.restart_thread.isRunning()): - self.restart_thread.quit() - self.restart_thread.wait() - - # ----- Private methods --------------------------------------------------- - def _before_prompt_is_ready(self, show_loading_page=True): - """Configuration before kernel is connected.""" - if show_loading_page: - self._show_loading_page() - self.shellwidget.sig_prompt_ready.connect( - self._when_prompt_is_ready) - # If remote execution, the loading page should be hidden as well - self.shellwidget.sig_remote_execute.connect( - self._when_prompt_is_ready) - - def _when_prompt_is_ready(self): - """Configuration after the prompt is shown.""" - self.start_successful = True - # To hide the loading page - self._hide_loading_page() - - # Show possible errors when setting Matplotlib backend - self._show_mpl_backend_errors() - - # To show if special console is valid - self._check_special_console_error() - - # Set the initial current working directory - self._set_initial_cwd() - - self.shellwidget.sig_prompt_ready.disconnect( - self._when_prompt_is_ready) - self.shellwidget.sig_remote_execute.disconnect( - self._when_prompt_is_ready) - - # It's necessary to do this at this point to avoid giving - # focus to _control at startup. - self._connect_control_signals() - - if self.give_focus: - self.shellwidget._control.setFocus() - - def _create_loading_page(self): - """Create html page to show while the kernel is starting""" - loading_template = Template(LOADING) - loading_img = get_image_path('loading_sprites') - if os.name == 'nt': - loading_img = loading_img.replace('\\', '/') - message = _("Connecting to kernel...") - page = loading_template.substitute(css_path=self.css_path, - loading_img=loading_img, - message=message) - return page - - def _create_blank_page(self): - """Create html page to show while the kernel is starting""" - loading_template = Template(BLANK) - page = loading_template.substitute(css_path=self.css_path) - return page - - def _show_loading_page(self): - """Show animation while the kernel is loading.""" - if self.infowidget is not None: - self.shellwidget.hide() - self.infowidget.show() - self.info_page = self.loading_page - self.set_info_page() - - def _hide_loading_page(self): - """Hide animation shown while the kernel is loading.""" - if self.infowidget is not None: - self.infowidget.hide() - self.info_page = self.blank_page - self.set_info_page() - self.shellwidget.show() - - def _read_stderr(self): - """Read the stderr file of the kernel.""" - # We need to read stderr_file as bytes to be able to - # detect its encoding with chardet - f = open(self.stderr_file, 'rb') - - try: - stderr_text = f.read() - - # This is needed to avoid showing an empty error message - # when the kernel takes too much time to start. - # See spyder-ide/spyder#8581. - if not stderr_text: - return '' - - # This is needed since the stderr file could be encoded - # in something different to utf-8. - # See spyder-ide/spyder#4191. - encoding = get_coding(stderr_text) - stderr_text = to_text_string(stderr_text, encoding) - return stderr_text - finally: - f.close() - - def _show_mpl_backend_errors(self): - """ - Show possible errors when setting the selected Matplotlib backend. - """ - if self.shellwidget.is_spyder_kernel: - self.shellwidget.call_kernel().show_mpl_backend_errors() - - def _check_special_console_error(self): - """Check if the dependecies for special consoles are available.""" - self.shellwidget.call_kernel( - callback=self._show_special_console_error - ).is_special_kernel_valid() - - def _show_special_console_error(self, missing_dependency): - if missing_dependency is not None: - error_message = _( - "Your Python environment or installation doesn't have the " - "{missing_dependency} module installed or it " - "occurred a problem importing it. Due to that, it is not " - "possible for Spyder to create this special console for " - "you." - ).format(missing_dependency=missing_dependency) - - self.show_kernel_error(error_message) - - def _abort_kernel_restart(self): - """ - Abort kernel restart if there are errors while starting it. - - We also ignore errors about comms, which are irrelevant. - """ - if self.start_successful: - return False - stderr = self.stderr_obj.get_contents() - if not stderr: - return False - # There is an error. If it is benign, ignore. - for line in stderr.splitlines(): - if line and not self.is_benign_error(line): - return True - return False - - def _connect_control_signals(self): - """Connect signals of control widgets.""" - control = self.shellwidget._control - page_control = self.shellwidget._page_control - - control.sig_focus_changed.connect( - self.container.sig_focus_changed) - page_control.sig_focus_changed.connect( - self.container.sig_focus_changed) - control.sig_visibility_changed.connect( - self.container.refresh_container) - page_control.sig_visibility_changed.connect( - self.container.refresh_container) - page_control.sig_show_find_widget_requested.connect( - self.container.find_widget.show) - - def _set_initial_cwd(self): - """Set initial cwd according to preferences.""" - logger.debug("Setting initial working directory") - cwd_path = get_home_dir() - project_path = self.container.get_active_project_path() - - # This is for the first client - if self.id_['int_id'] == '1': - if self.get_conf( - 'startup/use_project_or_home_directory', - section='workingdir' - ): - cwd_path = get_home_dir() - if project_path is not None: - cwd_path = project_path - elif self.get_conf( - 'startup/use_fixed_directory', - section='workingdir' - ): - cwd_path = self.get_conf( - 'startup/fixed_directory', - default=get_home_dir(), - section='workingdir' - ) - else: - # For new clients - if self.get_conf( - 'console/use_project_or_home_directory', - section='workingdir' - ): - cwd_path = get_home_dir() - if project_path is not None: - cwd_path = project_path - elif self.get_conf('console/use_cwd', section='workingdir'): - cwd_path = self.container.get_working_directory() - elif self.get_conf( - 'console/use_fixed_directory', - section='workingdir' - ): - cwd_path = self.get_conf( - 'console/fixed_directory', - default=get_home_dir(), - section='workingdir' - ) - - if osp.isdir(cwd_path): - self.shellwidget.set_cwd(cwd_path) - - # ----- Public API -------------------------------------------------------- - @property - def kernel_id(self): - """Get kernel id.""" - if self.connection_file is not None: - json_file = osp.basename(self.connection_file) - return json_file.split('.json')[0] - - def remove_std_files(self, is_last_client=True): - """Remove stderr_file associated with the client.""" - try: - self.shellwidget.executed.disconnect(self.poll_std_file_change) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + + +""" +Client widget for the IPython Console. + +This is the widget used on all its tabs. +""" + +# Standard library imports. +import logging +import os +import os.path as osp +import re +from string import Template +import time + +# Third party imports (qtpy) +from qtpy.QtCore import QUrl, QTimer, Signal, Slot, QThread +from qtpy.QtWidgets import (QMessageBox, QVBoxLayout, QWidget) + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import ( + get_home_dir, get_module_source_path, running_under_pytest) +from spyder.utils.icon_manager import ima +from spyder.utils import sourcecode +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.installers import InstallerIPythonKernelError +from spyder.utils.encoding import get_coding +from spyder.utils.environ import RemoteEnvDialog +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import add_actions, DialogManager +from spyder.py3compat import to_text_string +from spyder.plugins.ipythonconsole.widgets import ShellWidget +from spyder.widgets.collectionseditor import CollectionsEditor +from spyder.widgets.mixins import SaveHistoryMixin + + +# Localization and logging +_ = get_translation('spyder') +logger = logging.getLogger(__name__) + +# ----------------------------------------------------------------------------- +# Templates +# ----------------------------------------------------------------------------- +# Using the same css file from the Help plugin for now. Maybe +# later it'll be a good idea to create a new one. +PLUGINS_PATH = get_module_source_path('spyder', 'plugins') + +CSS_PATH = osp.join(PLUGINS_PATH, 'help', 'utils', 'static', 'css') +TEMPLATES_PATH = osp.join( + PLUGINS_PATH, 'ipythonconsole', 'assets', 'templates') + +BLANK = open(osp.join(TEMPLATES_PATH, 'blank.html')).read() +LOADING = open(osp.join(TEMPLATES_PATH, 'loading.html')).read() +KERNEL_ERROR = open(osp.join(TEMPLATES_PATH, 'kernel_error.html')).read() + +try: + time.monotonic # time.monotonic new in 3.3 +except AttributeError: + time.monotonic = time.time + +# ---------------------------------------------------------------------------- +# Client widget +# ---------------------------------------------------------------------------- +class ClientWidget(QWidget, SaveHistoryMixin, SpyderWidgetMixin): + """ + Client widget for the IPython Console + + This widget is necessary to handle the interaction between the + plugin and each shell widget. + """ + + sig_append_to_history_requested = Signal(str, str) + sig_execution_state_changed = Signal() + + CONF_SECTION = 'ipython_console' + SEPARATOR = '{0}## ---({1})---'.format(os.linesep*2, time.ctime()) + INITHISTORY = ['# -*- coding: utf-8 -*-', + '# *** Spyder Python Console History Log ***', ] + + def __init__(self, parent, id_, + history_filename, config_options, + additional_options, interpreter_versions, + connection_file=None, hostname=None, + context_menu_actions=(), + menu_actions=None, + is_external_kernel=False, + is_spyder_kernel=True, + given_name=None, + give_focus=True, + options_button=None, + time_label=None, + show_elapsed_time=False, + reset_warning=True, + ask_before_restart=True, + ask_before_closing=False, + css_path=None, + handlers={}, + stderr_obj=None, + stdout_obj=None, + fault_obj=None): + super(ClientWidget, self).__init__(parent) + SaveHistoryMixin.__init__(self, history_filename) + + # --- Init attrs + self.container = parent + self.id_ = id_ + self.connection_file = connection_file + self.hostname = hostname + self.menu_actions = menu_actions + self.is_external_kernel = is_external_kernel + self.given_name = given_name + self.show_elapsed_time = show_elapsed_time + self.reset_warning = reset_warning + self.ask_before_restart = ask_before_restart + self.ask_before_closing = ask_before_closing + + # --- Other attrs + self.context_menu_actions = context_menu_actions + self.time_label = time_label + self.options_button = options_button + self.history = [] + self.allow_rename = True + self.is_error_shown = False + self.error_text = None + self.restart_thread = None + self.give_focus = give_focus + + if css_path is None: + self.css_path = CSS_PATH + else: + self.css_path = css_path + + # --- Widgets + self.shellwidget = ShellWidget( + config=config_options, + ipyclient=self, + additional_options=additional_options, + interpreter_versions=interpreter_versions, + is_external_kernel=is_external_kernel, + is_spyder_kernel=is_spyder_kernel, + handlers=handlers, + local_kernel=True + ) + self.infowidget = self.container.infowidget + self.blank_page = self._create_blank_page() + self.loading_page = self._create_loading_page() + # To keep a reference to the page to be displayed + # in infowidget + self.info_page = None + self._before_prompt_is_ready() + + # Elapsed time + self.t0 = time.monotonic() + self.timer = QTimer(self) + + # --- Layout + self.layout = QVBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.addWidget(self.shellwidget) + if self.infowidget is not None: + self.layout.addWidget(self.infowidget) + self.setLayout(self.layout) + + # --- Exit function + self.exit_callback = lambda: self.container.close_client(client=self) + + # --- Dialog manager + self.dialog_manager = DialogManager() + + # --- Standard files handling + self.stderr_obj = stderr_obj + self.stdout_obj = stdout_obj + self.fault_obj = fault_obj + self.std_poll_timer = None + if self.stderr_obj is not None or self.stdout_obj is not None: + self.std_poll_timer = QTimer(self) + self.std_poll_timer.timeout.connect(self.poll_std_file_change) + self.std_poll_timer.setInterval(1000) + self.std_poll_timer.start() + self.shellwidget.executed.connect(self.poll_std_file_change) + + self.start_successful = False + + def __del__(self): + """Close threads to avoid segfault.""" + if (self.restart_thread is not None + and self.restart_thread.isRunning()): + self.restart_thread.quit() + self.restart_thread.wait() + + # ----- Private methods --------------------------------------------------- + def _before_prompt_is_ready(self, show_loading_page=True): + """Configuration before kernel is connected.""" + if show_loading_page: + self._show_loading_page() + self.shellwidget.sig_prompt_ready.connect( + self._when_prompt_is_ready) + # If remote execution, the loading page should be hidden as well + self.shellwidget.sig_remote_execute.connect( + self._when_prompt_is_ready) + + def _when_prompt_is_ready(self): + """Configuration after the prompt is shown.""" + self.start_successful = True + # To hide the loading page + self._hide_loading_page() + + # Show possible errors when setting Matplotlib backend + self._show_mpl_backend_errors() + + # To show if special console is valid + self._check_special_console_error() + + # Set the initial current working directory + self._set_initial_cwd() + + self.shellwidget.sig_prompt_ready.disconnect( + self._when_prompt_is_ready) + self.shellwidget.sig_remote_execute.disconnect( + self._when_prompt_is_ready) + + # It's necessary to do this at this point to avoid giving + # focus to _control at startup. + self._connect_control_signals() + + if self.give_focus: + self.shellwidget._control.setFocus() + + def _create_loading_page(self): + """Create html page to show while the kernel is starting""" + loading_template = Template(LOADING) + loading_img = get_image_path('loading_sprites') + if os.name == 'nt': + loading_img = loading_img.replace('\\', '/') + message = _("Connecting to kernel...") + page = loading_template.substitute(css_path=self.css_path, + loading_img=loading_img, + message=message) + return page + + def _create_blank_page(self): + """Create html page to show while the kernel is starting""" + loading_template = Template(BLANK) + page = loading_template.substitute(css_path=self.css_path) + return page + + def _show_loading_page(self): + """Show animation while the kernel is loading.""" + if self.infowidget is not None: + self.shellwidget.hide() + self.infowidget.show() + self.info_page = self.loading_page + self.set_info_page() + + def _hide_loading_page(self): + """Hide animation shown while the kernel is loading.""" + if self.infowidget is not None: + self.infowidget.hide() + self.info_page = self.blank_page + self.set_info_page() + self.shellwidget.show() + + def _read_stderr(self): + """Read the stderr file of the kernel.""" + # We need to read stderr_file as bytes to be able to + # detect its encoding with chardet + f = open(self.stderr_file, 'rb') + + try: + stderr_text = f.read() + + # This is needed to avoid showing an empty error message + # when the kernel takes too much time to start. + # See spyder-ide/spyder#8581. + if not stderr_text: + return '' + + # This is needed since the stderr file could be encoded + # in something different to utf-8. + # See spyder-ide/spyder#4191. + encoding = get_coding(stderr_text) + stderr_text = to_text_string(stderr_text, encoding) + return stderr_text + finally: + f.close() + + def _show_mpl_backend_errors(self): + """ + Show possible errors when setting the selected Matplotlib backend. + """ + if self.shellwidget.is_spyder_kernel: + self.shellwidget.call_kernel().show_mpl_backend_errors() + + def _check_special_console_error(self): + """Check if the dependecies for special consoles are available.""" + self.shellwidget.call_kernel( + callback=self._show_special_console_error + ).is_special_kernel_valid() + + def _show_special_console_error(self, missing_dependency): + if missing_dependency is not None: + error_message = _( + "Your Python environment or installation doesn't have the " + "{missing_dependency} module installed or it " + "occurred a problem importing it. Due to that, it is not " + "possible for Spyder to create this special console for " + "you." + ).format(missing_dependency=missing_dependency) + + self.show_kernel_error(error_message) + + def _abort_kernel_restart(self): + """ + Abort kernel restart if there are errors while starting it. + + We also ignore errors about comms, which are irrelevant. + """ + if self.start_successful: + return False + stderr = self.stderr_obj.get_contents() + if not stderr: + return False + # There is an error. If it is benign, ignore. + for line in stderr.splitlines(): + if line and not self.is_benign_error(line): + return True + return False + + def _connect_control_signals(self): + """Connect signals of control widgets.""" + control = self.shellwidget._control + page_control = self.shellwidget._page_control + + control.sig_focus_changed.connect( + self.container.sig_focus_changed) + page_control.sig_focus_changed.connect( + self.container.sig_focus_changed) + control.sig_visibility_changed.connect( + self.container.refresh_container) + page_control.sig_visibility_changed.connect( + self.container.refresh_container) + page_control.sig_show_find_widget_requested.connect( + self.container.find_widget.show) + + def _set_initial_cwd(self): + """Set initial cwd according to preferences.""" + logger.debug("Setting initial working directory") + cwd_path = get_home_dir() + project_path = self.container.get_active_project_path() + + # This is for the first client + if self.id_['int_id'] == '1': + if self.get_conf( + 'startup/use_project_or_home_directory', + section='workingdir' + ): + cwd_path = get_home_dir() + if project_path is not None: + cwd_path = project_path + elif self.get_conf( + 'startup/use_fixed_directory', + section='workingdir' + ): + cwd_path = self.get_conf( + 'startup/fixed_directory', + default=get_home_dir(), + section='workingdir' + ) + else: + # For new clients + if self.get_conf( + 'console/use_project_or_home_directory', + section='workingdir' + ): + cwd_path = get_home_dir() + if project_path is not None: + cwd_path = project_path + elif self.get_conf('console/use_cwd', section='workingdir'): + cwd_path = self.container.get_working_directory() + elif self.get_conf( + 'console/use_fixed_directory', + section='workingdir' + ): + cwd_path = self.get_conf( + 'console/fixed_directory', + default=get_home_dir(), + section='workingdir' + ) + + if osp.isdir(cwd_path): + self.shellwidget.set_cwd(cwd_path) + + # ----- Public API -------------------------------------------------------- + @property + def kernel_id(self): + """Get kernel id.""" + if self.connection_file is not None: + json_file = osp.basename(self.connection_file) + return json_file.split('.json')[0] + + def remove_std_files(self, is_last_client=True): + """Remove stderr_file associated with the client.""" + try: + self.shellwidget.executed.disconnect(self.poll_std_file_change) except (TypeError, ValueError): - pass - if self.std_poll_timer is not None: - self.std_poll_timer.stop() - if is_last_client: - if self.stderr_obj is not None: - self.stderr_obj.remove() - if self.stdout_obj is not None: - self.stdout_obj.remove() - if self.fault_obj is not None: - self.fault_obj.remove() - - @Slot() - def poll_std_file_change(self): - """Check if the stderr or stdout file just changed.""" - self.shellwidget.call_kernel().flush_std() - starting = self.shellwidget._starting - if self.stderr_obj is not None: - stderr = self.stderr_obj.poll_file_change() - if stderr: - if self.is_benign_error(stderr): - return - if self.shellwidget.isHidden(): - # Avoid printing the same thing again - if self.error_text != '%s' % stderr: - full_stderr = self.stderr_obj.get_contents() - self.show_kernel_error('%s' % full_stderr) - if starting: - self.shellwidget.banner = ( - stderr + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - '\n' + stderr, before_prompt=True) - - if self.stdout_obj is not None: - stdout = self.stdout_obj.poll_file_change() - if stdout: - if starting: - self.shellwidget.banner = ( - stdout + '\n' + self.shellwidget.banner) - else: - self.shellwidget._append_plain_text( - '\n' + stdout, before_prompt=True) - - def configure_shellwidget(self, give_focus=True): - """Configure shellwidget after kernel is connected.""" - self.give_focus = give_focus - - # Make sure the kernel sends the comm config over - self.shellwidget.call_kernel()._send_comm_config() - - # Set exit callback - self.shellwidget.set_exit_callback() - - # To save history - self.shellwidget.executing.connect(self.add_to_history) - - # For Mayavi to run correctly - self.shellwidget.executing.connect( - self.shellwidget.set_backend_for_mayavi) - - # To update history after execution - self.shellwidget.executed.connect(self.update_history) - - # To update the Variable Explorer after execution - self.shellwidget.executed.connect( - self.shellwidget.refresh_namespacebrowser) - - # To enable the stop button when executing a process - self.shellwidget.executing.connect( - self.sig_execution_state_changed) - - # To disable the stop button after execution stopped - self.shellwidget.executed.connect( - self.sig_execution_state_changed) - - # To show kernel restarted/died messages - self.shellwidget.sig_kernel_restarted_message.connect( - self.kernel_restarted_message) - self.shellwidget.sig_kernel_restarted.connect( - self._finalise_restart) - - # To correctly change Matplotlib backend interactively - self.shellwidget.executing.connect( - self.shellwidget.change_mpl_backend) - - # To show env and sys.path contents - self.shellwidget.sig_show_syspath.connect(self.show_syspath) - self.shellwidget.sig_show_env.connect(self.show_env) - - # To sync with working directory toolbar - self.shellwidget.executed.connect(self.shellwidget.update_cwd) - - # To apply style - self.set_color_scheme(self.shellwidget.syntax_style, reset=False) - - if self.fault_obj is not None: - # To display faulthandler - self.shellwidget.call_kernel().enable_faulthandler( - self.fault_obj.filename) - - def add_to_history(self, command): - """Add command to history""" - if self.shellwidget.is_debugging(): - return - return super(ClientWidget, self).add_to_history(command) - - def is_client_executing(self): - return (self.shellwidget._executing or - self.shellwidget.is_waiting_pdb_input()) - - @Slot() - def stop_button_click_handler(self): - """Method to handle what to do when the stop button is pressed""" - # Interrupt computations or stop debugging - if not self.shellwidget.is_waiting_pdb_input(): - self.interrupt_kernel() - else: - self.shellwidget.pdb_execute_command('exit') - - def show_kernel_error(self, error): - """Show kernel initialization errors in infowidget.""" - self.error_text = error - - if self.is_benign_error(error): - return - - InstallerIPythonKernelError(error) - - # Replace end of line chars with
- eol = sourcecode.get_eol_chars(error) - if eol: - error = error.replace(eol, '
') - - # Don't break lines in hyphens - # From https://stackoverflow.com/q/7691569/438386 - error = error.replace('-', '‑') - - # Create error page - message = _("An error ocurred while starting the kernel") - kernel_error_template = Template(KERNEL_ERROR) - self.info_page = kernel_error_template.substitute( - css_path=self.css_path, - message=message, - error=error) - - # Show error - if self.infowidget is not None: - self.set_info_page() - self.shellwidget.hide() - self.infowidget.show() - - # Tell the client we're in error mode - self.is_error_shown = True - - # Stop shellwidget - self.shellwidget.shutdown() - self.remove_std_files(is_last_client=False) - - def is_benign_error(self, error): - """Decide if an error is benign in order to filter it.""" - benign_errors = [ - # Error when switching from the Qt5 backend to the Tk one. - # See spyder-ide/spyder#17488 - "KeyboardInterrupt caught in kernel", - "QSocketNotifier: Multiple socket notifiers for same socket", - # Error when switching from the Tk backend to the Qt5 one. - # See spyder-ide/spyder#17488 - "Tcl_AsyncDelete async handler deleted by the wrong thread", - "error in background error handler:", - " while executing", - '"::tcl::Bgerror', - # Avoid showing this warning because it was up to the user to - # disable secure writes. - "WARNING: Insecure writes have been enabled via environment", - # Old error - "No such comm" - ] - - return any([err in error for err in benign_errors]) - - def get_name(self): - """Return client name""" - if self.given_name is None: - # Name according to host - if self.hostname is None: - name = _("Console") - else: - name = self.hostname - # Adding id to name - client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] - name = name + u' ' + client_id - elif self.given_name in ["Pylab", "SymPy", "Cython"]: - client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] - name = self.given_name + u' ' + client_id - else: - name = self.given_name + u'/' + self.id_['str_id'] - return name - - def get_control(self): - """Return the text widget (or similar) to give focus to""" - # page_control is the widget used for paging - page_control = self.shellwidget._page_control - if page_control and page_control.isVisible(): - return page_control - else: - return self.shellwidget._control - - def get_kernel(self): - """Get kernel associated with this client""" - return self.shellwidget.kernel_manager - - def add_actions_to_context_menu(self, menu): - """Add actions to IPython widget context menu""" - add_actions(menu, self.context_menu_actions) - - return menu - - def set_font(self, font): - """Set IPython widget's font""" - self.shellwidget._control.setFont(font) - self.shellwidget.font = font - - def set_color_scheme(self, color_scheme, reset=True): - """Set IPython color scheme.""" - # Needed to handle not initialized kernel_client - # See spyder-ide/spyder#6996. - try: - self.shellwidget.set_color_scheme(color_scheme, reset) - except AttributeError: - pass - - def shutdown(self, is_last_client): - """Shutdown connection and kernel if needed.""" - self.dialog_manager.close_all() - if (self.restart_thread is not None - and self.restart_thread.isRunning()): - self.restart_thread.finished.disconnect() - self.restart_thread.quit() - self.restart_thread.wait() - shutdown_kernel = ( - is_last_client and not self.is_external_kernel - and not self.is_error_shown) - self.shellwidget.shutdown(shutdown_kernel) - self.remove_std_files(shutdown_kernel) - - def interrupt_kernel(self): - """Interrupt the associanted Spyder kernel if it's running""" - # Needed to prevent a crash when a kernel is not running. - # See spyder-ide/spyder#6299. - try: - self.shellwidget.request_interrupt_kernel() - except RuntimeError: - pass - - @Slot() - def restart_kernel(self): - """ - Restart the associated kernel. - - Took this code from the qtconsole project - Licensed under the BSD license - """ - sw = self.shellwidget - - if not running_under_pytest() and self.ask_before_restart: - message = _('Are you sure you want to restart the kernel?') - buttons = QMessageBox.Yes | QMessageBox.No - result = QMessageBox.question(self, _('Restart kernel?'), - message, buttons) - else: - result = None - - if (result == QMessageBox.Yes or - running_under_pytest() or - not self.ask_before_restart): - if sw.kernel_manager: - if self.infowidget is not None: - if self.infowidget.isVisible(): - self.infowidget.hide() - - if self._abort_kernel_restart(): - sw.spyder_kernel_comm.close() - return - - self._show_loading_page() - - # Close comm - sw.spyder_kernel_comm.close() - - # Stop autorestart mechanism - sw.kernel_manager.stop_restarter() - sw.kernel_manager.autorestart = False - - # Reconfigure client before the new kernel is connected again. - self._before_prompt_is_ready(show_loading_page=False) - - # Create and run restarting thread - if (self.restart_thread is not None - and self.restart_thread.isRunning()): - self.restart_thread.finished.disconnect() - self.restart_thread.quit() - self.restart_thread.wait() - self.restart_thread = QThread(None) - self.restart_thread.run = self._restart_thread_main - self.restart_thread.error = None - self.restart_thread.finished.connect( - lambda: self._finalise_restart(True)) - self.restart_thread.start() - - else: - sw._append_plain_text( - _('Cannot restart a kernel not started by Spyder\n'), - before_prompt=True - ) - self._hide_loading_page() - - def _restart_thread_main(self): - """Restart the kernel in a thread.""" - try: - self.shellwidget.kernel_manager.restart_kernel( - stderr=self.stderr_obj.handle, - stdout=self.stdout_obj.handle) - except RuntimeError as e: - self.restart_thread.error = e - - def _finalise_restart(self, reset=False): - """Finishes the restarting of the kernel.""" - sw = self.shellwidget - - if self._abort_kernel_restart(): - sw.spyder_kernel_comm.close() - return - - if self.restart_thread and self.restart_thread.error is not None: - sw._append_plain_text( - _('Error restarting kernel: %s\n') % self.restart_thread.error, - before_prompt=True - ) - else: - if self.fault_obj is not None: - fault = self.fault_obj.get_contents() - if fault: - fault = self.filter_fault(fault) - self.shellwidget._append_plain_text( - '\n' + fault, before_prompt=True) - - # Reset Pdb state and reopen comm - sw._pdb_in_loop = False - sw.spyder_kernel_comm.remove() - try: - sw.spyder_kernel_comm.open_comm(sw.kernel_client) - except AttributeError: - # An error occurred while opening our comm channel. - # Aborting! - return - - # Start autorestart mechanism - sw.kernel_manager.autorestart = True - sw.kernel_manager.start_restarter() - - # For spyder-ide/spyder#6235, IPython was changing the - # setting of %colors on windows by assuming it was using a - # dark background. This corrects it based on the scheme. - self.set_color_scheme(sw.syntax_style, reset=reset) - sw._append_html(_("
Restarting kernel...
"), - before_prompt=True) - sw.insert_horizontal_ruler() - if self.fault_obj is not None: - self.shellwidget.call_kernel().enable_faulthandler( - self.fault_obj.filename) - - self._hide_loading_page() - self.restart_thread = None - self.sig_execution_state_changed.emit() - - def filter_fault(self, fault): - """Get a fault from a previous session.""" - thread_regex = ( - r"(Current thread|Thread) " - r"(0x[\da-f]+) \(most recent call first\):" - r"(?:.|\r\n|\r|\n)+?(?=Current thread|Thread|\Z)") - # Keep line for future improvments - # files_regex = r"File \"([^\"]+)\", line (\d+) in (\S+)" - - main_re = "Main thread id:(?:\r\n|\r|\n)(0x[0-9a-f]+)" - main_id = 0 - for match in re.finditer(main_re, fault): - main_id = int(match.group(1), base=16) - - system_re = ("System threads ids:" - "(?:\r\n|\r|\n)(0x[0-9a-f]+(?: 0x[0-9a-f]+)+)") - ignore_ids = [] - start_idx = 0 - for match in re.finditer(system_re, fault): - ignore_ids = [int(i, base=16) for i in match.group(1).split()] - start_idx = match.span()[1] - text = "" - for idx, match in enumerate(re.finditer(thread_regex, fault)): - if idx == 0: - text += fault[start_idx:match.span()[0]] - thread_id = int(match.group(2), base=16) - if thread_id != main_id: - if thread_id in ignore_ids: - continue - if "wurlitzer.py" in match.group(0): - # Wurlitzer threads are launched later - continue - text += "\n" + match.group(0) + "\n" - else: - try: - pattern = (r".*(?:/IPython/core/interactiveshell\.py|" - r"\\IPython\\core\\interactiveshell\.py).*") - match_internal = next(re.finditer(pattern, match.group(0))) - end_idx = match_internal.span()[0] - except StopIteration: - end_idx = None - text += "\nMain thread:\n" + match.group(0)[:end_idx] + "\n" - return text - - @Slot(str) - def kernel_restarted_message(self, msg): - """Show kernel restarted/died messages.""" - if self.stderr_obj is not None: - # If there are kernel creation errors, jupyter_client will - # try to restart the kernel and qtconsole prints a - # message about it. - # So we read the kernel's stderr_file and display its - # contents in the client instead of the usual message shown - # by qtconsole. - self.poll_std_file_change() - else: - self.shellwidget._append_html("
%s

" % msg, - before_prompt=False) - - @Slot() - def enter_array_inline(self): - """Enter and show the array builder on inline mode.""" - self.shellwidget._control.enter_array_inline() - - @Slot() - def enter_array_table(self): - """Enter and show the array builder on table.""" - self.shellwidget._control.enter_array_table() - - @Slot() - def inspect_object(self): - """Show how to inspect an object with our Help plugin""" - self.shellwidget._control.inspect_current_object() - - @Slot() - def clear_line(self): - """Clear a console line""" - self.shellwidget._keyboard_quit() - - @Slot() - def clear_console(self): - """Clear the whole console""" - self.shellwidget.clear_console() - - @Slot() - def reset_namespace(self): - """Resets the namespace by removing all names defined by the user""" - self.shellwidget.reset_namespace(warning=self.reset_warning, - message=True) - - def update_history(self): - self.history = self.shellwidget._history - - @Slot(object) - def show_syspath(self, syspath): - """Show sys.path contents.""" - if syspath is not None: - editor = CollectionsEditor(self) - editor.setup(syspath, title="sys.path contents", readonly=True, - icon=ima.icon('syspath')) - self.dialog_manager.show(editor) - else: - return - - @Slot(object) - def show_env(self, env): - """Show environment variables.""" - self.dialog_manager.show(RemoteEnvDialog(env, parent=self)) - - def show_time(self, end=False): - """Text to show in time_label.""" - if self.time_label is None: - return - - elapsed_time = time.monotonic() - self.t0 - # System time changed to past date, so reset start. - if elapsed_time < 0: - self.t0 = time.monotonic() - elapsed_time = 0 - if elapsed_time > 24 * 3600: # More than a day...! - fmt = "%d %H:%M:%S" - else: - fmt = "%H:%M:%S" - if end: - color = QStylePalette.COLOR_TEXT_3 - else: - color = QStylePalette.COLOR_ACCENT_4 - text = "%s" \ - "" % (color, - time.strftime(fmt, time.gmtime(elapsed_time))) - if self.show_elapsed_time: - self.time_label.setText(text) - else: - self.time_label.setText("") - - @Slot(bool) - def set_show_elapsed_time(self, state): - """Slot to show/hide elapsed time label.""" - self.show_elapsed_time = state - - def set_info_page(self): - """Set current info_page.""" - if self.infowidget is not None and self.info_page is not None: - self.infowidget.setHtml( - self.info_page, - QUrl.fromLocalFile(self.css_path) - ) + pass + if self.std_poll_timer is not None: + self.std_poll_timer.stop() + if is_last_client: + if self.stderr_obj is not None: + self.stderr_obj.remove() + if self.stdout_obj is not None: + self.stdout_obj.remove() + if self.fault_obj is not None: + self.fault_obj.remove() + + @Slot() + def poll_std_file_change(self): + """Check if the stderr or stdout file just changed.""" + self.shellwidget.call_kernel().flush_std() + starting = self.shellwidget._starting + if self.stderr_obj is not None: + stderr = self.stderr_obj.poll_file_change() + if stderr: + if self.is_benign_error(stderr): + return + if self.shellwidget.isHidden(): + # Avoid printing the same thing again + if self.error_text != '%s' % stderr: + full_stderr = self.stderr_obj.get_contents() + self.show_kernel_error('%s' % full_stderr) + if starting: + self.shellwidget.banner = ( + stderr + '\n' + self.shellwidget.banner) + else: + self.shellwidget._append_plain_text( + '\n' + stderr, before_prompt=True) + + if self.stdout_obj is not None: + stdout = self.stdout_obj.poll_file_change() + if stdout: + if starting: + self.shellwidget.banner = ( + stdout + '\n' + self.shellwidget.banner) + else: + self.shellwidget._append_plain_text( + '\n' + stdout, before_prompt=True) + + def configure_shellwidget(self, give_focus=True): + """Configure shellwidget after kernel is connected.""" + self.give_focus = give_focus + + # Make sure the kernel sends the comm config over + self.shellwidget.call_kernel()._send_comm_config() + + # Set exit callback + self.shellwidget.set_exit_callback() + + # To save history + self.shellwidget.executing.connect(self.add_to_history) + + # For Mayavi to run correctly + self.shellwidget.executing.connect( + self.shellwidget.set_backend_for_mayavi) + + # To update history after execution + self.shellwidget.executed.connect(self.update_history) + + # To update the Variable Explorer after execution + self.shellwidget.executed.connect( + self.shellwidget.refresh_namespacebrowser) + + # To enable the stop button when executing a process + self.shellwidget.executing.connect( + self.sig_execution_state_changed) + + # To disable the stop button after execution stopped + self.shellwidget.executed.connect( + self.sig_execution_state_changed) + + # To show kernel restarted/died messages + self.shellwidget.sig_kernel_restarted_message.connect( + self.kernel_restarted_message) + self.shellwidget.sig_kernel_restarted.connect( + self._finalise_restart) + + # To correctly change Matplotlib backend interactively + self.shellwidget.executing.connect( + self.shellwidget.change_mpl_backend) + + # To show env and sys.path contents + self.shellwidget.sig_show_syspath.connect(self.show_syspath) + self.shellwidget.sig_show_env.connect(self.show_env) + + # To sync with working directory toolbar + self.shellwidget.executed.connect(self.shellwidget.update_cwd) + + # To apply style + self.set_color_scheme(self.shellwidget.syntax_style, reset=False) + + if self.fault_obj is not None: + # To display faulthandler + self.shellwidget.call_kernel().enable_faulthandler( + self.fault_obj.filename) + + def add_to_history(self, command): + """Add command to history""" + if self.shellwidget.is_debugging(): + return + return super(ClientWidget, self).add_to_history(command) + + def is_client_executing(self): + return (self.shellwidget._executing or + self.shellwidget.is_waiting_pdb_input()) + + @Slot() + def stop_button_click_handler(self): + """Method to handle what to do when the stop button is pressed""" + # Interrupt computations or stop debugging + if not self.shellwidget.is_waiting_pdb_input(): + self.interrupt_kernel() + else: + self.shellwidget.pdb_execute_command('exit') + + def show_kernel_error(self, error): + """Show kernel initialization errors in infowidget.""" + self.error_text = error + + if self.is_benign_error(error): + return + + InstallerIPythonKernelError(error) + + # Replace end of line chars with
+ eol = sourcecode.get_eol_chars(error) + if eol: + error = error.replace(eol, '
') + + # Don't break lines in hyphens + # From https://stackoverflow.com/q/7691569/438386 + error = error.replace('-', '‑') + + # Create error page + message = _("An error ocurred while starting the kernel") + kernel_error_template = Template(KERNEL_ERROR) + self.info_page = kernel_error_template.substitute( + css_path=self.css_path, + message=message, + error=error) + + # Show error + if self.infowidget is not None: + self.set_info_page() + self.shellwidget.hide() + self.infowidget.show() + + # Tell the client we're in error mode + self.is_error_shown = True + + # Stop shellwidget + self.shellwidget.shutdown() + self.remove_std_files(is_last_client=False) + + def is_benign_error(self, error): + """Decide if an error is benign in order to filter it.""" + benign_errors = [ + # Error when switching from the Qt5 backend to the Tk one. + # See spyder-ide/spyder#17488 + "KeyboardInterrupt caught in kernel", + "QSocketNotifier: Multiple socket notifiers for same socket", + # Error when switching from the Tk backend to the Qt5 one. + # See spyder-ide/spyder#17488 + "Tcl_AsyncDelete async handler deleted by the wrong thread", + "error in background error handler:", + " while executing", + '"::tcl::Bgerror', + # Avoid showing this warning because it was up to the user to + # disable secure writes. + "WARNING: Insecure writes have been enabled via environment", + # Old error + "No such comm" + ] + + return any([err in error for err in benign_errors]) + + def get_name(self): + """Return client name""" + if self.given_name is None: + # Name according to host + if self.hostname is None: + name = _("Console") + else: + name = self.hostname + # Adding id to name + client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] + name = name + u' ' + client_id + elif self.given_name in ["Pylab", "SymPy", "Cython"]: + client_id = self.id_['int_id'] + u'/' + self.id_['str_id'] + name = self.given_name + u' ' + client_id + else: + name = self.given_name + u'/' + self.id_['str_id'] + return name + + def get_control(self): + """Return the text widget (or similar) to give focus to""" + # page_control is the widget used for paging + page_control = self.shellwidget._page_control + if page_control and page_control.isVisible(): + return page_control + else: + return self.shellwidget._control + + def get_kernel(self): + """Get kernel associated with this client""" + return self.shellwidget.kernel_manager + + def add_actions_to_context_menu(self, menu): + """Add actions to IPython widget context menu""" + add_actions(menu, self.context_menu_actions) + + return menu + + def set_font(self, font): + """Set IPython widget's font""" + self.shellwidget._control.setFont(font) + self.shellwidget.font = font + + def set_color_scheme(self, color_scheme, reset=True): + """Set IPython color scheme.""" + # Needed to handle not initialized kernel_client + # See spyder-ide/spyder#6996. + try: + self.shellwidget.set_color_scheme(color_scheme, reset) + except AttributeError: + pass + + def shutdown(self, is_last_client): + """Shutdown connection and kernel if needed.""" + self.dialog_manager.close_all() + if (self.restart_thread is not None + and self.restart_thread.isRunning()): + self.restart_thread.finished.disconnect() + self.restart_thread.quit() + self.restart_thread.wait() + shutdown_kernel = ( + is_last_client and not self.is_external_kernel + and not self.is_error_shown) + self.shellwidget.shutdown(shutdown_kernel) + self.remove_std_files(shutdown_kernel) + + def interrupt_kernel(self): + """Interrupt the associanted Spyder kernel if it's running""" + # Needed to prevent a crash when a kernel is not running. + # See spyder-ide/spyder#6299. + try: + self.shellwidget.request_interrupt_kernel() + except RuntimeError: + pass + + @Slot() + def restart_kernel(self): + """ + Restart the associated kernel. + + Took this code from the qtconsole project + Licensed under the BSD license + """ + sw = self.shellwidget + + if not running_under_pytest() and self.ask_before_restart: + message = _('Are you sure you want to restart the kernel?') + buttons = QMessageBox.Yes | QMessageBox.No + result = QMessageBox.question(self, _('Restart kernel?'), + message, buttons) + else: + result = None + + if (result == QMessageBox.Yes or + running_under_pytest() or + not self.ask_before_restart): + if sw.kernel_manager: + if self.infowidget is not None: + if self.infowidget.isVisible(): + self.infowidget.hide() + + if self._abort_kernel_restart(): + sw.spyder_kernel_comm.close() + return + + self._show_loading_page() + + # Close comm + sw.spyder_kernel_comm.close() + + # Stop autorestart mechanism + sw.kernel_manager.stop_restarter() + sw.kernel_manager.autorestart = False + + # Reconfigure client before the new kernel is connected again. + self._before_prompt_is_ready(show_loading_page=False) + + # Create and run restarting thread + if (self.restart_thread is not None + and self.restart_thread.isRunning()): + self.restart_thread.finished.disconnect() + self.restart_thread.quit() + self.restart_thread.wait() + self.restart_thread = QThread(None) + self.restart_thread.run = self._restart_thread_main + self.restart_thread.error = None + self.restart_thread.finished.connect( + lambda: self._finalise_restart(True)) + self.restart_thread.start() + + else: + sw._append_plain_text( + _('Cannot restart a kernel not started by Spyder\n'), + before_prompt=True + ) + self._hide_loading_page() + + def _restart_thread_main(self): + """Restart the kernel in a thread.""" + try: + self.shellwidget.kernel_manager.restart_kernel( + stderr=self.stderr_obj.handle, + stdout=self.stdout_obj.handle) + except RuntimeError as e: + self.restart_thread.error = e + + def _finalise_restart(self, reset=False): + """Finishes the restarting of the kernel.""" + sw = self.shellwidget + + if self._abort_kernel_restart(): + sw.spyder_kernel_comm.close() + return + + if self.restart_thread and self.restart_thread.error is not None: + sw._append_plain_text( + _('Error restarting kernel: %s\n') % self.restart_thread.error, + before_prompt=True + ) + else: + if self.fault_obj is not None: + fault = self.fault_obj.get_contents() + if fault: + fault = self.filter_fault(fault) + self.shellwidget._append_plain_text( + '\n' + fault, before_prompt=True) + + # Reset Pdb state and reopen comm + sw._pdb_in_loop = False + sw.spyder_kernel_comm.remove() + try: + sw.spyder_kernel_comm.open_comm(sw.kernel_client) + except AttributeError: + # An error occurred while opening our comm channel. + # Aborting! + return + + # Start autorestart mechanism + sw.kernel_manager.autorestart = True + sw.kernel_manager.start_restarter() + + # For spyder-ide/spyder#6235, IPython was changing the + # setting of %colors on windows by assuming it was using a + # dark background. This corrects it based on the scheme. + self.set_color_scheme(sw.syntax_style, reset=reset) + sw._append_html(_("
Restarting kernel...
"), + before_prompt=True) + sw.insert_horizontal_ruler() + if self.fault_obj is not None: + self.shellwidget.call_kernel().enable_faulthandler( + self.fault_obj.filename) + + self._hide_loading_page() + self.restart_thread = None + self.sig_execution_state_changed.emit() + + def filter_fault(self, fault): + """Get a fault from a previous session.""" + thread_regex = ( + r"(Current thread|Thread) " + r"(0x[\da-f]+) \(most recent call first\):" + r"(?:.|\r\n|\r|\n)+?(?=Current thread|Thread|\Z)") + # Keep line for future improvments + # files_regex = r"File \"([^\"]+)\", line (\d+) in (\S+)" + + main_re = "Main thread id:(?:\r\n|\r|\n)(0x[0-9a-f]+)" + main_id = 0 + for match in re.finditer(main_re, fault): + main_id = int(match.group(1), base=16) + + system_re = ("System threads ids:" + "(?:\r\n|\r|\n)(0x[0-9a-f]+(?: 0x[0-9a-f]+)+)") + ignore_ids = [] + start_idx = 0 + for match in re.finditer(system_re, fault): + ignore_ids = [int(i, base=16) for i in match.group(1).split()] + start_idx = match.span()[1] + text = "" + for idx, match in enumerate(re.finditer(thread_regex, fault)): + if idx == 0: + text += fault[start_idx:match.span()[0]] + thread_id = int(match.group(2), base=16) + if thread_id != main_id: + if thread_id in ignore_ids: + continue + if "wurlitzer.py" in match.group(0): + # Wurlitzer threads are launched later + continue + text += "\n" + match.group(0) + "\n" + else: + try: + pattern = (r".*(?:/IPython/core/interactiveshell\.py|" + r"\\IPython\\core\\interactiveshell\.py).*") + match_internal = next(re.finditer(pattern, match.group(0))) + end_idx = match_internal.span()[0] + except StopIteration: + end_idx = None + text += "\nMain thread:\n" + match.group(0)[:end_idx] + "\n" + return text + + @Slot(str) + def kernel_restarted_message(self, msg): + """Show kernel restarted/died messages.""" + if self.stderr_obj is not None: + # If there are kernel creation errors, jupyter_client will + # try to restart the kernel and qtconsole prints a + # message about it. + # So we read the kernel's stderr_file and display its + # contents in the client instead of the usual message shown + # by qtconsole. + self.poll_std_file_change() + else: + self.shellwidget._append_html("
%s

" % msg, + before_prompt=False) + + @Slot() + def enter_array_inline(self): + """Enter and show the array builder on inline mode.""" + self.shellwidget._control.enter_array_inline() + + @Slot() + def enter_array_table(self): + """Enter and show the array builder on table.""" + self.shellwidget._control.enter_array_table() + + @Slot() + def inspect_object(self): + """Show how to inspect an object with our Help plugin""" + self.shellwidget._control.inspect_current_object() + + @Slot() + def clear_line(self): + """Clear a console line""" + self.shellwidget._keyboard_quit() + + @Slot() + def clear_console(self): + """Clear the whole console""" + self.shellwidget.clear_console() + + @Slot() + def reset_namespace(self): + """Resets the namespace by removing all names defined by the user""" + self.shellwidget.reset_namespace(warning=self.reset_warning, + message=True) + + def update_history(self): + self.history = self.shellwidget._history + + @Slot(object) + def show_syspath(self, syspath): + """Show sys.path contents.""" + if syspath is not None: + editor = CollectionsEditor(self) + editor.setup(syspath, title="sys.path contents", readonly=True, + icon=ima.icon('syspath')) + self.dialog_manager.show(editor) + else: + return + + @Slot(object) + def show_env(self, env): + """Show environment variables.""" + self.dialog_manager.show(RemoteEnvDialog(env, parent=self)) + + def show_time(self, end=False): + """Text to show in time_label.""" + if self.time_label is None: + return + + elapsed_time = time.monotonic() - self.t0 + # System time changed to past date, so reset start. + if elapsed_time < 0: + self.t0 = time.monotonic() + elapsed_time = 0 + if elapsed_time > 24 * 3600: # More than a day...! + fmt = "%d %H:%M:%S" + else: + fmt = "%H:%M:%S" + if end: + color = QStylePalette.COLOR_TEXT_3 + else: + color = QStylePalette.COLOR_ACCENT_4 + text = "%s" \ + "" % (color, + time.strftime(fmt, time.gmtime(elapsed_time))) + if self.show_elapsed_time: + self.time_label.setText(text) + else: + self.time_label.setText("") + + @Slot(bool) + def set_show_elapsed_time(self, state): + """Slot to show/hide elapsed time label.""" + self.show_elapsed_time = state + + def set_info_page(self): + """Set current info_page.""" + if self.infowidget is not None and self.info_page is not None: + self.infowidget.setHtml( + self.info_page, + QUrl.fromLocalFile(self.css_path) + ) diff --git a/spyder/plugins/layout/container.py b/spyder/plugins/layout/container.py index 18b2c6f47b4..bb36e742268 100644 --- a/spyder/plugins/layout/container.py +++ b/spyder/plugins/layout/container.py @@ -1,440 +1,440 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Layout container. -""" - -# Standard library imports -from collections import OrderedDict -import sys - -# Third party imports -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import QMessageBox - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.plugins.layout.api import BaseGridLayoutType -from spyder.plugins.layout.layouts import DefaultLayouts -from spyder.plugins.layout.widgets.dialog import ( - LayoutSaveDialog, LayoutSettingsDialog) - -# Localization -_ = get_translation("spyder") - - -class LayoutContainerActions: - DefaultLayout = 'default_layout_action' - MatlabLayout = 'matlab_layout_action' - RStudio = 'rstudio_layout_action' - HorizontalSplit = 'horizontal_split_layout_action' - VerticalSplit = 'vertical_split_layout_action' - SaveLayoutAction = 'save_layout_action' - ShowLayoutPreferencesAction = 'show_layout_preferences_action' - ResetLayout = 'reset_layout_action' - # Needs to have 'Maximize pane' as name to properly register - # the action shortcut - MaximizeCurrentDockwidget = 'Maximize pane' - # Needs to have 'Fullscreen mode' as name to properly register - # the action shortcut - Fullscreen = 'Fullscreen mode' - # Needs to have 'Use next layout' as name to properly register - # the action shortcut - NextLayout = 'Use next layout' - # Needs to have 'Use previous layout' as name to properly register - # the action shortcut - PreviousLayout = 'Use previous layout' - # Needs to have 'Close pane' as name to properly register - # the action shortcut - CloseCurrentDockwidget = 'Close pane' - # Needs to have 'Lock unlock panes' as name to properly register - # the action shortcut - LockDockwidgetsAndToolbars = 'Lock unlock panes' - - -class LayoutPluginMenus: - PluginsMenu = "plugins_menu" - LayoutsMenu = 'layouts_menu' - - -class LayoutContainer(PluginMainContainer): - """ - Plugin container class that handles the Spyder quick layouts functionality. - """ - - def setup(self): - # Basic attributes to handle layouts options and dialogs references - self._spyder_layouts = OrderedDict() - self._save_dialog = None - self._settings_dialog = None - self._layouts_menu = None - self._current_quick_layout = None - - # Close current dockable plugin - self._close_dockwidget_action = self.create_action( - LayoutContainerActions.CloseCurrentDockwidget, - text=_('Close current pane'), - icon=self.create_icon('close_pane'), - triggered=self._plugin.close_current_dockwidget, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_' - ) - - # Maximize current dockable plugin - self._maximize_dockwidget_action = self.create_action( - LayoutContainerActions.MaximizeCurrentDockwidget, - text=_('Maximize current pane'), - icon=self.create_icon('maximize'), - toggled=lambda state: self._plugin.maximize_dockwidget(), - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - - # Fullscreen mode - self._fullscreen_action = self.create_action( - LayoutContainerActions.Fullscreen, - text=_('Fullscreen mode'), - triggered=self._plugin.toggle_fullscreen, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - if sys.platform == 'darwin': - self._fullscreen_action.setEnabled(False) - self._fullscreen_action.setToolTip(_("For fullscreen mode use the " - "macOS built-in feature")) - - # Lock dockwidgets and toolbars - self._lock_interface_action = self.create_action( - LayoutContainerActions.LockDockwidgetsAndToolbars, - text='', - triggered=lambda checked: - self._plugin.toggle_lock(), - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_' - ) - - self._save_layout_action = self.create_action( - LayoutContainerActions.SaveLayoutAction, - _("Save current layout"), - triggered=lambda: self.show_save_layout(), - context=Qt.ApplicationShortcut, - register_shortcut=False, - ) - self._show_preferences_action = self.create_action( - LayoutContainerActions.ShowLayoutPreferencesAction, - text=_("Layout preferences"), - triggered=lambda: self.show_layout_settings(), - context=Qt.ApplicationShortcut, - register_shortcut=False, - ) - self._reset_action = self.create_action( - LayoutContainerActions.ResetLayout, - text=_('Reset to Spyder default'), - triggered=self.reset_window_layout, - register_shortcut=False, - ) - - # Layouts shortcuts actions - self._toggle_next_layout_action = self.create_action( - LayoutContainerActions.NextLayout, - _("Use next layout"), - triggered=self.toggle_next_layout, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - self._toggle_previous_layout_action = self.create_action( - LayoutContainerActions.PreviousLayout, - _("Use previous layout"), - triggered=self.toggle_previous_layout, - context=Qt.ApplicationShortcut, - register_shortcut=True, - shortcut_context='_') - - # Layouts menu - self._layouts_menu = self.create_menu( - LayoutPluginMenus.LayoutsMenu, _("Window layouts")) - - self._plugins_menu = self.create_menu( - LayoutPluginMenus.PluginsMenu, _("Panes")) - self._plugins_menu.setObjectName('checkbox-padding') - - def update_actions(self): - pass - - def update_layout_menu_actions(self): - """ - Update layouts menu and layouts related actions. - """ - menu = self._layouts_menu - menu.clear_actions() - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - - actions = [] - for name in order: - if name in active: - if name in self._spyder_layouts: - index = name - name = self._spyder_layouts[index].get_name() - else: - index = names.index(name) - name = ui_names[index] - - # closure required so lambda works with the default parameter - def trigger(i=index, self=self): - return lambda: self.quick_layout_switch(i) - - layout_switch_action = self.create_action( - name, - text=name, - triggered=trigger(), - register_shortcut=False, - overwrite=True - ) - - actions.append(layout_switch_action) - - for item in actions: - self.add_item_to_menu(item, menu, section="layouts_section") - - for item in [self._save_layout_action, self._show_preferences_action, - self._reset_action]: - self.add_item_to_menu(item, menu, section="layouts_section_2") - - self._show_preferences_action.setEnabled(len(order) != 0) - - # --- Public API - # ------------------------------------------------------------------------ - def critical_message(self, title, message): - """Expose a QMessageBox.critical dialog to be used from the plugin.""" - QMessageBox.critical(self, title, message) - - def register_layout(self, parent_plugin, layout_type): - """ - Register a new layout type. - - Parameters - ---------- - parent_plugin: spyder.api.plugins.SpyderPluginV2 - Plugin registering the layout type. - layout_type: spyder.plugins.layout.api.BaseGridLayoutType - Layout to register. - """ - if not issubclass(layout_type, BaseGridLayoutType): - raise SpyderAPIError( - "A layout must be a subclass is `BaseGridLayoutType`!") - - layout_id = layout_type.ID - if layout_id in self._spyder_layouts: - raise SpyderAPIError( - "Layout with id `{}` already registered!".format(layout_id)) - - layout = layout_type(parent_plugin) - layout._check_layout_validity() - self._spyder_layouts[layout_id] = layout - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - - if layout_id not in names: - names.append(layout_id) - ui_names.append(layout.get_name()) - order.append(layout_id) - active.append(layout_id) - self.set_conf('names', names) - self.set_conf('ui_names', ui_names) - self.set_conf('order', order) - self.set_conf('active', active) - - def get_layout(self, layout_id): - """ - Get a registered layout by its ID. - - Parameters - ---------- - layout_id : string - The ID of the layout. - - Raises - ------ - SpyderAPIError - If the given id is not found in the registered layouts. - - Returns - ------- - Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass - Layout. - """ - if layout_id not in self._spyder_layouts: - raise SpyderAPIError( - "Layout with id `{}` is not registered!".format(layout_id)) - - return self._spyder_layouts[layout_id] - - def show_save_layout(self): - """Show the save layout dialog.""" - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - dialog_names = [name for name in names - if name not in self._spyder_layouts.keys()] - dlg = self._save_dialog = LayoutSaveDialog(self, dialog_names) - - if dlg.exec_(): - name = dlg.combo_box.currentText() - if name in self._spyder_layouts: - QMessageBox.critical( - self, - _("Error"), - _("Layout {0} was defined programatically. " - "It is not possible to overwrite programatically " - "registered layouts.").format(name) - ) - return - if name in names: - answer = QMessageBox.warning( - self, - _("Warning"), - _("Layout {0} will be overwritten. " - "Do you want to continue?").format(name), - QMessageBox.Yes | QMessageBox.No, - ) - index = order.index(name) - else: - answer = True - if None in names: - index = names.index(None) - names[index] = name - else: - index = len(names) - names.append(name) - - order.append(name) - - # Always make active a new layout even if it overwrites an - # inactive layout - if name not in active: - active.append(name) - - if name not in ui_names: - ui_names.append(name) - - if answer: - self._plugin.save_current_window_settings( - 'layout_{}/'.format(index), section='quick_layouts') - self.set_conf('names', names) - self.set_conf('ui_names', ui_names) - self.set_conf('order', order) - self.set_conf('active', active) - - self.update_layout_menu_actions() - - def show_layout_settings(self): - """Layout settings dialog.""" - names = self.get_conf('names') - ui_names = self.get_conf('ui_names') - order = self.get_conf('order') - active = self.get_conf('active') - read_only = list(self._spyder_layouts.keys()) - - dlg = self._settings_dialog = LayoutSettingsDialog( - self, names, ui_names, order, active, read_only) - if dlg.exec_(): - self.set_conf('names', dlg.names) - self.set_conf('ui_names', dlg.ui_names) - self.set_conf('order', dlg.order) - self.set_conf('active', dlg.active) - - self.update_layout_menu_actions() - - @Slot() - def reset_window_layout(self): - """Reset window layout to default.""" - answer = QMessageBox.warning( - self, - _("Warning"), - _("Window layout will be reset to default settings: " - "this affects window position, size and dockwidgets.\n" - "Do you want to continue?"), - QMessageBox.Yes | QMessageBox.No, - ) - - if answer == QMessageBox.Yes: - self._plugin.setup_layout(default=True) - - @Slot() - def toggle_previous_layout(self): - """Use the previous layout from the layouts list (default + custom).""" - self.toggle_layout('previous') - - @Slot() - def toggle_next_layout(self): - """Use the next layout from the layouts list (default + custom).""" - self.toggle_layout('next') - - def toggle_layout(self, direction='next'): - """Change current layout.""" - names = self.get_conf('names') - order = self.get_conf('order') - active = self.get_conf('active') - - if len(active) == 0: - return - - layout_index = [] - for name in order: - if name in active: - layout_index.append(names.index(name)) - - current_layout = self._current_quick_layout - dic = {'next': 1, 'previous': -1} - - if current_layout is None: - # Start from default - current_layout = names.index(DefaultLayouts.SpyderLayout) - - if current_layout in layout_index: - current_index = layout_index.index(current_layout) - else: - current_index = 0 - - new_index = (current_index + dic[direction]) % len(layout_index) - index_or_layout_id = layout_index[new_index] - is_layout_id = ( - names[index_or_layout_id] in self._spyder_layouts) - - if is_layout_id: - index_or_layout_id = names[layout_index[new_index]] - - self.quick_layout_switch(index_or_layout_id) - - def quick_layout_switch(self, index_or_layout_id): - """ - Switch to quick layout number *index* or *layout id*. - - Parameters - ---------- - index: int or str - """ - possible_current_layout = self._plugin.quick_layout_switch( - index_or_layout_id) - - if possible_current_layout is not None: - if isinstance(possible_current_layout, int): - self._current_quick_layout = possible_current_layout - else: - names = self.get_conf('names') - self._current_quick_layout = names.index( - possible_current_layout) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Layout container. +""" + +# Standard library imports +from collections import OrderedDict +import sys + +# Third party imports +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import QMessageBox + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.layout.api import BaseGridLayoutType +from spyder.plugins.layout.layouts import DefaultLayouts +from spyder.plugins.layout.widgets.dialog import ( + LayoutSaveDialog, LayoutSettingsDialog) + +# Localization +_ = get_translation("spyder") + + +class LayoutContainerActions: + DefaultLayout = 'default_layout_action' + MatlabLayout = 'matlab_layout_action' + RStudio = 'rstudio_layout_action' + HorizontalSplit = 'horizontal_split_layout_action' + VerticalSplit = 'vertical_split_layout_action' + SaveLayoutAction = 'save_layout_action' + ShowLayoutPreferencesAction = 'show_layout_preferences_action' + ResetLayout = 'reset_layout_action' + # Needs to have 'Maximize pane' as name to properly register + # the action shortcut + MaximizeCurrentDockwidget = 'Maximize pane' + # Needs to have 'Fullscreen mode' as name to properly register + # the action shortcut + Fullscreen = 'Fullscreen mode' + # Needs to have 'Use next layout' as name to properly register + # the action shortcut + NextLayout = 'Use next layout' + # Needs to have 'Use previous layout' as name to properly register + # the action shortcut + PreviousLayout = 'Use previous layout' + # Needs to have 'Close pane' as name to properly register + # the action shortcut + CloseCurrentDockwidget = 'Close pane' + # Needs to have 'Lock unlock panes' as name to properly register + # the action shortcut + LockDockwidgetsAndToolbars = 'Lock unlock panes' + + +class LayoutPluginMenus: + PluginsMenu = "plugins_menu" + LayoutsMenu = 'layouts_menu' + + +class LayoutContainer(PluginMainContainer): + """ + Plugin container class that handles the Spyder quick layouts functionality. + """ + + def setup(self): + # Basic attributes to handle layouts options and dialogs references + self._spyder_layouts = OrderedDict() + self._save_dialog = None + self._settings_dialog = None + self._layouts_menu = None + self._current_quick_layout = None + + # Close current dockable plugin + self._close_dockwidget_action = self.create_action( + LayoutContainerActions.CloseCurrentDockwidget, + text=_('Close current pane'), + icon=self.create_icon('close_pane'), + triggered=self._plugin.close_current_dockwidget, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_' + ) + + # Maximize current dockable plugin + self._maximize_dockwidget_action = self.create_action( + LayoutContainerActions.MaximizeCurrentDockwidget, + text=_('Maximize current pane'), + icon=self.create_icon('maximize'), + toggled=lambda state: self._plugin.maximize_dockwidget(), + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + + # Fullscreen mode + self._fullscreen_action = self.create_action( + LayoutContainerActions.Fullscreen, + text=_('Fullscreen mode'), + triggered=self._plugin.toggle_fullscreen, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + if sys.platform == 'darwin': + self._fullscreen_action.setEnabled(False) + self._fullscreen_action.setToolTip(_("For fullscreen mode use the " + "macOS built-in feature")) + + # Lock dockwidgets and toolbars + self._lock_interface_action = self.create_action( + LayoutContainerActions.LockDockwidgetsAndToolbars, + text='', + triggered=lambda checked: + self._plugin.toggle_lock(), + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_' + ) + + self._save_layout_action = self.create_action( + LayoutContainerActions.SaveLayoutAction, + _("Save current layout"), + triggered=lambda: self.show_save_layout(), + context=Qt.ApplicationShortcut, + register_shortcut=False, + ) + self._show_preferences_action = self.create_action( + LayoutContainerActions.ShowLayoutPreferencesAction, + text=_("Layout preferences"), + triggered=lambda: self.show_layout_settings(), + context=Qt.ApplicationShortcut, + register_shortcut=False, + ) + self._reset_action = self.create_action( + LayoutContainerActions.ResetLayout, + text=_('Reset to Spyder default'), + triggered=self.reset_window_layout, + register_shortcut=False, + ) + + # Layouts shortcuts actions + self._toggle_next_layout_action = self.create_action( + LayoutContainerActions.NextLayout, + _("Use next layout"), + triggered=self.toggle_next_layout, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + self._toggle_previous_layout_action = self.create_action( + LayoutContainerActions.PreviousLayout, + _("Use previous layout"), + triggered=self.toggle_previous_layout, + context=Qt.ApplicationShortcut, + register_shortcut=True, + shortcut_context='_') + + # Layouts menu + self._layouts_menu = self.create_menu( + LayoutPluginMenus.LayoutsMenu, _("Window layouts")) + + self._plugins_menu = self.create_menu( + LayoutPluginMenus.PluginsMenu, _("Panes")) + self._plugins_menu.setObjectName('checkbox-padding') + + def update_actions(self): + pass + + def update_layout_menu_actions(self): + """ + Update layouts menu and layouts related actions. + """ + menu = self._layouts_menu + menu.clear_actions() + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + + actions = [] + for name in order: + if name in active: + if name in self._spyder_layouts: + index = name + name = self._spyder_layouts[index].get_name() + else: + index = names.index(name) + name = ui_names[index] + + # closure required so lambda works with the default parameter + def trigger(i=index, self=self): + return lambda: self.quick_layout_switch(i) + + layout_switch_action = self.create_action( + name, + text=name, + triggered=trigger(), + register_shortcut=False, + overwrite=True + ) + + actions.append(layout_switch_action) + + for item in actions: + self.add_item_to_menu(item, menu, section="layouts_section") + + for item in [self._save_layout_action, self._show_preferences_action, + self._reset_action]: + self.add_item_to_menu(item, menu, section="layouts_section_2") + + self._show_preferences_action.setEnabled(len(order) != 0) + + # --- Public API + # ------------------------------------------------------------------------ + def critical_message(self, title, message): + """Expose a QMessageBox.critical dialog to be used from the plugin.""" + QMessageBox.critical(self, title, message) + + def register_layout(self, parent_plugin, layout_type): + """ + Register a new layout type. + + Parameters + ---------- + parent_plugin: spyder.api.plugins.SpyderPluginV2 + Plugin registering the layout type. + layout_type: spyder.plugins.layout.api.BaseGridLayoutType + Layout to register. + """ + if not issubclass(layout_type, BaseGridLayoutType): + raise SpyderAPIError( + "A layout must be a subclass is `BaseGridLayoutType`!") + + layout_id = layout_type.ID + if layout_id in self._spyder_layouts: + raise SpyderAPIError( + "Layout with id `{}` already registered!".format(layout_id)) + + layout = layout_type(parent_plugin) + layout._check_layout_validity() + self._spyder_layouts[layout_id] = layout + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + + if layout_id not in names: + names.append(layout_id) + ui_names.append(layout.get_name()) + order.append(layout_id) + active.append(layout_id) + self.set_conf('names', names) + self.set_conf('ui_names', ui_names) + self.set_conf('order', order) + self.set_conf('active', active) + + def get_layout(self, layout_id): + """ + Get a registered layout by its ID. + + Parameters + ---------- + layout_id : string + The ID of the layout. + + Raises + ------ + SpyderAPIError + If the given id is not found in the registered layouts. + + Returns + ------- + Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass + Layout. + """ + if layout_id not in self._spyder_layouts: + raise SpyderAPIError( + "Layout with id `{}` is not registered!".format(layout_id)) + + return self._spyder_layouts[layout_id] + + def show_save_layout(self): + """Show the save layout dialog.""" + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + dialog_names = [name for name in names + if name not in self._spyder_layouts.keys()] + dlg = self._save_dialog = LayoutSaveDialog(self, dialog_names) + + if dlg.exec_(): + name = dlg.combo_box.currentText() + if name in self._spyder_layouts: + QMessageBox.critical( + self, + _("Error"), + _("Layout {0} was defined programatically. " + "It is not possible to overwrite programatically " + "registered layouts.").format(name) + ) + return + if name in names: + answer = QMessageBox.warning( + self, + _("Warning"), + _("Layout {0} will be overwritten. " + "Do you want to continue?").format(name), + QMessageBox.Yes | QMessageBox.No, + ) + index = order.index(name) + else: + answer = True + if None in names: + index = names.index(None) + names[index] = name + else: + index = len(names) + names.append(name) + + order.append(name) + + # Always make active a new layout even if it overwrites an + # inactive layout + if name not in active: + active.append(name) + + if name not in ui_names: + ui_names.append(name) + + if answer: + self._plugin.save_current_window_settings( + 'layout_{}/'.format(index), section='quick_layouts') + self.set_conf('names', names) + self.set_conf('ui_names', ui_names) + self.set_conf('order', order) + self.set_conf('active', active) + + self.update_layout_menu_actions() + + def show_layout_settings(self): + """Layout settings dialog.""" + names = self.get_conf('names') + ui_names = self.get_conf('ui_names') + order = self.get_conf('order') + active = self.get_conf('active') + read_only = list(self._spyder_layouts.keys()) + + dlg = self._settings_dialog = LayoutSettingsDialog( + self, names, ui_names, order, active, read_only) + if dlg.exec_(): + self.set_conf('names', dlg.names) + self.set_conf('ui_names', dlg.ui_names) + self.set_conf('order', dlg.order) + self.set_conf('active', dlg.active) + + self.update_layout_menu_actions() + + @Slot() + def reset_window_layout(self): + """Reset window layout to default.""" + answer = QMessageBox.warning( + self, + _("Warning"), + _("Window layout will be reset to default settings: " + "this affects window position, size and dockwidgets.\n" + "Do you want to continue?"), + QMessageBox.Yes | QMessageBox.No, + ) + + if answer == QMessageBox.Yes: + self._plugin.setup_layout(default=True) + + @Slot() + def toggle_previous_layout(self): + """Use the previous layout from the layouts list (default + custom).""" + self.toggle_layout('previous') + + @Slot() + def toggle_next_layout(self): + """Use the next layout from the layouts list (default + custom).""" + self.toggle_layout('next') + + def toggle_layout(self, direction='next'): + """Change current layout.""" + names = self.get_conf('names') + order = self.get_conf('order') + active = self.get_conf('active') + + if len(active) == 0: + return + + layout_index = [] + for name in order: + if name in active: + layout_index.append(names.index(name)) + + current_layout = self._current_quick_layout + dic = {'next': 1, 'previous': -1} + + if current_layout is None: + # Start from default + current_layout = names.index(DefaultLayouts.SpyderLayout) + + if current_layout in layout_index: + current_index = layout_index.index(current_layout) + else: + current_index = 0 + + new_index = (current_index + dic[direction]) % len(layout_index) + index_or_layout_id = layout_index[new_index] + is_layout_id = ( + names[index_or_layout_id] in self._spyder_layouts) + + if is_layout_id: + index_or_layout_id = names[layout_index[new_index]] + + self.quick_layout_switch(index_or_layout_id) + + def quick_layout_switch(self, index_or_layout_id): + """ + Switch to quick layout number *index* or *layout id*. + + Parameters + ---------- + index: int or str + """ + possible_current_layout = self._plugin.quick_layout_switch( + index_or_layout_id) + + if possible_current_layout is not None: + if isinstance(possible_current_layout, int): + self._current_quick_layout = possible_current_layout + else: + names = self.get_conf('names') + self._current_quick_layout = names.index( + possible_current_layout) diff --git a/spyder/plugins/layout/layouts.py b/spyder/plugins/layout/layouts.py index 6d0385e810c..540b1fd27df 100644 --- a/spyder/plugins/layout/layouts.py +++ b/spyder/plugins/layout/layouts.py @@ -1,262 +1,262 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Default layout definitions. -""" - -# Third party imports -from qtpy.QtCore import QRect, QRectF, Qt -from qtpy.QtWidgets import (QApplication, QDockWidget, QGridLayout, - QMainWindow, QPlainTextEdit, QWidget) - -# Local imports -from spyder.api.plugins import Plugins -from spyder.api.translations import get_translation -from spyder.plugins.layout.api import BaseGridLayoutType - - -# Localization -_ = get_translation("spyder") - - -class DefaultLayouts: - SpyderLayout = "Spyder Default Layout" - HorizontalSplitLayout = "Horizontal split" - VerticalSplitLayout = "Vertical split" - RLayout = "Rstudio layout" - MatlabLayout = "Matlab layout" - - -class SpyderLayout(BaseGridLayoutType): - ID = DefaultLayouts.SpyderLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Projects], - row=0, - column=0, - row_span=2, - visible=False, - ) - self.add_area( - [Plugins.Editor], - row=0, - column=1, - row_span=2, - ) - self.add_area( - [Plugins.OutlineExplorer], - row=0, - column=2, - row_span=2, - visible=False, - ) - self.add_area( - [Plugins.Help, Plugins.VariableExplorer, Plugins.Plots, - Plugins.OnlineHelp, Plugins.Explorer, Plugins.Find], - row=0, - column=3, - default=True, - hidden_plugin_ids=[Plugins.OnlineHelp, Plugins.Find] - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.History, Plugins.Console], - row=1, - column=3, - hidden_plugin_ids=[Plugins.Console] - ) - - self.set_column_stretch(0, 1) - self.set_column_stretch(1, 4) - self.set_column_stretch(2, 1) - self.set_column_stretch(3, 4) - - def get_name(self): - return _("Spyder Default Layout") - - -class HorizontalSplitLayout(BaseGridLayoutType): - ID = DefaultLayouts.HorizontalSplitLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Editor], - row=0, - column=0, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, - Plugins.VariableExplorer, Plugins.Plots, Plugins.History], - row=0, - column=1, - default=True, - ) - - self.set_column_stretch(0, 5) - self.set_column_stretch(1, 4) - - def get_name(self): - return _("Horizontal split") - - -class VerticalSplitLayout(BaseGridLayoutType): - ID = DefaultLayouts.VerticalSplitLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Editor], - row=0, - column=0, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, - Plugins.VariableExplorer, Plugins.Plots, Plugins.History], - row=1, - column=0, - default=True, - ) - - self.set_row_stretch(0, 6) - self.set_row_stretch(1, 4) - - def get_name(self): - return _("Vertical split") - - -class RLayout(BaseGridLayoutType): - ID = DefaultLayouts.RLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Editor], - row=0, - column=0, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Console], - row=1, - column=0, - hidden_plugin_ids=[Plugins.Console] - ) - self.add_area( - [Plugins.VariableExplorer, Plugins.Plots, Plugins.History, - Plugins.OutlineExplorer, Plugins.Find], - row=0, - column=1, - default=True, - hidden_plugin_ids=[Plugins.OutlineExplorer, Plugins.Find] - ) - self.add_area( - [Plugins.Explorer, Plugins.Projects, Plugins.Help, - Plugins.OnlineHelp], - row=1, - column=1, - hidden_plugin_ids=[Plugins.Projects, Plugins.OnlineHelp] - ) - - def get_name(self): - return _("Rstudio layout") - - -class MatlabLayout(BaseGridLayoutType): - ID = DefaultLayouts.MatlabLayout - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area( - [Plugins.Explorer, Plugins.Projects], - row=0, - column=0, - hidden_plugin_ids=[Plugins.Projects] - ) - self.add_area( - [Plugins.OutlineExplorer], - row=1, - column=0, - ) - self.add_area( - [Plugins.Editor], - row=0, - column=1, - ) - self.add_area( - [Plugins.IPythonConsole, Plugins.Console], - row=1, - column=1, - hidden_plugin_ids=[Plugins.Console] - ) - self.add_area( - [Plugins.VariableExplorer, Plugins.Plots, Plugins.Find], - row=0, - column=2, - default=True, - hidden_plugin_ids=[Plugins.Find] - ) - self.add_area( - [Plugins.History, Plugins.Help, Plugins.OnlineHelp], - row=1, - column=2, - hidden_plugin_ids=[Plugins.OnlineHelp] - ) - - self.set_column_stretch(0, 2) - self.set_column_stretch(1, 3) - self.set_column_stretch(2, 2) - - self.set_row_stretch(0, 3) - self.set_row_stretch(1, 2) - - def get_name(self): - return _("Matlab layout") - - -class VerticalSplitLayout2(BaseGridLayoutType): - ID = "testing layout" - - def __init__(self, parent_plugin): - super().__init__(parent_plugin) - - self.add_area([Plugins.IPythonConsole], 0, 0, row_span=2) - self.add_area([Plugins.Editor], 0, 1, col_span=2) - self.add_area([Plugins.Explorer], 1, 1, default=True) - self.add_area([Plugins.Help], 1, 2) - self.add_area([Plugins.Console], 0, 3, row_span=2) - self.add_area( - [Plugins.VariableExplorer], 2, 0, col_span=4, visible=False) - - self.set_column_stretch(0, 1) - self.set_column_stretch(1, 4) - self.set_column_stretch(2, 4) - self.set_column_stretch(3, 1) - - self.set_row_stretch(0, 2) - self.set_row_stretch(1, 2) - self.set_row_stretch(2, 1) - - def get_name(self): - return _("testing layout") - - -if __name__ == "__main__": - for layout in [ - # SpyderLayout(None), - # HorizontalSplitLayout(None), - # VerticalSplitLayout(None), - # RLayout(None), - # MatlabLayout(None), - VerticalSplitLayout2(None), - ]: - layout.preview_layout(show_hidden_areas=True) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Default layout definitions. +""" + +# Third party imports +from qtpy.QtCore import QRect, QRectF, Qt +from qtpy.QtWidgets import (QApplication, QDockWidget, QGridLayout, + QMainWindow, QPlainTextEdit, QWidget) + +# Local imports +from spyder.api.plugins import Plugins +from spyder.api.translations import get_translation +from spyder.plugins.layout.api import BaseGridLayoutType + + +# Localization +_ = get_translation("spyder") + + +class DefaultLayouts: + SpyderLayout = "Spyder Default Layout" + HorizontalSplitLayout = "Horizontal split" + VerticalSplitLayout = "Vertical split" + RLayout = "Rstudio layout" + MatlabLayout = "Matlab layout" + + +class SpyderLayout(BaseGridLayoutType): + ID = DefaultLayouts.SpyderLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Projects], + row=0, + column=0, + row_span=2, + visible=False, + ) + self.add_area( + [Plugins.Editor], + row=0, + column=1, + row_span=2, + ) + self.add_area( + [Plugins.OutlineExplorer], + row=0, + column=2, + row_span=2, + visible=False, + ) + self.add_area( + [Plugins.Help, Plugins.VariableExplorer, Plugins.Plots, + Plugins.OnlineHelp, Plugins.Explorer, Plugins.Find], + row=0, + column=3, + default=True, + hidden_plugin_ids=[Plugins.OnlineHelp, Plugins.Find] + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.History, Plugins.Console], + row=1, + column=3, + hidden_plugin_ids=[Plugins.Console] + ) + + self.set_column_stretch(0, 1) + self.set_column_stretch(1, 4) + self.set_column_stretch(2, 1) + self.set_column_stretch(3, 4) + + def get_name(self): + return _("Spyder Default Layout") + + +class HorizontalSplitLayout(BaseGridLayoutType): + ID = DefaultLayouts.HorizontalSplitLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Editor], + row=0, + column=0, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, + Plugins.VariableExplorer, Plugins.Plots, Plugins.History], + row=0, + column=1, + default=True, + ) + + self.set_column_stretch(0, 5) + self.set_column_stretch(1, 4) + + def get_name(self): + return _("Horizontal split") + + +class VerticalSplitLayout(BaseGridLayoutType): + ID = DefaultLayouts.VerticalSplitLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Editor], + row=0, + column=0, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Explorer, Plugins.Help, + Plugins.VariableExplorer, Plugins.Plots, Plugins.History], + row=1, + column=0, + default=True, + ) + + self.set_row_stretch(0, 6) + self.set_row_stretch(1, 4) + + def get_name(self): + return _("Vertical split") + + +class RLayout(BaseGridLayoutType): + ID = DefaultLayouts.RLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Editor], + row=0, + column=0, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Console], + row=1, + column=0, + hidden_plugin_ids=[Plugins.Console] + ) + self.add_area( + [Plugins.VariableExplorer, Plugins.Plots, Plugins.History, + Plugins.OutlineExplorer, Plugins.Find], + row=0, + column=1, + default=True, + hidden_plugin_ids=[Plugins.OutlineExplorer, Plugins.Find] + ) + self.add_area( + [Plugins.Explorer, Plugins.Projects, Plugins.Help, + Plugins.OnlineHelp], + row=1, + column=1, + hidden_plugin_ids=[Plugins.Projects, Plugins.OnlineHelp] + ) + + def get_name(self): + return _("Rstudio layout") + + +class MatlabLayout(BaseGridLayoutType): + ID = DefaultLayouts.MatlabLayout + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area( + [Plugins.Explorer, Plugins.Projects], + row=0, + column=0, + hidden_plugin_ids=[Plugins.Projects] + ) + self.add_area( + [Plugins.OutlineExplorer], + row=1, + column=0, + ) + self.add_area( + [Plugins.Editor], + row=0, + column=1, + ) + self.add_area( + [Plugins.IPythonConsole, Plugins.Console], + row=1, + column=1, + hidden_plugin_ids=[Plugins.Console] + ) + self.add_area( + [Plugins.VariableExplorer, Plugins.Plots, Plugins.Find], + row=0, + column=2, + default=True, + hidden_plugin_ids=[Plugins.Find] + ) + self.add_area( + [Plugins.History, Plugins.Help, Plugins.OnlineHelp], + row=1, + column=2, + hidden_plugin_ids=[Plugins.OnlineHelp] + ) + + self.set_column_stretch(0, 2) + self.set_column_stretch(1, 3) + self.set_column_stretch(2, 2) + + self.set_row_stretch(0, 3) + self.set_row_stretch(1, 2) + + def get_name(self): + return _("Matlab layout") + + +class VerticalSplitLayout2(BaseGridLayoutType): + ID = "testing layout" + + def __init__(self, parent_plugin): + super().__init__(parent_plugin) + + self.add_area([Plugins.IPythonConsole], 0, 0, row_span=2) + self.add_area([Plugins.Editor], 0, 1, col_span=2) + self.add_area([Plugins.Explorer], 1, 1, default=True) + self.add_area([Plugins.Help], 1, 2) + self.add_area([Plugins.Console], 0, 3, row_span=2) + self.add_area( + [Plugins.VariableExplorer], 2, 0, col_span=4, visible=False) + + self.set_column_stretch(0, 1) + self.set_column_stretch(1, 4) + self.set_column_stretch(2, 4) + self.set_column_stretch(3, 1) + + self.set_row_stretch(0, 2) + self.set_row_stretch(1, 2) + self.set_row_stretch(2, 1) + + def get_name(self): + return _("testing layout") + + +if __name__ == "__main__": + for layout in [ + # SpyderLayout(None), + # HorizontalSplitLayout(None), + # VerticalSplitLayout(None), + # RLayout(None), + # MatlabLayout(None), + VerticalSplitLayout2(None), + ]: + layout.preview_layout(show_hidden_areas=True) diff --git a/spyder/plugins/layout/plugin.py b/spyder/plugins/layout/plugin.py index 3d090dfa25f..d210ded0878 100644 --- a/spyder/plugins/layout/plugin.py +++ b/spyder/plugins/layout/plugin.py @@ -1,829 +1,829 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Layout Plugin. -""" -# Standard library imports -import configparser as cp -import os - -# Third party imports -from qtpy.QtCore import Qt, QByteArray, QSize, QPoint, Slot -from qtpy.QtWidgets import QApplication, QDesktopWidget, QDockWidget - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.utils import get_class_values -from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections -from spyder.plugins.layout.container import ( - LayoutContainer, LayoutContainerActions, LayoutPluginMenus) -from spyder.plugins.layout.layouts import (DefaultLayouts, - HorizontalSplitLayout, - MatlabLayout, RLayout, - SpyderLayout, VerticalSplitLayout) -from spyder.plugins.preferences.widgets.container import PreferencesActions -from spyder.plugins.toolbar.api import ( - ApplicationToolbars, MainToolbarSections) -from spyder.py3compat import qbytearray_to_str # FIXME: - - -# Localization -_ = get_translation("spyder") - -# Constants - -# Number of default layouts available -DEFAULT_LAYOUTS = get_class_values(DefaultLayouts) - -# ---------------------------------------------------------------------------- -# ---- Window state version passed to saveState/restoreState. -# ---------------------------------------------------------------------------- -# This defines the layout version used by different Spyder releases. In case -# there's a need to reset the layout when moving from one release to another, -# please increase the number below in integer steps, e.g. from 1 to 2, and -# leave a mention below explaining what prompted the change. -# -# The current versions are: -# -# * Spyder 4: Version 0 (it was the default). -# * Spyder 5.0.0 to 5.0.5: Version 1 (a bump was required due to the new API). -# * Spyder 5.1.0: Version 2 (a bump was required due to the migration of -# Projects to the new API). -# * Spyder 5.2.0: Version 3 (a bump was required due to the migration of -# IPython Console to the new API) -WINDOW_STATE_VERSION = 3 - - -class Layout(SpyderPluginV2): - """ - Layout manager plugin. - """ - NAME = "layout" - CONF_SECTION = "quick_layouts" - REQUIRES = [Plugins.All] # Uses wildcard to require all the plugins - CONF_FILE = False - CONTAINER_CLASS = LayoutContainer - CAN_BE_DISABLED = False - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Layout") - - def get_description(self): - return _("Layout manager") - - def get_icon(self): - return self.create_icon("history") # FIXME: - - def on_initialize(self): - self._last_plugin = None - self._first_spyder_run = False - self._fullscreen_flag = None - # The following flag remember the maximized state even when - # the window is in fullscreen mode: - self._maximized_flag = None - # The following flag is used to restore window's geometry when - # toggling out of fullscreen mode in Windows. - self._saved_normal_geometry = None - self._state_before_maximizing = None - self._interface_locked = self.get_conf('panes_locked', section='main') - - # Register default layouts - self.register_layout(self, SpyderLayout) - self.register_layout(self, RLayout) - self.register_layout(self, MatlabLayout) - self.register_layout(self, HorizontalSplitLayout) - self.register_layout(self, VerticalSplitLayout) - - self._update_fullscreen_action() - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - container = self.get_container() - # Add Panes related actions to View application menu - panes_items = [ - container._plugins_menu, - container._lock_interface_action, - container._close_dockwidget_action, - container._maximize_dockwidget_action] - for panes_item in panes_items: - mainmenu.add_item_to_application_menu( - panes_item, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Pane, - before_section=ViewMenuSections.Toolbar) - # Add layouts menu to View application menu - layout_items = [ - container._layouts_menu, - container._toggle_next_layout_action, - container._toggle_previous_layout_action] - for layout_item in layout_items: - mainmenu.add_item_to_application_menu( - layout_item, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Layout, - before_section=ViewMenuSections.Bottom) - # Add fullscreen action to View application menu - mainmenu.add_item_to_application_menu( - container._fullscreen_action, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Bottom) - - @on_plugin_available(plugin=Plugins.Toolbar) - def on_toolbar_available(self): - container = self.get_container() - toolbars = self.get_plugin(Plugins.Toolbar) - # Add actions to Main application toolbar - toolbars.add_item_to_application_toolbar( - container._maximize_dockwidget_action, - toolbar_id=ApplicationToolbars.Main, - section=MainToolbarSections.ApplicationSection, - before=PreferencesActions.Show - ) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - # Remove Panes related actions from the View application menu - panes_items = [ - LayoutPluginMenus.PluginsMenu, - LayoutContainerActions.LockDockwidgetsAndToolbars, - LayoutContainerActions.CloseCurrentDockwidget, - LayoutContainerActions.MaximizeCurrentDockwidget] - for panes_item in panes_items: - mainmenu.remove_item_from_application_menu( - panes_item, - menu_id=ApplicationMenus.View) - # Remove layouts menu from the View application menu - layout_items = [ - LayoutPluginMenus.LayoutsMenu, - LayoutContainerActions.NextLayout, - LayoutContainerActions.PreviousLayout] - for layout_item in layout_items: - mainmenu.remove_item_from_application_menu( - layout_item, - menu_id=ApplicationMenus.View) - # Remove fullscreen action from the View application menu - mainmenu.remove_item_from_application_menu( - LayoutContainerActions.Fullscreen, - menu_id=ApplicationMenus.View) - - @on_plugin_teardown(plugin=Plugins.Toolbar) - def on_toolbar_teardown(self): - toolbars = self.get_plugin(Plugins.Toolbar) - - # Remove actions from the Main application toolbar - toolbars.remove_item_from_application_toolbar( - LayoutContainerActions.MaximizeCurrentDockwidget, - toolbar_id=ApplicationToolbars.Main - ) - - def before_mainwindow_visible(self): - # Update layout menu - self.update_layout_menu_actions() - # Setup layout - self.setup_layout(default=False) - - def on_mainwindow_visible(self): - # Populate panes menu - self.create_plugins_menu() - # Update panes and toolbars lock status - self.toggle_lock(self._interface_locked) - - # --- Plubic API - # ------------------------------------------------------------------------ - def get_last_plugin(self): - """ - Return the last focused dockable plugin. - - Returns - ------- - SpyderDockablePlugin - The last focused dockable plugin. - """ - return self._last_plugin - - def get_fullscreen_flag(self): - """ - Give access to the fullscreen flag. - - The flag shows if the mainwindow is in fullscreen mode or not. - - Returns - ------- - bool - True is the mainwindow is in fullscreen. False otherwise. - """ - return self._fullscreen_flag - - def register_layout(self, parent_plugin, layout_type): - """ - Register a new layout type. - - Parameters - ---------- - parent_plugin: spyder.api.plugins.SpyderPluginV2 - Plugin registering the layout type. - layout_type: spyder.plugins.layout.api.BaseGridLayoutType - Layout to register. - """ - self.get_container().register_layout(parent_plugin, layout_type) - - def get_layout(self, layout_id): - """ - Get a registered layout by his ID. - - Parameters - ---------- - layout_id : string - The ID of the layout. - - Returns - ------- - Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass - Layout. - """ - return self.get_container().get_layout(layout_id) - - def update_layout_menu_actions(self): - self.get_container().update_layout_menu_actions() - - def setup_layout(self, default=False): - """Initialize mainwindow layout.""" - prefix = 'window' + '/' - settings = self.load_window_settings(prefix, default) - hexstate = settings[0] - - self._first_spyder_run = False - if hexstate is None: - # First Spyder execution: - self.main.setWindowState(Qt.WindowMaximized) - self._first_spyder_run = True - self.setup_default_layouts(DefaultLayouts.SpyderLayout, settings) - - # Now that the initial setup is done, copy the window settings, - # except for the hexstate in the quick layouts sections for the - # default layouts. - # Order and name of the default layouts is found in config.py - section = 'quick_layouts' - get_func = self.get_conf_default if default else self.get_conf - order = get_func('order', section=section) - - # Restore the original defaults if reset layouts is called - if default: - self.set_conf('active', order, section) - self.set_conf('order', order, section) - self.set_conf('names', order, section) - self.set_conf('ui_names', order, section) - - for index, _name, in enumerate(order): - prefix = 'layout_{0}/'.format(index) - self.save_current_window_settings(prefix, section, - none_state=True) - - # Store the initial layout as the default in spyder - prefix = 'layout_default/' - section = 'quick_layouts' - self.save_current_window_settings(prefix, section, none_state=True) - self._current_quick_layout = DefaultLayouts.SpyderLayout - - self.set_window_settings(*settings) - - def setup_default_layouts(self, layout_id, settings): - """Setup default layouts when run for the first time.""" - main = self.main - main.setUpdatesEnabled(False) - - first_spyder_run = bool(self._first_spyder_run) # Store copy - - if first_spyder_run: - self.set_window_settings(*settings) - else: - if self._last_plugin: - if self._last_plugin._ismaximized: - self.maximize_dockwidget(restore=True) - - if not (main.isMaximized() or self._maximized_flag): - main.showMaximized() - - min_width = main.minimumWidth() - max_width = main.maximumWidth() - base_width = main.width() - main.setFixedWidth(base_width) - - # Layout selection - layout = self.get_layout(layout_id) - - # Apply selected layout - layout.set_main_window_layout(self.main, self.get_dockable_plugins()) - - if first_spyder_run: - self._first_spyder_run = False - else: - self.main.setMinimumWidth(min_width) - self.main.setMaximumWidth(max_width) - - if not (self.main.isMaximized() or self._maximized_flag): - self.main.showMaximized() - - self.main.setUpdatesEnabled(True) - self.main.sig_layout_setup_ready.emit(layout) - - return layout - - def quick_layout_switch(self, index_or_layout_id): - """ - Switch to quick layout. - - Using a number *index* or a registered layout id *layout_id*. - - Parameters - ---------- - index_or_layout_id: int or str - """ - section = 'quick_layouts' - container = self.get_container() - try: - settings = self.load_window_settings( - 'layout_{}/'.format(index_or_layout_id), section=section) - (hexstate, window_size, prefs_dialog_size, pos, is_maximized, - is_fullscreen) = settings - - # The defaults layouts will always be regenerated unless there was - # an overwrite, either by rewriting with same name, or by deleting - # and then creating a new one - if hexstate is None: - # The value for hexstate shouldn't be None for a custom saved - # layout (ie, where the index is greater than the number of - # defaults). See spyder-ide/spyder#6202. - if index_or_layout_id not in DEFAULT_LAYOUTS: - container.critical_message( - _("Warning"), - _("Error opening the custom layout. Please close" - " Spyder and try again. If the issue persists," - " then you must use 'Reset to Spyder default' " - "from the layout menu.")) - return - self.setup_default_layouts(index_or_layout_id, settings) - else: - self.set_window_settings(*settings) - except cp.NoOptionError: - try: - layout = self.get_layout(index_or_layout_id) - layout.set_main_window_layout( - self.main, self.get_dockable_plugins()) - self.main.sig_layout_setup_ready.emit(layout) - except SpyderAPIError: - container.critical_message( - _("Warning"), - _("Quick switch layout #%s has not yet " - "been defined.") % str(index_or_layout_id)) - - # Make sure the flags are correctly set for visible panes - for plugin in self.get_dockable_plugins(): - try: - # New API - action = plugin.toggle_view_action - except AttributeError: - # Old API - action = plugin._toggle_view_action - action.setChecked(plugin.dockwidget.isVisible()) - - return index_or_layout_id - - def load_window_settings(self, prefix, default=False, section='main'): - """ - Load window layout settings from userconfig-based configuration with - *prefix*, under *section*. - - Parameters - ---------- - default: bool - if True, do not restore inner layout. - """ - get_func = self.get_conf_default if default else self.get_conf - window_size = get_func(prefix + 'size', section=section) - prefs_dialog_size = get_func( - prefix + 'prefs_dialog_size', section=section) - - if default: - hexstate = None - else: - try: - hexstate = get_func(prefix + 'state', section=section) - except Exception: - hexstate = None - - pos = get_func(prefix + 'position', section=section) - - # It's necessary to verify if the window/position value is valid - # with the current screen. See spyder-ide/spyder#3748. - width = pos[0] - height = pos[1] - screen_shape = QApplication.desktop().geometry() - current_width = screen_shape.width() - current_height = screen_shape.height() - if current_width < width or current_height < height: - pos = self.get_conf_default(prefix + 'position', section) - - is_maximized = get_func(prefix + 'is_maximized', section=section) - is_fullscreen = get_func(prefix + 'is_fullscreen', section=section) - return (hexstate, window_size, prefs_dialog_size, pos, is_maximized, - is_fullscreen) - - def get_window_settings(self): - """ - Return current window settings. - - Symetric to the 'set_window_settings' setter. - """ - # FIXME: Window size in main window is update on resize - window_size = (self.window_size.width(), self.window_size.height()) - - is_fullscreen = self.main.isFullScreen() - if is_fullscreen: - is_maximized = self._maximized_flag - else: - is_maximized = self.main.isMaximized() - - pos = (self.window_position.x(), self.window_position.y()) - prefs_dialog_size = (self.prefs_dialog_size.width(), - self.prefs_dialog_size.height()) - - hexstate = qbytearray_to_str( - self.main.saveState(version=WINDOW_STATE_VERSION) - ) - return (hexstate, window_size, prefs_dialog_size, pos, is_maximized, - is_fullscreen) - - def set_window_settings(self, hexstate, window_size, prefs_dialog_size, - pos, is_maximized, is_fullscreen): - """ - Set window settings Symetric to the 'get_window_settings' accessor. - """ - main = self.main - main.setUpdatesEnabled(False) - self.prefs_dialog_size = QSize(prefs_dialog_size[0], - prefs_dialog_size[1]) # width,height - main.set_prefs_size(self.prefs_dialog_size) - self.window_size = QSize(window_size[0], - window_size[1]) # width, height - self.window_position = QPoint(pos[0], pos[1]) # x,y - main.setWindowState(Qt.WindowNoState) - main.resize(self.window_size) - main.move(self.window_position) - - # Window layout - if hexstate: - hexstate_valid = self.main.restoreState( - QByteArray().fromHex(str(hexstate).encode('utf-8')), - version=WINDOW_STATE_VERSION - ) - - # Check layout validity. Spyder 4 and below use the version 0 - # state (default), whereas Spyder 5 will use version 1 state. - # For more info see the version argument for - # QMainWindow.restoreState: - # https://doc.qt.io/qt-5/qmainwindow.html#restoreState - if not hexstate_valid: - self.main.setUpdatesEnabled(True) - self.setup_layout(default=True) - return - - # Is fullscreen? - if is_fullscreen: - self.main.setWindowState(Qt.WindowFullScreen) - - # Is maximized? - if is_fullscreen: - self._maximized_flag = is_maximized - elif is_maximized: - self.main.setWindowState(Qt.WindowMaximized) - - self.main.setUpdatesEnabled(True) - - def save_current_window_settings(self, prefix, section='main', - none_state=False): - """ - Save current window settings. - - It saves config with *prefix* in the userconfig-based, - configuration under *section*. - """ - # Use current size and position when saving window settings. - # Fixes spyder-ide/spyder#13882 - win_size = self.main.size() - pos = self.main.pos() - prefs_size = self.prefs_dialog_size - - self.set_conf( - prefix + 'size', - (win_size.width(), win_size.height()), - section=section, - ) - self.set_conf( - prefix + 'prefs_dialog_size', - (prefs_size.width(), prefs_size.height()), - section=section, - ) - self.set_conf( - prefix + 'is_maximized', - self.main.isMaximized(), - section=section, - ) - self.set_conf( - prefix + 'is_fullscreen', - self.main.isFullScreen(), - section=section, - ) - self.set_conf( - prefix + 'position', - (pos.x(), pos.y()), - section=section, - ) - - self.maximize_dockwidget(restore=True) # Restore non-maximized layout - - if none_state: - self.set_conf( - prefix + 'state', - None, - section=section, - ) - else: - qba = self.main.saveState(version=WINDOW_STATE_VERSION) - self.set_conf( - prefix + 'state', - qbytearray_to_str(qba), - section=section, - ) - - self.set_conf( - prefix + 'statusbar', - not self.main.statusBar().isHidden(), - section=section, - ) - - @Slot() - def close_current_dockwidget(self): - """Search for the currently focused plugin and close it.""" - widget = QApplication.focusWidget() - for plugin in self.get_dockable_plugins(): - # TODO: remove old API - try: - # New API - if plugin.get_widget().isAncestorOf(widget): - plugin.toggle_view_action.setChecked(False) - break - except AttributeError: - # Old API - if plugin.isAncestorOf(widget): - plugin._toggle_view_action.setChecked(False) - break - - @property - def maximize_action(self): - """Expose maximize current dockwidget action.""" - return self.get_container()._maximize_dockwidget_action - - def maximize_dockwidget(self, restore=False): - """ - Maximize current dockwidget. - - Shortcut: Ctrl+Alt+Shift+M - First call: maximize current dockwidget - Second call (or restore=True): restore original window layout - """ - if self._state_before_maximizing is None: - if restore: - return - - # Select plugin to maximize - self._state_before_maximizing = self.main.saveState( - version=WINDOW_STATE_VERSION - ) - focus_widget = QApplication.focusWidget() - - for plugin in self.get_dockable_plugins(): - plugin.dockwidget.hide() - - try: - # New API - if plugin.get_widget().isAncestorOf(focus_widget): - self._last_plugin = plugin - except Exception: - # Old API - if plugin.isAncestorOf(focus_widget): - self._last_plugin = plugin - - # Only plugins that have a dockwidget are part of widgetlist, - # so last_plugin can be None after the above "for" cycle. - # For example, this happens if, after Spyder has started, focus - # is set to the Working directory toolbar (which doesn't have - # a dockwidget) and then you press the Maximize button - if self._last_plugin is None: - # Using the Editor as default plugin to maximize - self._last_plugin = self.get_plugin(Plugins.Editor) - - # Maximize last_plugin - self._last_plugin.dockwidget.toggleViewAction().setDisabled(True) - try: - # New API - self.main.setCentralWidget(self._last_plugin.get_widget()) - except AttributeError: - # Old API - self.main.setCentralWidget(self._last_plugin) - self._last_plugin._ismaximized = True - - # Workaround to solve an issue with editor's outline explorer: - # (otherwise the whole plugin is hidden and so is the outline - # explorer and the latter won't be refreshed if not visible) - try: - # New API - self._last_plugin.get_widget().show() - self._last_plugin.change_visibility(True) - except AttributeError: - # Old API - self._last_plugin.show() - self._last_plugin._visibility_changed(True) - - if self._last_plugin is self.main.editor: - # Automatically show the outline if the editor was maximized: - outline_explorer = self.get_plugin(Plugins.OutlineExplorer) - self.main.addDockWidget( - Qt.RightDockWidgetArea, - outline_explorer.dockwidget) - outline_explorer.dockwidget.show() - else: - # Restore original layout (before maximizing current dockwidget) - try: - # New API - self._last_plugin.dockwidget.setWidget( - self._last_plugin.get_widget()) - except AttributeError: - # Old API - self._last_plugin.dockwidget.setWidget(self._last_plugin) - self._last_plugin.dockwidget.toggleViewAction().setEnabled(True) - self.main.setCentralWidget(None) - - try: - # New API - self._last_plugin.get_widget().is_maximized = False - except AttributeError: - # Old API - self._last_plugin._ismaximized = False - - self.main.restoreState( - self._state_before_maximizing, version=WINDOW_STATE_VERSION - ) - self._state_before_maximizing = None - try: - # New API - self._last_plugin.get_widget().get_focus_widget().setFocus() - except AttributeError: - # Old API - self._last_plugin.get_focus_widget().setFocus() - - def _update_fullscreen_action(self): - if self._fullscreen_flag: - icon = self.create_icon('window_nofullscreen') - else: - icon = self.create_icon('window_fullscreen') - self.get_container()._fullscreen_action.setIcon(icon) - - @Slot() - def toggle_fullscreen(self): - """ - Toggle option to show the mainwindow in fullscreen or windowed. - """ - main = self.main - if self._fullscreen_flag: - self._fullscreen_flag = False - if os.name == 'nt': - main.setWindowFlags( - main.windowFlags() - ^ (Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)) - main.setGeometry(self._saved_normal_geometry) - main.showNormal() - if self._maximized_flag: - main.showMaximized() - else: - self._maximized_flag = main.isMaximized() - self._fullscreen_flag = True - self._saved_normal_geometry = main.normalGeometry() - if os.name == 'nt': - # Due to limitations of the Windows DWM, compositing is not - # handled correctly for OpenGL based windows when going into - # full screen mode, so we need to use this workaround. - # See spyder-ide/spyder#4291. - main.setWindowFlags(main.windowFlags() - | Qt.FramelessWindowHint - | Qt.WindowStaysOnTopHint) - - screen_number = QDesktopWidget().screenNumber(main) - if screen_number < 0: - screen_number = 0 - - r = QApplication.desktop().screenGeometry(screen_number) - main.setGeometry( - r.left() - 1, r.top() - 1, r.width() + 2, r.height() + 2) - main.showNormal() - else: - main.showFullScreen() - self._update_fullscreen_action() - - @property - def plugins_menu(self): - """Expose plugins toggle actions menu.""" - return self.get_container()._plugins_menu - - def create_plugins_menu(self): - """ - Populate panes menu with the toggle view action of each base plugin. - """ - order = ['editor', 'ipython_console', 'variable_explorer', - 'help', 'plots', None, 'explorer', 'outline_explorer', - 'project_explorer', 'find_in_files', None, 'historylog', - 'profiler', 'breakpoints', 'pylint', None, - 'onlinehelp', 'internal_console', None] - - for plugin in self.get_dockable_plugins(): - try: - # New API - action = plugin.toggle_view_action - except AttributeError: - # Old API - action = plugin._toggle_view_action - action.action_id = f'switch to {plugin.CONF_SECTION}' - - if action: - action.setChecked(plugin.dockwidget.isVisible()) - - try: - name = plugin.CONF_SECTION - pos = order.index(name) - except ValueError: - pos = None - - if pos is not None: - order[pos] = action - else: - order.append(action) - - actions = order[:] - for action in actions: - if type(action) is not str: - self.get_container()._plugins_menu.add_action(action) - - @property - def lock_interface_action(self): - return self.get_container()._lock_interface_action - - def _update_lock_interface_action(self): - """ - Helper method to update the locking of panes/dockwidgets and toolbars. - - Returns - ------- - None. - """ - if self._interface_locked: - icon = self.create_icon('drag_dock_widget') - text = _('Unlock panes and toolbars') - else: - icon = self.create_icon('lock') - text = _('Lock panes and toolbars') - self.lock_interface_action.setIcon(icon) - self.lock_interface_action.setText(text) - - def toggle_lock(self, value=None): - """Lock/Unlock dockwidgets and toolbars.""" - self._interface_locked = ( - not self._interface_locked if value is None else value) - self.set_conf('panes_locked', self._interface_locked, 'main') - self._update_lock_interface_action() - # Apply lock to panes - for plugin in self.get_dockable_plugins(): - if self._interface_locked: - if plugin.dockwidget.isFloating(): - plugin.dockwidget.setFloating(False) - - plugin.dockwidget.remove_title_bar() - else: - plugin.dockwidget.set_title_bar() - - # Apply lock to toolbars - toolbar = self.get_plugin(Plugins.Toolbar) - if toolbar: - toolbar.toggle_lock(value=self._interface_locked) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Layout Plugin. +""" +# Standard library imports +import configparser as cp +import os + +# Third party imports +from qtpy.QtCore import Qt, QByteArray, QSize, QPoint, Slot +from qtpy.QtWidgets import QApplication, QDesktopWidget, QDockWidget + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.utils import get_class_values +from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections +from spyder.plugins.layout.container import ( + LayoutContainer, LayoutContainerActions, LayoutPluginMenus) +from spyder.plugins.layout.layouts import (DefaultLayouts, + HorizontalSplitLayout, + MatlabLayout, RLayout, + SpyderLayout, VerticalSplitLayout) +from spyder.plugins.preferences.widgets.container import PreferencesActions +from spyder.plugins.toolbar.api import ( + ApplicationToolbars, MainToolbarSections) +from spyder.py3compat import qbytearray_to_str # FIXME: + + +# Localization +_ = get_translation("spyder") + +# Constants + +# Number of default layouts available +DEFAULT_LAYOUTS = get_class_values(DefaultLayouts) + +# ---------------------------------------------------------------------------- +# ---- Window state version passed to saveState/restoreState. +# ---------------------------------------------------------------------------- +# This defines the layout version used by different Spyder releases. In case +# there's a need to reset the layout when moving from one release to another, +# please increase the number below in integer steps, e.g. from 1 to 2, and +# leave a mention below explaining what prompted the change. +# +# The current versions are: +# +# * Spyder 4: Version 0 (it was the default). +# * Spyder 5.0.0 to 5.0.5: Version 1 (a bump was required due to the new API). +# * Spyder 5.1.0: Version 2 (a bump was required due to the migration of +# Projects to the new API). +# * Spyder 5.2.0: Version 3 (a bump was required due to the migration of +# IPython Console to the new API) +WINDOW_STATE_VERSION = 3 + + +class Layout(SpyderPluginV2): + """ + Layout manager plugin. + """ + NAME = "layout" + CONF_SECTION = "quick_layouts" + REQUIRES = [Plugins.All] # Uses wildcard to require all the plugins + CONF_FILE = False + CONTAINER_CLASS = LayoutContainer + CAN_BE_DISABLED = False + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Layout") + + def get_description(self): + return _("Layout manager") + + def get_icon(self): + return self.create_icon("history") # FIXME: + + def on_initialize(self): + self._last_plugin = None + self._first_spyder_run = False + self._fullscreen_flag = None + # The following flag remember the maximized state even when + # the window is in fullscreen mode: + self._maximized_flag = None + # The following flag is used to restore window's geometry when + # toggling out of fullscreen mode in Windows. + self._saved_normal_geometry = None + self._state_before_maximizing = None + self._interface_locked = self.get_conf('panes_locked', section='main') + + # Register default layouts + self.register_layout(self, SpyderLayout) + self.register_layout(self, RLayout) + self.register_layout(self, MatlabLayout) + self.register_layout(self, HorizontalSplitLayout) + self.register_layout(self, VerticalSplitLayout) + + self._update_fullscreen_action() + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + container = self.get_container() + # Add Panes related actions to View application menu + panes_items = [ + container._plugins_menu, + container._lock_interface_action, + container._close_dockwidget_action, + container._maximize_dockwidget_action] + for panes_item in panes_items: + mainmenu.add_item_to_application_menu( + panes_item, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Pane, + before_section=ViewMenuSections.Toolbar) + # Add layouts menu to View application menu + layout_items = [ + container._layouts_menu, + container._toggle_next_layout_action, + container._toggle_previous_layout_action] + for layout_item in layout_items: + mainmenu.add_item_to_application_menu( + layout_item, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Layout, + before_section=ViewMenuSections.Bottom) + # Add fullscreen action to View application menu + mainmenu.add_item_to_application_menu( + container._fullscreen_action, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Bottom) + + @on_plugin_available(plugin=Plugins.Toolbar) + def on_toolbar_available(self): + container = self.get_container() + toolbars = self.get_plugin(Plugins.Toolbar) + # Add actions to Main application toolbar + toolbars.add_item_to_application_toolbar( + container._maximize_dockwidget_action, + toolbar_id=ApplicationToolbars.Main, + section=MainToolbarSections.ApplicationSection, + before=PreferencesActions.Show + ) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + # Remove Panes related actions from the View application menu + panes_items = [ + LayoutPluginMenus.PluginsMenu, + LayoutContainerActions.LockDockwidgetsAndToolbars, + LayoutContainerActions.CloseCurrentDockwidget, + LayoutContainerActions.MaximizeCurrentDockwidget] + for panes_item in panes_items: + mainmenu.remove_item_from_application_menu( + panes_item, + menu_id=ApplicationMenus.View) + # Remove layouts menu from the View application menu + layout_items = [ + LayoutPluginMenus.LayoutsMenu, + LayoutContainerActions.NextLayout, + LayoutContainerActions.PreviousLayout] + for layout_item in layout_items: + mainmenu.remove_item_from_application_menu( + layout_item, + menu_id=ApplicationMenus.View) + # Remove fullscreen action from the View application menu + mainmenu.remove_item_from_application_menu( + LayoutContainerActions.Fullscreen, + menu_id=ApplicationMenus.View) + + @on_plugin_teardown(plugin=Plugins.Toolbar) + def on_toolbar_teardown(self): + toolbars = self.get_plugin(Plugins.Toolbar) + + # Remove actions from the Main application toolbar + toolbars.remove_item_from_application_toolbar( + LayoutContainerActions.MaximizeCurrentDockwidget, + toolbar_id=ApplicationToolbars.Main + ) + + def before_mainwindow_visible(self): + # Update layout menu + self.update_layout_menu_actions() + # Setup layout + self.setup_layout(default=False) + + def on_mainwindow_visible(self): + # Populate panes menu + self.create_plugins_menu() + # Update panes and toolbars lock status + self.toggle_lock(self._interface_locked) + + # --- Plubic API + # ------------------------------------------------------------------------ + def get_last_plugin(self): + """ + Return the last focused dockable plugin. + + Returns + ------- + SpyderDockablePlugin + The last focused dockable plugin. + """ + return self._last_plugin + + def get_fullscreen_flag(self): + """ + Give access to the fullscreen flag. + + The flag shows if the mainwindow is in fullscreen mode or not. + + Returns + ------- + bool + True is the mainwindow is in fullscreen. False otherwise. + """ + return self._fullscreen_flag + + def register_layout(self, parent_plugin, layout_type): + """ + Register a new layout type. + + Parameters + ---------- + parent_plugin: spyder.api.plugins.SpyderPluginV2 + Plugin registering the layout type. + layout_type: spyder.plugins.layout.api.BaseGridLayoutType + Layout to register. + """ + self.get_container().register_layout(parent_plugin, layout_type) + + def get_layout(self, layout_id): + """ + Get a registered layout by his ID. + + Parameters + ---------- + layout_id : string + The ID of the layout. + + Returns + ------- + Instance of a spyder.plugins.layout.api.BaseGridLayoutType subclass + Layout. + """ + return self.get_container().get_layout(layout_id) + + def update_layout_menu_actions(self): + self.get_container().update_layout_menu_actions() + + def setup_layout(self, default=False): + """Initialize mainwindow layout.""" + prefix = 'window' + '/' + settings = self.load_window_settings(prefix, default) + hexstate = settings[0] + + self._first_spyder_run = False + if hexstate is None: + # First Spyder execution: + self.main.setWindowState(Qt.WindowMaximized) + self._first_spyder_run = True + self.setup_default_layouts(DefaultLayouts.SpyderLayout, settings) + + # Now that the initial setup is done, copy the window settings, + # except for the hexstate in the quick layouts sections for the + # default layouts. + # Order and name of the default layouts is found in config.py + section = 'quick_layouts' + get_func = self.get_conf_default if default else self.get_conf + order = get_func('order', section=section) + + # Restore the original defaults if reset layouts is called + if default: + self.set_conf('active', order, section) + self.set_conf('order', order, section) + self.set_conf('names', order, section) + self.set_conf('ui_names', order, section) + + for index, _name, in enumerate(order): + prefix = 'layout_{0}/'.format(index) + self.save_current_window_settings(prefix, section, + none_state=True) + + # Store the initial layout as the default in spyder + prefix = 'layout_default/' + section = 'quick_layouts' + self.save_current_window_settings(prefix, section, none_state=True) + self._current_quick_layout = DefaultLayouts.SpyderLayout + + self.set_window_settings(*settings) + + def setup_default_layouts(self, layout_id, settings): + """Setup default layouts when run for the first time.""" + main = self.main + main.setUpdatesEnabled(False) + + first_spyder_run = bool(self._first_spyder_run) # Store copy + + if first_spyder_run: + self.set_window_settings(*settings) + else: + if self._last_plugin: + if self._last_plugin._ismaximized: + self.maximize_dockwidget(restore=True) + + if not (main.isMaximized() or self._maximized_flag): + main.showMaximized() + + min_width = main.minimumWidth() + max_width = main.maximumWidth() + base_width = main.width() + main.setFixedWidth(base_width) + + # Layout selection + layout = self.get_layout(layout_id) + + # Apply selected layout + layout.set_main_window_layout(self.main, self.get_dockable_plugins()) + + if first_spyder_run: + self._first_spyder_run = False + else: + self.main.setMinimumWidth(min_width) + self.main.setMaximumWidth(max_width) + + if not (self.main.isMaximized() or self._maximized_flag): + self.main.showMaximized() + + self.main.setUpdatesEnabled(True) + self.main.sig_layout_setup_ready.emit(layout) + + return layout + + def quick_layout_switch(self, index_or_layout_id): + """ + Switch to quick layout. + + Using a number *index* or a registered layout id *layout_id*. + + Parameters + ---------- + index_or_layout_id: int or str + """ + section = 'quick_layouts' + container = self.get_container() + try: + settings = self.load_window_settings( + 'layout_{}/'.format(index_or_layout_id), section=section) + (hexstate, window_size, prefs_dialog_size, pos, is_maximized, + is_fullscreen) = settings + + # The defaults layouts will always be regenerated unless there was + # an overwrite, either by rewriting with same name, or by deleting + # and then creating a new one + if hexstate is None: + # The value for hexstate shouldn't be None for a custom saved + # layout (ie, where the index is greater than the number of + # defaults). See spyder-ide/spyder#6202. + if index_or_layout_id not in DEFAULT_LAYOUTS: + container.critical_message( + _("Warning"), + _("Error opening the custom layout. Please close" + " Spyder and try again. If the issue persists," + " then you must use 'Reset to Spyder default' " + "from the layout menu.")) + return + self.setup_default_layouts(index_or_layout_id, settings) + else: + self.set_window_settings(*settings) + except cp.NoOptionError: + try: + layout = self.get_layout(index_or_layout_id) + layout.set_main_window_layout( + self.main, self.get_dockable_plugins()) + self.main.sig_layout_setup_ready.emit(layout) + except SpyderAPIError: + container.critical_message( + _("Warning"), + _("Quick switch layout #%s has not yet " + "been defined.") % str(index_or_layout_id)) + + # Make sure the flags are correctly set for visible panes + for plugin in self.get_dockable_plugins(): + try: + # New API + action = plugin.toggle_view_action + except AttributeError: + # Old API + action = plugin._toggle_view_action + action.setChecked(plugin.dockwidget.isVisible()) + + return index_or_layout_id + + def load_window_settings(self, prefix, default=False, section='main'): + """ + Load window layout settings from userconfig-based configuration with + *prefix*, under *section*. + + Parameters + ---------- + default: bool + if True, do not restore inner layout. + """ + get_func = self.get_conf_default if default else self.get_conf + window_size = get_func(prefix + 'size', section=section) + prefs_dialog_size = get_func( + prefix + 'prefs_dialog_size', section=section) + + if default: + hexstate = None + else: + try: + hexstate = get_func(prefix + 'state', section=section) + except Exception: + hexstate = None + + pos = get_func(prefix + 'position', section=section) + + # It's necessary to verify if the window/position value is valid + # with the current screen. See spyder-ide/spyder#3748. + width = pos[0] + height = pos[1] + screen_shape = QApplication.desktop().geometry() + current_width = screen_shape.width() + current_height = screen_shape.height() + if current_width < width or current_height < height: + pos = self.get_conf_default(prefix + 'position', section) + + is_maximized = get_func(prefix + 'is_maximized', section=section) + is_fullscreen = get_func(prefix + 'is_fullscreen', section=section) + return (hexstate, window_size, prefs_dialog_size, pos, is_maximized, + is_fullscreen) + + def get_window_settings(self): + """ + Return current window settings. + + Symetric to the 'set_window_settings' setter. + """ + # FIXME: Window size in main window is update on resize + window_size = (self.window_size.width(), self.window_size.height()) + + is_fullscreen = self.main.isFullScreen() + if is_fullscreen: + is_maximized = self._maximized_flag + else: + is_maximized = self.main.isMaximized() + + pos = (self.window_position.x(), self.window_position.y()) + prefs_dialog_size = (self.prefs_dialog_size.width(), + self.prefs_dialog_size.height()) + + hexstate = qbytearray_to_str( + self.main.saveState(version=WINDOW_STATE_VERSION) + ) + return (hexstate, window_size, prefs_dialog_size, pos, is_maximized, + is_fullscreen) + + def set_window_settings(self, hexstate, window_size, prefs_dialog_size, + pos, is_maximized, is_fullscreen): + """ + Set window settings Symetric to the 'get_window_settings' accessor. + """ + main = self.main + main.setUpdatesEnabled(False) + self.prefs_dialog_size = QSize(prefs_dialog_size[0], + prefs_dialog_size[1]) # width,height + main.set_prefs_size(self.prefs_dialog_size) + self.window_size = QSize(window_size[0], + window_size[1]) # width, height + self.window_position = QPoint(pos[0], pos[1]) # x,y + main.setWindowState(Qt.WindowNoState) + main.resize(self.window_size) + main.move(self.window_position) + + # Window layout + if hexstate: + hexstate_valid = self.main.restoreState( + QByteArray().fromHex(str(hexstate).encode('utf-8')), + version=WINDOW_STATE_VERSION + ) + + # Check layout validity. Spyder 4 and below use the version 0 + # state (default), whereas Spyder 5 will use version 1 state. + # For more info see the version argument for + # QMainWindow.restoreState: + # https://doc.qt.io/qt-5/qmainwindow.html#restoreState + if not hexstate_valid: + self.main.setUpdatesEnabled(True) + self.setup_layout(default=True) + return + + # Is fullscreen? + if is_fullscreen: + self.main.setWindowState(Qt.WindowFullScreen) + + # Is maximized? + if is_fullscreen: + self._maximized_flag = is_maximized + elif is_maximized: + self.main.setWindowState(Qt.WindowMaximized) + + self.main.setUpdatesEnabled(True) + + def save_current_window_settings(self, prefix, section='main', + none_state=False): + """ + Save current window settings. + + It saves config with *prefix* in the userconfig-based, + configuration under *section*. + """ + # Use current size and position when saving window settings. + # Fixes spyder-ide/spyder#13882 + win_size = self.main.size() + pos = self.main.pos() + prefs_size = self.prefs_dialog_size + + self.set_conf( + prefix + 'size', + (win_size.width(), win_size.height()), + section=section, + ) + self.set_conf( + prefix + 'prefs_dialog_size', + (prefs_size.width(), prefs_size.height()), + section=section, + ) + self.set_conf( + prefix + 'is_maximized', + self.main.isMaximized(), + section=section, + ) + self.set_conf( + prefix + 'is_fullscreen', + self.main.isFullScreen(), + section=section, + ) + self.set_conf( + prefix + 'position', + (pos.x(), pos.y()), + section=section, + ) + + self.maximize_dockwidget(restore=True) # Restore non-maximized layout + + if none_state: + self.set_conf( + prefix + 'state', + None, + section=section, + ) + else: + qba = self.main.saveState(version=WINDOW_STATE_VERSION) + self.set_conf( + prefix + 'state', + qbytearray_to_str(qba), + section=section, + ) + + self.set_conf( + prefix + 'statusbar', + not self.main.statusBar().isHidden(), + section=section, + ) + + @Slot() + def close_current_dockwidget(self): + """Search for the currently focused plugin and close it.""" + widget = QApplication.focusWidget() + for plugin in self.get_dockable_plugins(): + # TODO: remove old API + try: + # New API + if plugin.get_widget().isAncestorOf(widget): + plugin.toggle_view_action.setChecked(False) + break + except AttributeError: + # Old API + if plugin.isAncestorOf(widget): + plugin._toggle_view_action.setChecked(False) + break + + @property + def maximize_action(self): + """Expose maximize current dockwidget action.""" + return self.get_container()._maximize_dockwidget_action + + def maximize_dockwidget(self, restore=False): + """ + Maximize current dockwidget. + + Shortcut: Ctrl+Alt+Shift+M + First call: maximize current dockwidget + Second call (or restore=True): restore original window layout + """ + if self._state_before_maximizing is None: + if restore: + return + + # Select plugin to maximize + self._state_before_maximizing = self.main.saveState( + version=WINDOW_STATE_VERSION + ) + focus_widget = QApplication.focusWidget() + + for plugin in self.get_dockable_plugins(): + plugin.dockwidget.hide() + + try: + # New API + if plugin.get_widget().isAncestorOf(focus_widget): + self._last_plugin = plugin + except Exception: + # Old API + if plugin.isAncestorOf(focus_widget): + self._last_plugin = plugin + + # Only plugins that have a dockwidget are part of widgetlist, + # so last_plugin can be None after the above "for" cycle. + # For example, this happens if, after Spyder has started, focus + # is set to the Working directory toolbar (which doesn't have + # a dockwidget) and then you press the Maximize button + if self._last_plugin is None: + # Using the Editor as default plugin to maximize + self._last_plugin = self.get_plugin(Plugins.Editor) + + # Maximize last_plugin + self._last_plugin.dockwidget.toggleViewAction().setDisabled(True) + try: + # New API + self.main.setCentralWidget(self._last_plugin.get_widget()) + except AttributeError: + # Old API + self.main.setCentralWidget(self._last_plugin) + self._last_plugin._ismaximized = True + + # Workaround to solve an issue with editor's outline explorer: + # (otherwise the whole plugin is hidden and so is the outline + # explorer and the latter won't be refreshed if not visible) + try: + # New API + self._last_plugin.get_widget().show() + self._last_plugin.change_visibility(True) + except AttributeError: + # Old API + self._last_plugin.show() + self._last_plugin._visibility_changed(True) + + if self._last_plugin is self.main.editor: + # Automatically show the outline if the editor was maximized: + outline_explorer = self.get_plugin(Plugins.OutlineExplorer) + self.main.addDockWidget( + Qt.RightDockWidgetArea, + outline_explorer.dockwidget) + outline_explorer.dockwidget.show() + else: + # Restore original layout (before maximizing current dockwidget) + try: + # New API + self._last_plugin.dockwidget.setWidget( + self._last_plugin.get_widget()) + except AttributeError: + # Old API + self._last_plugin.dockwidget.setWidget(self._last_plugin) + self._last_plugin.dockwidget.toggleViewAction().setEnabled(True) + self.main.setCentralWidget(None) + + try: + # New API + self._last_plugin.get_widget().is_maximized = False + except AttributeError: + # Old API + self._last_plugin._ismaximized = False + + self.main.restoreState( + self._state_before_maximizing, version=WINDOW_STATE_VERSION + ) + self._state_before_maximizing = None + try: + # New API + self._last_plugin.get_widget().get_focus_widget().setFocus() + except AttributeError: + # Old API + self._last_plugin.get_focus_widget().setFocus() + + def _update_fullscreen_action(self): + if self._fullscreen_flag: + icon = self.create_icon('window_nofullscreen') + else: + icon = self.create_icon('window_fullscreen') + self.get_container()._fullscreen_action.setIcon(icon) + + @Slot() + def toggle_fullscreen(self): + """ + Toggle option to show the mainwindow in fullscreen or windowed. + """ + main = self.main + if self._fullscreen_flag: + self._fullscreen_flag = False + if os.name == 'nt': + main.setWindowFlags( + main.windowFlags() + ^ (Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)) + main.setGeometry(self._saved_normal_geometry) + main.showNormal() + if self._maximized_flag: + main.showMaximized() + else: + self._maximized_flag = main.isMaximized() + self._fullscreen_flag = True + self._saved_normal_geometry = main.normalGeometry() + if os.name == 'nt': + # Due to limitations of the Windows DWM, compositing is not + # handled correctly for OpenGL based windows when going into + # full screen mode, so we need to use this workaround. + # See spyder-ide/spyder#4291. + main.setWindowFlags(main.windowFlags() + | Qt.FramelessWindowHint + | Qt.WindowStaysOnTopHint) + + screen_number = QDesktopWidget().screenNumber(main) + if screen_number < 0: + screen_number = 0 + + r = QApplication.desktop().screenGeometry(screen_number) + main.setGeometry( + r.left() - 1, r.top() - 1, r.width() + 2, r.height() + 2) + main.showNormal() + else: + main.showFullScreen() + self._update_fullscreen_action() + + @property + def plugins_menu(self): + """Expose plugins toggle actions menu.""" + return self.get_container()._plugins_menu + + def create_plugins_menu(self): + """ + Populate panes menu with the toggle view action of each base plugin. + """ + order = ['editor', 'ipython_console', 'variable_explorer', + 'help', 'plots', None, 'explorer', 'outline_explorer', + 'project_explorer', 'find_in_files', None, 'historylog', + 'profiler', 'breakpoints', 'pylint', None, + 'onlinehelp', 'internal_console', None] + + for plugin in self.get_dockable_plugins(): + try: + # New API + action = plugin.toggle_view_action + except AttributeError: + # Old API + action = plugin._toggle_view_action + action.action_id = f'switch to {plugin.CONF_SECTION}' + + if action: + action.setChecked(plugin.dockwidget.isVisible()) + + try: + name = plugin.CONF_SECTION + pos = order.index(name) + except ValueError: + pos = None + + if pos is not None: + order[pos] = action + else: + order.append(action) + + actions = order[:] + for action in actions: + if type(action) is not str: + self.get_container()._plugins_menu.add_action(action) + + @property + def lock_interface_action(self): + return self.get_container()._lock_interface_action + + def _update_lock_interface_action(self): + """ + Helper method to update the locking of panes/dockwidgets and toolbars. + + Returns + ------- + None. + """ + if self._interface_locked: + icon = self.create_icon('drag_dock_widget') + text = _('Unlock panes and toolbars') + else: + icon = self.create_icon('lock') + text = _('Lock panes and toolbars') + self.lock_interface_action.setIcon(icon) + self.lock_interface_action.setText(text) + + def toggle_lock(self, value=None): + """Lock/Unlock dockwidgets and toolbars.""" + self._interface_locked = ( + not self._interface_locked if value is None else value) + self.set_conf('panes_locked', self._interface_locked, 'main') + self._update_lock_interface_action() + # Apply lock to panes + for plugin in self.get_dockable_plugins(): + if self._interface_locked: + if plugin.dockwidget.isFloating(): + plugin.dockwidget.setFloating(False) + + plugin.dockwidget.remove_title_bar() + else: + plugin.dockwidget.set_title_bar() + + # Apply lock to toolbars + toolbar = self.get_plugin(Plugins.Toolbar) + if toolbar: + toolbar.toggle_lock(value=self._interface_locked) diff --git a/spyder/plugins/layout/widgets/dialog.py b/spyder/plugins/layout/widgets/dialog.py index fc626c8a225..59b66eb835a 100644 --- a/spyder/plugins/layout/widgets/dialog.py +++ b/spyder/plugins/layout/widgets/dialog.py @@ -1,395 +1,395 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Layout dialogs""" - -# Standard library imports -import sys - -# Third party imports -from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtWidgets import (QAbstractItemView, QComboBox, QDialog, - QDialogButtonBox, QGroupBox, QHBoxLayout, - QPushButton, QTableView, QVBoxLayout) - -# Local imports -from spyder.config.base import _ -from spyder.py3compat import to_text_string - - -class LayoutModel(QAbstractTableModel): - """ """ - def __init__(self, parent, names, ui_names, order, active, read_only): - super(LayoutModel, self).__init__(parent) - - # variables - self._parent = parent - self.names = names - self.ui_names = ui_names - self.order = order - self.active = active - self.read_only = read_only - self._rows = [] - self.set_data(names, ui_names, order, active, read_only) - - def set_data(self, names, ui_names, order, active, read_only): - """ """ - self._rows = [] - self.names = names - self.ui_names = ui_names - self.order = order - self.active = active - self.read_only = read_only - for name in order: - index = names.index(name) - if name in active: - row = [ui_names[index], name, True] - else: - row = [ui_names[index], name, False] - self._rows.append(row) - - def flags(self, index): - """Override Qt method""" - row = index.row() - ui_name, name, state = self.row(row) - - if name in self.read_only: - return Qt.NoItemFlags - if not index.isValid(): - return Qt.ItemIsEnabled - column = index.column() - if column in [0]: - return Qt.ItemFlags(int(Qt.ItemIsEnabled | Qt.ItemIsSelectable | - Qt.ItemIsUserCheckable | - Qt.ItemIsEditable)) - else: - return Qt.ItemFlags(Qt.ItemIsEnabled) - - def data(self, index, role=Qt.DisplayRole): - """Override Qt method""" - if not index.isValid() or not 0 <= index.row() < len(self._rows): - return to_qvariant() - row = index.row() - column = index.column() - - ui_name, name, state = self.row(row) - - if role == Qt.DisplayRole or role == Qt.EditRole: - if column == 0: - return to_qvariant(ui_name) - elif role == Qt.UserRole: - if column == 0: - return to_qvariant(name) - elif role == Qt.CheckStateRole: - if column == 0: - if state: - return Qt.Checked - else: - return Qt.Unchecked - if column == 1: - return to_qvariant(state) - return to_qvariant() - - def setData(self, index, value, role): - """Override Qt method""" - row = index.row() - ui_name, name, state = self.row(row) - - if role == Qt.CheckStateRole: - self.set_row(row, [ui_name, name, not state]) - self._parent.setCurrentIndex(index) - self._parent.setFocus() - self.dataChanged.emit(index, index) - return True - elif role == Qt.EditRole: - self.set_row( - row, [from_qvariant(value, to_text_string), name, state]) - self.dataChanged.emit(index, index) - return True - return True - - def rowCount(self, index=QModelIndex()): - """Override Qt method""" - return len(self._rows) - - def columnCount(self, index=QModelIndex()): - """Override Qt method""" - return 2 - - def row(self, rownum): - """ """ - if self._rows == [] or rownum >= len(self._rows): - return [None, None, None] - else: - return self._rows[rownum] - - def set_row(self, rownum, value): - """ """ - self._rows[rownum] = value - - -class LayoutSaveDialog(QDialog): - """ """ - def __init__(self, parent, order): - super(LayoutSaveDialog, self).__init__(parent) - - # variables - self._parent = parent - - # widgets - self.combo_box = QComboBox(self) - self.combo_box.addItems(order) - self.combo_box.setEditable(True) - self.combo_box.clearEditText() - self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | - QDialogButtonBox.Cancel, - Qt.Horizontal, self) - self.button_ok = self.button_box.button(QDialogButtonBox.Ok) - self.button_cancel = self.button_box.button(QDialogButtonBox.Cancel) - - # widget setup - self.button_ok.setEnabled(False) - self.dialog_size = QSize(300, 100) - self.setWindowTitle('Save layout as') - self.setModal(True) - self.setMinimumSize(self.dialog_size) - self.setFixedSize(self.dialog_size) - - # layouts - self.layout = QVBoxLayout() - self.layout.addWidget(self.combo_box) - self.layout.addWidget(self.button_box) - self.setLayout(self.layout) - - # signals and slots - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.close) - self.combo_box.editTextChanged.connect(self.check_text) - - def check_text(self, text): - """Disable empty layout name possibility""" - if to_text_string(text) == u'': - self.button_ok.setEnabled(False) - else: - self.button_ok.setEnabled(True) - - -class LayoutSettingsDialog(QDialog): - """Layout settings dialog""" - def __init__(self, parent, names, ui_names, order, active, read_only): - super(LayoutSettingsDialog, self).__init__(parent) - # variables - self._parent = parent - self._selection_model = None - self.names = names - self.ui_names = ui_names - self.order = order - self.active = active - self.read_only = read_only - - # widgets - self.button_move_up = QPushButton(_('Move Up')) - self.button_move_down = QPushButton(_('Move Down')) - self.button_delete = QPushButton(_('Delete Layout')) - self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | - QDialogButtonBox.Cancel, - Qt.Horizontal, self) - self.group_box = QGroupBox(_("Layout Display and Order")) - self.table = QTableView(self) - self.ok_button = self.button_box.button(QDialogButtonBox.Ok) - self.cancel_button = self.button_box.button(QDialogButtonBox.Cancel) - self.cancel_button.setDefault(True) - self.cancel_button.setAutoDefault(True) - - # widget setup - self.dialog_size = QSize(300, 200) - self.setMinimumSize(self.dialog_size) - self.setFixedSize(self.dialog_size) - self.setWindowTitle('Layout Settings') - - self.table.setModel( - LayoutModel(self.table, names, ui_names, order, active, read_only)) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SingleSelection) - self.table.verticalHeader().hide() - self.table.horizontalHeader().hide() - self.table.setAlternatingRowColors(True) - self.table.setShowGrid(False) - self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table.horizontalHeader().setStretchLastSection(True) - self.table.setColumnHidden(1, True) - - # need to keep a reference for pyside not to segfault! - self._selection_model = self.table.selectionModel() - - # layout - buttons_layout = QVBoxLayout() - buttons_layout.addWidget(self.button_move_up) - buttons_layout.addWidget(self.button_move_down) - buttons_layout.addStretch() - buttons_layout.addWidget(self.button_delete) - - group_layout = QHBoxLayout() - group_layout.addWidget(self.table) - group_layout.addLayout(buttons_layout) - self.group_box.setLayout(group_layout) - - layout = QVBoxLayout() - layout.addWidget(self.group_box) - layout.addWidget(self.button_box) - - self.setLayout(layout) - - # signals and slots - self.button_box.accepted.connect(self.accept) - self.button_box.rejected.connect(self.close) - self.button_delete.clicked.connect(self.delete_layout) - self.button_move_up.clicked.connect(lambda: self.move_layout(True)) - self.button_move_down.clicked.connect(lambda: self.move_layout(False)) - self.table.model().dataChanged.connect( - lambda: self.selection_changed(None, None)) - self._selection_model.selectionChanged.connect( - lambda: self.selection_changed(None, None)) - - # focus table - if len(names) > len(read_only): - row = len(read_only) - index = self.table.model().index(row, 0) - self.table.setCurrentIndex(index) - self.table.setFocus() - else: - # initial button state in case only programmatic layouts - # are available - self.button_move_up.setDisabled(True) - self.button_move_down.setDisabled(True) - self.button_delete.setDisabled(True) - - def delete_layout(self): - """Delete layout from the config.""" - names, ui_names, order, active, read_only = ( - self.names, self.ui_names, self.order, self.active, self.read_only) - row = self.table.selectionModel().currentIndex().row() - ui_name, name, state = self.table.model().row(row) - - if name not in read_only: - name = from_qvariant( - self.table.selectionModel().currentIndex().data(), - to_text_string) - if ui_name in ui_names: - index = ui_names.index(ui_name) - else: - # In case nothing has focus in the table - return - if index != -1: - order.remove(ui_name) - names.remove(ui_name) - ui_names.remove(ui_name) - if name in active: - active.remove(ui_name) - self.names, self.ui_names, self.order, self.active = ( - names, ui_names, order, active) - self.table.model().set_data( - names, ui_names, order, active, read_only) - index = self.table.model().index(0, 0) - self.table.setCurrentIndex(index) - self.table.setFocus() - self.selection_changed(None, None) - if len(order) == 0 or len(names) == len(read_only): - self.button_move_up.setDisabled(True) - self.button_move_down.setDisabled(True) - self.button_delete.setDisabled(True) - - def move_layout(self, up=True): - """ """ - names, ui_names, order, active, read_only = ( - self.names, self.ui_names, self.order, self.active, self.read_only) - row = self.table.selectionModel().currentIndex().row() - row_new = row - _ui_name, name, _state = self.table.model().row(row) - - if name not in read_only: - if up: - row_new -= 1 - else: - row_new += 1 - - if order[row_new] not in read_only: - order[row], order[row_new] = order[row_new], order[row] - - self.order = order - self.table.model().set_data( - names, ui_names, order, active, read_only) - index = self.table.model().index(row_new, 0) - self.table.setCurrentIndex(index) - self.table.setFocus() - self.selection_changed(None, None) - - def selection_changed(self, selection, deselection): - """ """ - model = self.table.model() - index = self.table.currentIndex() - row = index.row() - order, names, ui_names, active, read_only = ( - self.order, self.names, self.ui_names, self.active, self.read_only) - - state = model.row(row)[2] - ui_name = model.row(row)[0] - - # Check if name changed - if ui_name not in ui_names: # Did changed - # row == -1, means no items left to delete - if row != -1 and len(names) > len(read_only): - old_name = order[row] - order[row] = ui_name - names[names.index(old_name)] = ui_name - ui_names = names - if old_name in active: - active[active.index(old_name)] = ui_name - - # Check if checkbox clicked - if state: - if ui_name not in active: - active.append(ui_name) - else: - if ui_name in active: - active.remove(ui_name) - - self.active = active - self.order = order - self.names = names - self.ui_names = ui_names - self.button_move_up.setDisabled(False) - self.button_move_down.setDisabled(False) - - if row == 0: - self.button_move_up.setDisabled(True) - if row == len(names) - 1: - self.button_move_down.setDisabled(True) - if len(names) == 0: - self.button_move_up.setDisabled(True) - self.button_move_down.setDisabled(True) - - -def test(): - """Run layout test widget test""" - from spyder.utils.qthelpers import qapplication - - app = qapplication() - names = ['test', 'tester', '20', '30', '40'] - ui_names = ['L1', 'L2', '20', '30', '40'] - order = ['test', 'tester', '20', '30', '40'] - read_only = ['test', 'tester'] - active = ['test', 'tester'] - widget_1 = LayoutSettingsDialog( - None, names, ui_names, order, active, read_only) - widget_2 = LayoutSaveDialog(None, order) - widget_1.show() - widget_2.show() - sys.exit(app.exec_()) - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Layout dialogs""" + +# Standard library imports +import sys + +# Third party imports +from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtWidgets import (QAbstractItemView, QComboBox, QDialog, + QDialogButtonBox, QGroupBox, QHBoxLayout, + QPushButton, QTableView, QVBoxLayout) + +# Local imports +from spyder.config.base import _ +from spyder.py3compat import to_text_string + + +class LayoutModel(QAbstractTableModel): + """ """ + def __init__(self, parent, names, ui_names, order, active, read_only): + super(LayoutModel, self).__init__(parent) + + # variables + self._parent = parent + self.names = names + self.ui_names = ui_names + self.order = order + self.active = active + self.read_only = read_only + self._rows = [] + self.set_data(names, ui_names, order, active, read_only) + + def set_data(self, names, ui_names, order, active, read_only): + """ """ + self._rows = [] + self.names = names + self.ui_names = ui_names + self.order = order + self.active = active + self.read_only = read_only + for name in order: + index = names.index(name) + if name in active: + row = [ui_names[index], name, True] + else: + row = [ui_names[index], name, False] + self._rows.append(row) + + def flags(self, index): + """Override Qt method""" + row = index.row() + ui_name, name, state = self.row(row) + + if name in self.read_only: + return Qt.NoItemFlags + if not index.isValid(): + return Qt.ItemIsEnabled + column = index.column() + if column in [0]: + return Qt.ItemFlags(int(Qt.ItemIsEnabled | Qt.ItemIsSelectable | + Qt.ItemIsUserCheckable | + Qt.ItemIsEditable)) + else: + return Qt.ItemFlags(Qt.ItemIsEnabled) + + def data(self, index, role=Qt.DisplayRole): + """Override Qt method""" + if not index.isValid() or not 0 <= index.row() < len(self._rows): + return to_qvariant() + row = index.row() + column = index.column() + + ui_name, name, state = self.row(row) + + if role == Qt.DisplayRole or role == Qt.EditRole: + if column == 0: + return to_qvariant(ui_name) + elif role == Qt.UserRole: + if column == 0: + return to_qvariant(name) + elif role == Qt.CheckStateRole: + if column == 0: + if state: + return Qt.Checked + else: + return Qt.Unchecked + if column == 1: + return to_qvariant(state) + return to_qvariant() + + def setData(self, index, value, role): + """Override Qt method""" + row = index.row() + ui_name, name, state = self.row(row) + + if role == Qt.CheckStateRole: + self.set_row(row, [ui_name, name, not state]) + self._parent.setCurrentIndex(index) + self._parent.setFocus() + self.dataChanged.emit(index, index) + return True + elif role == Qt.EditRole: + self.set_row( + row, [from_qvariant(value, to_text_string), name, state]) + self.dataChanged.emit(index, index) + return True + return True + + def rowCount(self, index=QModelIndex()): + """Override Qt method""" + return len(self._rows) + + def columnCount(self, index=QModelIndex()): + """Override Qt method""" + return 2 + + def row(self, rownum): + """ """ + if self._rows == [] or rownum >= len(self._rows): + return [None, None, None] + else: + return self._rows[rownum] + + def set_row(self, rownum, value): + """ """ + self._rows[rownum] = value + + +class LayoutSaveDialog(QDialog): + """ """ + def __init__(self, parent, order): + super(LayoutSaveDialog, self).__init__(parent) + + # variables + self._parent = parent + + # widgets + self.combo_box = QComboBox(self) + self.combo_box.addItems(order) + self.combo_box.setEditable(True) + self.combo_box.clearEditText() + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | + QDialogButtonBox.Cancel, + Qt.Horizontal, self) + self.button_ok = self.button_box.button(QDialogButtonBox.Ok) + self.button_cancel = self.button_box.button(QDialogButtonBox.Cancel) + + # widget setup + self.button_ok.setEnabled(False) + self.dialog_size = QSize(300, 100) + self.setWindowTitle('Save layout as') + self.setModal(True) + self.setMinimumSize(self.dialog_size) + self.setFixedSize(self.dialog_size) + + # layouts + self.layout = QVBoxLayout() + self.layout.addWidget(self.combo_box) + self.layout.addWidget(self.button_box) + self.setLayout(self.layout) + + # signals and slots + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.close) + self.combo_box.editTextChanged.connect(self.check_text) + + def check_text(self, text): + """Disable empty layout name possibility""" + if to_text_string(text) == u'': + self.button_ok.setEnabled(False) + else: + self.button_ok.setEnabled(True) + + +class LayoutSettingsDialog(QDialog): + """Layout settings dialog""" + def __init__(self, parent, names, ui_names, order, active, read_only): + super(LayoutSettingsDialog, self).__init__(parent) + # variables + self._parent = parent + self._selection_model = None + self.names = names + self.ui_names = ui_names + self.order = order + self.active = active + self.read_only = read_only + + # widgets + self.button_move_up = QPushButton(_('Move Up')) + self.button_move_down = QPushButton(_('Move Down')) + self.button_delete = QPushButton(_('Delete Layout')) + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | + QDialogButtonBox.Cancel, + Qt.Horizontal, self) + self.group_box = QGroupBox(_("Layout Display and Order")) + self.table = QTableView(self) + self.ok_button = self.button_box.button(QDialogButtonBox.Ok) + self.cancel_button = self.button_box.button(QDialogButtonBox.Cancel) + self.cancel_button.setDefault(True) + self.cancel_button.setAutoDefault(True) + + # widget setup + self.dialog_size = QSize(300, 200) + self.setMinimumSize(self.dialog_size) + self.setFixedSize(self.dialog_size) + self.setWindowTitle('Layout Settings') + + self.table.setModel( + LayoutModel(self.table, names, ui_names, order, active, read_only)) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.verticalHeader().hide() + self.table.horizontalHeader().hide() + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.setColumnHidden(1, True) + + # need to keep a reference for pyside not to segfault! + self._selection_model = self.table.selectionModel() + + # layout + buttons_layout = QVBoxLayout() + buttons_layout.addWidget(self.button_move_up) + buttons_layout.addWidget(self.button_move_down) + buttons_layout.addStretch() + buttons_layout.addWidget(self.button_delete) + + group_layout = QHBoxLayout() + group_layout.addWidget(self.table) + group_layout.addLayout(buttons_layout) + self.group_box.setLayout(group_layout) + + layout = QVBoxLayout() + layout.addWidget(self.group_box) + layout.addWidget(self.button_box) + + self.setLayout(layout) + + # signals and slots + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.close) + self.button_delete.clicked.connect(self.delete_layout) + self.button_move_up.clicked.connect(lambda: self.move_layout(True)) + self.button_move_down.clicked.connect(lambda: self.move_layout(False)) + self.table.model().dataChanged.connect( + lambda: self.selection_changed(None, None)) + self._selection_model.selectionChanged.connect( + lambda: self.selection_changed(None, None)) + + # focus table + if len(names) > len(read_only): + row = len(read_only) + index = self.table.model().index(row, 0) + self.table.setCurrentIndex(index) + self.table.setFocus() + else: + # initial button state in case only programmatic layouts + # are available + self.button_move_up.setDisabled(True) + self.button_move_down.setDisabled(True) + self.button_delete.setDisabled(True) + + def delete_layout(self): + """Delete layout from the config.""" + names, ui_names, order, active, read_only = ( + self.names, self.ui_names, self.order, self.active, self.read_only) + row = self.table.selectionModel().currentIndex().row() + ui_name, name, state = self.table.model().row(row) + + if name not in read_only: + name = from_qvariant( + self.table.selectionModel().currentIndex().data(), + to_text_string) + if ui_name in ui_names: + index = ui_names.index(ui_name) + else: + # In case nothing has focus in the table + return + if index != -1: + order.remove(ui_name) + names.remove(ui_name) + ui_names.remove(ui_name) + if name in active: + active.remove(ui_name) + self.names, self.ui_names, self.order, self.active = ( + names, ui_names, order, active) + self.table.model().set_data( + names, ui_names, order, active, read_only) + index = self.table.model().index(0, 0) + self.table.setCurrentIndex(index) + self.table.setFocus() + self.selection_changed(None, None) + if len(order) == 0 or len(names) == len(read_only): + self.button_move_up.setDisabled(True) + self.button_move_down.setDisabled(True) + self.button_delete.setDisabled(True) + + def move_layout(self, up=True): + """ """ + names, ui_names, order, active, read_only = ( + self.names, self.ui_names, self.order, self.active, self.read_only) + row = self.table.selectionModel().currentIndex().row() + row_new = row + _ui_name, name, _state = self.table.model().row(row) + + if name not in read_only: + if up: + row_new -= 1 + else: + row_new += 1 + + if order[row_new] not in read_only: + order[row], order[row_new] = order[row_new], order[row] + + self.order = order + self.table.model().set_data( + names, ui_names, order, active, read_only) + index = self.table.model().index(row_new, 0) + self.table.setCurrentIndex(index) + self.table.setFocus() + self.selection_changed(None, None) + + def selection_changed(self, selection, deselection): + """ """ + model = self.table.model() + index = self.table.currentIndex() + row = index.row() + order, names, ui_names, active, read_only = ( + self.order, self.names, self.ui_names, self.active, self.read_only) + + state = model.row(row)[2] + ui_name = model.row(row)[0] + + # Check if name changed + if ui_name not in ui_names: # Did changed + # row == -1, means no items left to delete + if row != -1 and len(names) > len(read_only): + old_name = order[row] + order[row] = ui_name + names[names.index(old_name)] = ui_name + ui_names = names + if old_name in active: + active[active.index(old_name)] = ui_name + + # Check if checkbox clicked + if state: + if ui_name not in active: + active.append(ui_name) + else: + if ui_name in active: + active.remove(ui_name) + + self.active = active + self.order = order + self.names = names + self.ui_names = ui_names + self.button_move_up.setDisabled(False) + self.button_move_down.setDisabled(False) + + if row == 0: + self.button_move_up.setDisabled(True) + if row == len(names) - 1: + self.button_move_down.setDisabled(True) + if len(names) == 0: + self.button_move_up.setDisabled(True) + self.button_move_down.setDisabled(True) + + +def test(): + """Run layout test widget test""" + from spyder.utils.qthelpers import qapplication + + app = qapplication() + names = ['test', 'tester', '20', '30', '40'] + ui_names = ['L1', 'L2', '20', '30', '40'] + order = ['test', 'tester', '20', '30', '40'] + read_only = ['test', 'tester'] + active = ['test', 'tester'] + widget_1 = LayoutSettingsDialog( + None, names, ui_names, order, active, read_only) + widget_2 = LayoutSaveDialog(None, order) + widget_1.show() + widget_2.show() + sys.exit(app.exec_()) + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/maininterpreter/confpage.py b/spyder/plugins/maininterpreter/confpage.py index 3f1f9f7893c..0f4c3ff795d 100644 --- a/spyder/plugins/maininterpreter/confpage.py +++ b/spyder/plugins/maininterpreter/confpage.py @@ -1,276 +1,276 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Main interpreter entry in Preferences.""" - -# Standard library imports -import os -import os.path as osp -import sys - -# Third party imports -from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QInputDialog, QLabel, - QLineEdit, QMessageBox, QPushButton, QVBoxLayout) - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.preferences import PluginConfigPage -from spyder.py3compat import PY2, to_text_string -from spyder.utils import programs -from spyder.utils.conda import get_list_conda_envs_cache -from spyder.utils.misc import get_python_executable -from spyder.utils.pyenv import get_list_pyenv_envs_cache - -# Localization -_ = get_translation('spyder') - - -class MainInterpreterConfigPage(PluginConfigPage): - - def __init__(self, plugin, parent): - super().__init__(plugin, parent) - self.apply_callback = self.perform_adjustments - - self.cus_exec_radio = None - self.pyexec_edit = None - self.cus_exec_combo = None - - conda_env = get_list_conda_envs_cache() - pyenv_env = get_list_pyenv_envs_cache() - envs = {**conda_env, **pyenv_env} - valid_custom_list = self.get_option('custom_interpreters_list') - for env in envs.keys(): - path, _ = envs[env] - if path not in valid_custom_list: - valid_custom_list.append(path) - self.set_option('custom_interpreters_list', valid_custom_list) - - # add custom_interpreter to executable selection - executable = self.get_option('executable') - - # check if the executable is valid - use Spyder's if not - if self.get_option('default') or not osp.isfile(executable): - executable = get_python_executable() - elif not self.get_option('custom_interpreter'): - self.set_option('custom_interpreter', ' ') - - plugin._add_to_custom_interpreters(executable) - self.validate_custom_interpreters_list() - - def initialize(self): - super().initialize() - - def setup_page(self): - newcb = self.create_checkbox - - # Python executable Group - pyexec_group = QGroupBox(_("Python interpreter")) - pyexec_bg = QButtonGroup(pyexec_group) - pyexec_label = QLabel(_("Select the Python interpreter for all Spyder " - "consoles")) - self.def_exec_radio = self.create_radiobutton( - _("Default (i.e. the same as Spyder's)"), - 'default', - button_group=pyexec_bg, - ) - self.cus_exec_radio = self.create_radiobutton( - _("Use the following Python interpreter:"), - 'custom', - button_group=pyexec_bg, - ) - - if os.name == 'nt': - filters = _("Executables")+" (*.exe)" - else: - filters = None - - pyexec_layout = QVBoxLayout() - pyexec_layout.addWidget(pyexec_label) - pyexec_layout.addWidget(self.def_exec_radio) - pyexec_layout.addWidget(self.cus_exec_radio) - self.validate_custom_interpreters_list() - self.cus_exec_combo = self.create_file_combobox( - _('Recent custom interpreters'), - self.get_option('custom_interpreters_list'), - 'custom_interpreter', - filters=filters, - default_line_edit=True, - adjust_to_contents=True, - validate_callback=programs.is_python_interpreter, - ) - self.def_exec_radio.toggled.connect(self.cus_exec_combo.setDisabled) - self.cus_exec_radio.toggled.connect(self.cus_exec_combo.setEnabled) - pyexec_layout.addWidget(self.cus_exec_combo) - pyexec_group.setLayout(pyexec_layout) - - self.pyexec_edit = self.cus_exec_combo.combobox.lineEdit() - - # UMR Group - umr_group = QGroupBox(_("User Module Reloader (UMR)")) - umr_label = QLabel(_("UMR forces Python to reload modules which were " - "imported when executing a file in a Python or " - "IPython console with the runfile " - "function.")) - umr_label.setWordWrap(True) - umr_enabled_box = newcb( - _("Enable UMR"), - 'umr/enabled', - msg_if_enabled=True, - msg_warning=_( - "This option will enable the User Module Reloader (UMR) " - "in Python/IPython consoles. UMR forces Python to " - "reload deeply modules during import when running a " - "Python file using the Spyder's builtin function " - "runfile." - "

1. UMR may require to restart the " - "console in which it will be called " - "(otherwise only newly imported modules will be " - "reloaded when executing files)." - "

2. If errors occur when re-running a " - "PyQt-based program, please check that the Qt objects " - "are properly destroyed (e.g. you may have to use the " - "attribute Qt.WA_DeleteOnClose on your main " - "window, using the setAttribute method)" - ), - ) - umr_verbose_box = newcb( - _("Show reloaded modules list"), - 'umr/verbose', - msg_info=_("Please note that these changes will " - "be applied only to new consoles"), - ) - umr_namelist_btn = QPushButton( - _("Set UMR excluded (not reloaded) modules")) - umr_namelist_btn.clicked.connect(self.set_umr_namelist) - - umr_layout = QVBoxLayout() - umr_layout.addWidget(umr_label) - umr_layout.addWidget(umr_enabled_box) - umr_layout.addWidget(umr_verbose_box) - umr_layout.addWidget(umr_namelist_btn) - umr_group.setLayout(umr_layout) - - vlayout = QVBoxLayout() - vlayout.addWidget(pyexec_group) - vlayout.addWidget(umr_group) - vlayout.addStretch(1) - self.setLayout(vlayout) - - def warn_python_compatibility(self, pyexec): - if not osp.isfile(pyexec): - return - - spyder_version = sys.version_info[0] - try: - args = ["-c", "import sys; print(sys.version_info[0])"] - proc = programs.run_program(pyexec, args, env={}) - console_version = int(proc.communicate()[0]) - except IOError: - console_version = spyder_version - except ValueError: - return False - - if spyder_version != console_version: - QMessageBox.warning( - self, - _('Warning'), - _("You selected a Python %d interpreter for the console " - "but Spyder is running on Python %d!.

" - "Although this is possible, we recommend you to install and " - "run Spyder directly with your selected interpreter, to avoid " - "seeing false warnings and errors due to the incompatible " - "syntax between these two Python versions." - ) % (console_version, spyder_version), - QMessageBox.Ok, - ) - - return True - - def set_umr_namelist(self): - """Set UMR excluded modules name list""" - arguments, valid = QInputDialog.getText( - self, - _('UMR'), - _("Set the list of excluded modules as this: " - "numpy, scipy"), - QLineEdit.Normal, - ", ".join(self.get_option('umr/namelist')), - ) - if valid: - arguments = to_text_string(arguments) - if arguments: - namelist = arguments.replace(' ', '').split(',') - fixed_namelist = [] - non_ascii_namelist = [] - for module_name in namelist: - if PY2: - if all(ord(c) < 128 for c in module_name): - if programs.is_module_installed(module_name): - fixed_namelist.append(module_name) - else: - QMessageBox.warning( - self, - _('Warning'), - _("You are working with Python 2, this means " - "that you can not import a module that " - "contains non-ascii characters."), - QMessageBox.Ok, - ) - non_ascii_namelist.append(module_name) - elif programs.is_module_installed(module_name): - fixed_namelist.append(module_name) - - invalid = ", ".join(set(namelist)-set(fixed_namelist)- - set(non_ascii_namelist)) - if invalid: - QMessageBox.warning( - self, - _('UMR'), - _("The following modules are not " - "installed on your machine:\n%s") % invalid, - QMessageBox.Ok, - ) - QMessageBox.information( - self, - _('UMR'), - _("Please note that these changes will " - "be applied only to new IPython consoles"), - QMessageBox.Ok, - ) - else: - fixed_namelist = [] - - self.set_option('umr/namelist', fixed_namelist) - - def validate_custom_interpreters_list(self): - """Check that the used custom interpreters are still valid.""" - custom_list = self.get_option('custom_interpreters_list') - valid_custom_list = [] - for value in custom_list: - if osp.isfile(value): - valid_custom_list.append(value) - - self.set_option('custom_interpreters_list', valid_custom_list) - - def perform_adjustments(self): - """Perform some adjustments to the page after applying preferences.""" - if not self.def_exec_radio.isChecked(): - # Get current executable - executable = self.pyexec_edit.text() - executable = osp.normpath(executable) - if executable.endswith('pythonw.exe'): - executable = executable.replace("pythonw.exe", "python.exe") - - # Update combobox items. - custom_list = self.cus_exec_combo.combobox.choices - if executable not in custom_list: - custom_list = custom_list + [executable] - self.cus_exec_combo.combobox.clear() - self.cus_exec_combo.combobox.addItems(custom_list) - self.pyexec_edit.setText(executable) - - # Show warning compatibility message. - self.warn_python_compatibility(executable) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Main interpreter entry in Preferences.""" + +# Standard library imports +import os +import os.path as osp +import sys + +# Third party imports +from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QInputDialog, QLabel, + QLineEdit, QMessageBox, QPushButton, QVBoxLayout) + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.preferences import PluginConfigPage +from spyder.py3compat import PY2, to_text_string +from spyder.utils import programs +from spyder.utils.conda import get_list_conda_envs_cache +from spyder.utils.misc import get_python_executable +from spyder.utils.pyenv import get_list_pyenv_envs_cache + +# Localization +_ = get_translation('spyder') + + +class MainInterpreterConfigPage(PluginConfigPage): + + def __init__(self, plugin, parent): + super().__init__(plugin, parent) + self.apply_callback = self.perform_adjustments + + self.cus_exec_radio = None + self.pyexec_edit = None + self.cus_exec_combo = None + + conda_env = get_list_conda_envs_cache() + pyenv_env = get_list_pyenv_envs_cache() + envs = {**conda_env, **pyenv_env} + valid_custom_list = self.get_option('custom_interpreters_list') + for env in envs.keys(): + path, _ = envs[env] + if path not in valid_custom_list: + valid_custom_list.append(path) + self.set_option('custom_interpreters_list', valid_custom_list) + + # add custom_interpreter to executable selection + executable = self.get_option('executable') + + # check if the executable is valid - use Spyder's if not + if self.get_option('default') or not osp.isfile(executable): + executable = get_python_executable() + elif not self.get_option('custom_interpreter'): + self.set_option('custom_interpreter', ' ') + + plugin._add_to_custom_interpreters(executable) + self.validate_custom_interpreters_list() + + def initialize(self): + super().initialize() + + def setup_page(self): + newcb = self.create_checkbox + + # Python executable Group + pyexec_group = QGroupBox(_("Python interpreter")) + pyexec_bg = QButtonGroup(pyexec_group) + pyexec_label = QLabel(_("Select the Python interpreter for all Spyder " + "consoles")) + self.def_exec_radio = self.create_radiobutton( + _("Default (i.e. the same as Spyder's)"), + 'default', + button_group=pyexec_bg, + ) + self.cus_exec_radio = self.create_radiobutton( + _("Use the following Python interpreter:"), + 'custom', + button_group=pyexec_bg, + ) + + if os.name == 'nt': + filters = _("Executables")+" (*.exe)" + else: + filters = None + + pyexec_layout = QVBoxLayout() + pyexec_layout.addWidget(pyexec_label) + pyexec_layout.addWidget(self.def_exec_radio) + pyexec_layout.addWidget(self.cus_exec_radio) + self.validate_custom_interpreters_list() + self.cus_exec_combo = self.create_file_combobox( + _('Recent custom interpreters'), + self.get_option('custom_interpreters_list'), + 'custom_interpreter', + filters=filters, + default_line_edit=True, + adjust_to_contents=True, + validate_callback=programs.is_python_interpreter, + ) + self.def_exec_radio.toggled.connect(self.cus_exec_combo.setDisabled) + self.cus_exec_radio.toggled.connect(self.cus_exec_combo.setEnabled) + pyexec_layout.addWidget(self.cus_exec_combo) + pyexec_group.setLayout(pyexec_layout) + + self.pyexec_edit = self.cus_exec_combo.combobox.lineEdit() + + # UMR Group + umr_group = QGroupBox(_("User Module Reloader (UMR)")) + umr_label = QLabel(_("UMR forces Python to reload modules which were " + "imported when executing a file in a Python or " + "IPython console with the runfile " + "function.")) + umr_label.setWordWrap(True) + umr_enabled_box = newcb( + _("Enable UMR"), + 'umr/enabled', + msg_if_enabled=True, + msg_warning=_( + "This option will enable the User Module Reloader (UMR) " + "in Python/IPython consoles. UMR forces Python to " + "reload deeply modules during import when running a " + "Python file using the Spyder's builtin function " + "runfile." + "

1. UMR may require to restart the " + "console in which it will be called " + "(otherwise only newly imported modules will be " + "reloaded when executing files)." + "

2. If errors occur when re-running a " + "PyQt-based program, please check that the Qt objects " + "are properly destroyed (e.g. you may have to use the " + "attribute Qt.WA_DeleteOnClose on your main " + "window, using the setAttribute method)" + ), + ) + umr_verbose_box = newcb( + _("Show reloaded modules list"), + 'umr/verbose', + msg_info=_("Please note that these changes will " + "be applied only to new consoles"), + ) + umr_namelist_btn = QPushButton( + _("Set UMR excluded (not reloaded) modules")) + umr_namelist_btn.clicked.connect(self.set_umr_namelist) + + umr_layout = QVBoxLayout() + umr_layout.addWidget(umr_label) + umr_layout.addWidget(umr_enabled_box) + umr_layout.addWidget(umr_verbose_box) + umr_layout.addWidget(umr_namelist_btn) + umr_group.setLayout(umr_layout) + + vlayout = QVBoxLayout() + vlayout.addWidget(pyexec_group) + vlayout.addWidget(umr_group) + vlayout.addStretch(1) + self.setLayout(vlayout) + + def warn_python_compatibility(self, pyexec): + if not osp.isfile(pyexec): + return + + spyder_version = sys.version_info[0] + try: + args = ["-c", "import sys; print(sys.version_info[0])"] + proc = programs.run_program(pyexec, args, env={}) + console_version = int(proc.communicate()[0]) + except IOError: + console_version = spyder_version + except ValueError: + return False + + if spyder_version != console_version: + QMessageBox.warning( + self, + _('Warning'), + _("You selected a Python %d interpreter for the console " + "but Spyder is running on Python %d!.

" + "Although this is possible, we recommend you to install and " + "run Spyder directly with your selected interpreter, to avoid " + "seeing false warnings and errors due to the incompatible " + "syntax between these two Python versions." + ) % (console_version, spyder_version), + QMessageBox.Ok, + ) + + return True + + def set_umr_namelist(self): + """Set UMR excluded modules name list""" + arguments, valid = QInputDialog.getText( + self, + _('UMR'), + _("Set the list of excluded modules as this: " + "numpy, scipy"), + QLineEdit.Normal, + ", ".join(self.get_option('umr/namelist')), + ) + if valid: + arguments = to_text_string(arguments) + if arguments: + namelist = arguments.replace(' ', '').split(',') + fixed_namelist = [] + non_ascii_namelist = [] + for module_name in namelist: + if PY2: + if all(ord(c) < 128 for c in module_name): + if programs.is_module_installed(module_name): + fixed_namelist.append(module_name) + else: + QMessageBox.warning( + self, + _('Warning'), + _("You are working with Python 2, this means " + "that you can not import a module that " + "contains non-ascii characters."), + QMessageBox.Ok, + ) + non_ascii_namelist.append(module_name) + elif programs.is_module_installed(module_name): + fixed_namelist.append(module_name) + + invalid = ", ".join(set(namelist)-set(fixed_namelist)- + set(non_ascii_namelist)) + if invalid: + QMessageBox.warning( + self, + _('UMR'), + _("The following modules are not " + "installed on your machine:\n%s") % invalid, + QMessageBox.Ok, + ) + QMessageBox.information( + self, + _('UMR'), + _("Please note that these changes will " + "be applied only to new IPython consoles"), + QMessageBox.Ok, + ) + else: + fixed_namelist = [] + + self.set_option('umr/namelist', fixed_namelist) + + def validate_custom_interpreters_list(self): + """Check that the used custom interpreters are still valid.""" + custom_list = self.get_option('custom_interpreters_list') + valid_custom_list = [] + for value in custom_list: + if osp.isfile(value): + valid_custom_list.append(value) + + self.set_option('custom_interpreters_list', valid_custom_list) + + def perform_adjustments(self): + """Perform some adjustments to the page after applying preferences.""" + if not self.def_exec_radio.isChecked(): + # Get current executable + executable = self.pyexec_edit.text() + executable = osp.normpath(executable) + if executable.endswith('pythonw.exe'): + executable = executable.replace("pythonw.exe", "python.exe") + + # Update combobox items. + custom_list = self.cus_exec_combo.combobox.choices + if executable not in custom_list: + custom_list = custom_list + [executable] + self.cus_exec_combo.combobox.clear() + self.cus_exec_combo.combobox.addItems(custom_list) + self.pyexec_edit.setText(executable) + + # Show warning compatibility message. + self.warn_python_compatibility(executable) diff --git a/spyder/plugins/maininterpreter/plugin.py b/spyder/plugins/maininterpreter/plugin.py index 06e89f6ca31..713cb9c9b92 100644 --- a/spyder/plugins/maininterpreter/plugin.py +++ b/spyder/plugins/maininterpreter/plugin.py @@ -1,122 +1,122 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Main interpreter Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third-party imports -from qtpy.QtCore import Slot - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.maininterpreter.confpage import MainInterpreterConfigPage -from spyder.plugins.maininterpreter.container import MainInterpreterContainer -from spyder.utils.misc import get_python_executable - -# Localization -_ = get_translation('spyder') - - -class MainInterpreter(SpyderPluginV2): - """ - Main interpreter Plugin. - """ - - NAME = "main_interpreter" - REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.StatusBar] - CONTAINER_CLASS = MainInterpreterContainer - CONF_WIDGET_CLASS = MainInterpreterConfigPage - CONF_SECTION = NAME - CONF_FILE = False - CAN_BE_DISABLED = False - - # ---- SpyderPluginV2 API - @staticmethod - def get_name(): - return _("Python interpreter") - - def get_description(self): - return _("Main Python interpreter to open consoles.") - - def get_icon(self): - return self.create_icon('python') - - def on_initialize(self): - container = self.get_container() - - # Connect signal to open preferences - container.sig_open_preferences_requested.connect( - self._open_interpreter_preferences - ) - - # Add custom interpreter to list of saved ones - container.sig_add_to_custom_interpreters_requested.connect( - self._add_to_custom_interpreters - ) - - # Validate that the custom interpreter from the previous session - # still exists - if self.get_conf('custom'): - interpreter = self.get_conf('custom_interpreter') - if not osp.isfile(interpreter): - self.set_conf('custom', False) - self.set_conf('default', True) - self.set_conf('executable', get_python_executable()) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - # Register conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.StatusBar) - def on_statusbar_available(self): - # Add status widget - statusbar = self.get_plugin(Plugins.StatusBar) - statusbar.add_status_widget(self.interpreter_status) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - # Deregister conf page - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.StatusBar) - def on_statusbar_teardown(self): - # Add status widget - statusbar = self.get_plugin(Plugins.StatusBar) - statusbar.remove_status_widget(self.interpreter_status.ID) - - @property - def interpreter_status(self): - return self.get_container().interpreter_status - - # ---- Private API - def _open_interpreter_preferences(self): - """Open the Preferences dialog in the main interpreter section.""" - self._main.show_preferences() - preferences = self._main.preferences - container = preferences.get_container() - dlg = container.dialog - index = dlg.get_index_by_name("main_interpreter") - dlg.set_current_index(index) - - @Slot(str) - def _add_to_custom_interpreters(self, interpreter): - """Add a new interpreter to the list of saved ones.""" - custom_list = self.get_conf('custom_interpreters_list') - if interpreter not in custom_list: - custom_list.append(interpreter) - self.set_conf('custom_interpreters_list', custom_list) +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Main interpreter Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third-party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.maininterpreter.confpage import MainInterpreterConfigPage +from spyder.plugins.maininterpreter.container import MainInterpreterContainer +from spyder.utils.misc import get_python_executable + +# Localization +_ = get_translation('spyder') + + +class MainInterpreter(SpyderPluginV2): + """ + Main interpreter Plugin. + """ + + NAME = "main_interpreter" + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.StatusBar] + CONTAINER_CLASS = MainInterpreterContainer + CONF_WIDGET_CLASS = MainInterpreterConfigPage + CONF_SECTION = NAME + CONF_FILE = False + CAN_BE_DISABLED = False + + # ---- SpyderPluginV2 API + @staticmethod + def get_name(): + return _("Python interpreter") + + def get_description(self): + return _("Main Python interpreter to open consoles.") + + def get_icon(self): + return self.create_icon('python') + + def on_initialize(self): + container = self.get_container() + + # Connect signal to open preferences + container.sig_open_preferences_requested.connect( + self._open_interpreter_preferences + ) + + # Add custom interpreter to list of saved ones + container.sig_add_to_custom_interpreters_requested.connect( + self._add_to_custom_interpreters + ) + + # Validate that the custom interpreter from the previous session + # still exists + if self.get_conf('custom'): + interpreter = self.get_conf('custom_interpreter') + if not osp.isfile(interpreter): + self.set_conf('custom', False) + self.set_conf('default', True) + self.set_conf('executable', get_python_executable()) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + # Register conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.StatusBar) + def on_statusbar_available(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.add_status_widget(self.interpreter_status) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + # Deregister conf page + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.StatusBar) + def on_statusbar_teardown(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + statusbar.remove_status_widget(self.interpreter_status.ID) + + @property + def interpreter_status(self): + return self.get_container().interpreter_status + + # ---- Private API + def _open_interpreter_preferences(self): + """Open the Preferences dialog in the main interpreter section.""" + self._main.show_preferences() + preferences = self._main.preferences + container = preferences.get_container() + dlg = container.dialog + index = dlg.get_index_by_name("main_interpreter") + dlg.set_current_index(index) + + @Slot(str) + def _add_to_custom_interpreters(self, interpreter): + """Add a new interpreter to the list of saved ones.""" + custom_list = self.get_conf('custom_interpreters_list') + if interpreter not in custom_list: + custom_list.append(interpreter) + self.set_conf('custom_interpreters_list', custom_list) diff --git a/spyder/plugins/onlinehelp/api.py b/spyder/plugins/onlinehelp/api.py index 444ce4138aa..111236d17b1 100644 --- a/spyder/plugins/onlinehelp/api.py +++ b/spyder/plugins/onlinehelp/api.py @@ -1,9 +1,9 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -# Local imports -from spyder.plugins.onlinehelp.widgets import PydocBrowserActions -from spyder.widgets.browser import WebViewActions +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Local imports +from spyder.plugins.onlinehelp.widgets import PydocBrowserActions +from spyder.widgets.browser import WebViewActions diff --git a/spyder/plugins/onlinehelp/plugin.py b/spyder/plugins/onlinehelp/plugin.py index 664c7fab4fd..4bed08d6ab2 100644 --- a/spyder/plugins/onlinehelp/plugin.py +++ b/spyder/plugins/onlinehelp/plugin.py @@ -1,95 +1,95 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Online Help Plugin""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.plugins.onlinehelp.widgets import PydocBrowser - -# Localization -_ = get_translation('spyder') - - -# --- Plugin -# ---------------------------------------------------------------------------- -class OnlineHelp(SpyderDockablePlugin): - """ - Online Help Plugin. - """ - - NAME = 'onlinehelp' - TABIFY = Plugins.Help - CONF_SECTION = NAME - CONF_FILE = False - WIDGET_CLASS = PydocBrowser - LOG_PATH = get_conf_path(NAME) - - # --- Signals - # ------------------------------------------------------------------------ - sig_load_finished = Signal() - """ - This signal is emitted to indicate the help page has finished loading. - """ - - # --- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Online help') - - def get_description(self): - return _( - 'Browse and search the currently installed modules interactively.') - - def get_icon(self): - return self.create_icon('help') - - def on_close(self, cancelable=False): - self.save_history() - self.set_conf('zoom_factor', - self.get_widget().get_zoom_factor()) - return True - - def on_initialize(self): - widget = self.get_widget() - widget.load_history(self.load_history()) - widget.sig_load_finished.connect(self.sig_load_finished) - - def update_font(self): - self.get_widget().reload() - - # --- Public API - # ------------------------------------------------------------------------ - def load_history(self): - """ - Load history from a text file in the Spyder configuration directory. - """ - if osp.isfile(self.LOG_PATH): - with open(self.LOG_PATH, 'r') as fh: - lines = fh.read().split('\n') - - history = [line.replace('\n', '') for line in lines] - else: - history = [] - - return history - - def save_history(self): - """ - Save history to a text file in the Spyder configuration directory. - """ - data = "\n".join(self.get_widget().get_history()) - with open(self.LOG_PATH, 'w') as fh: - fh.write(data) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Online Help Plugin""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.plugins.onlinehelp.widgets import PydocBrowser + +# Localization +_ = get_translation('spyder') + + +# --- Plugin +# ---------------------------------------------------------------------------- +class OnlineHelp(SpyderDockablePlugin): + """ + Online Help Plugin. + """ + + NAME = 'onlinehelp' + TABIFY = Plugins.Help + CONF_SECTION = NAME + CONF_FILE = False + WIDGET_CLASS = PydocBrowser + LOG_PATH = get_conf_path(NAME) + + # --- Signals + # ------------------------------------------------------------------------ + sig_load_finished = Signal() + """ + This signal is emitted to indicate the help page has finished loading. + """ + + # --- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Online help') + + def get_description(self): + return _( + 'Browse and search the currently installed modules interactively.') + + def get_icon(self): + return self.create_icon('help') + + def on_close(self, cancelable=False): + self.save_history() + self.set_conf('zoom_factor', + self.get_widget().get_zoom_factor()) + return True + + def on_initialize(self): + widget = self.get_widget() + widget.load_history(self.load_history()) + widget.sig_load_finished.connect(self.sig_load_finished) + + def update_font(self): + self.get_widget().reload() + + # --- Public API + # ------------------------------------------------------------------------ + def load_history(self): + """ + Load history from a text file in the Spyder configuration directory. + """ + if osp.isfile(self.LOG_PATH): + with open(self.LOG_PATH, 'r') as fh: + lines = fh.read().split('\n') + + history = [line.replace('\n', '') for line in lines] + else: + history = [] + + return history + + def save_history(self): + """ + Save history to a text file in the Spyder configuration directory. + """ + data = "\n".join(self.get_widget().get_history()) + with open(self.LOG_PATH, 'w') as fh: + fh.write(data) diff --git a/spyder/plugins/onlinehelp/widgets.py b/spyder/plugins/onlinehelp/widgets.py index 9ebd3ea4d75..d97a9bb6241 100644 --- a/spyder/plugins/onlinehelp/widgets.py +++ b/spyder/plugins/onlinehelp/widgets.py @@ -1,513 +1,513 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -PyDoc widget. -""" - -# Standard library imports -import os.path as osp -import pydoc -import sys - -# Third party imports -from qtpy.QtCore import Qt, QThread, QUrl, Signal, Slot -from qtpy.QtGui import QCursor -from qtpy.QtWebEngineWidgets import WEBENGINE -from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import PluginMainWidget -from spyder.plugins.onlinehelp.pydoc_patch import _start_server, _url_handler -from spyder.widgets.browser import FrameWebView, WebViewActions -from spyder.widgets.comboboxes import UrlComboBox -from spyder.widgets.findreplace import FindReplace - - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -PORT = 30128 - - -class PydocBrowserActions: - # Triggers - Home = 'home_action' - Find = 'find_action' - - -class PydocBrowserMainToolbarSections: - Main = 'main_section' - - -class PydocBrowserToolbarItems: - PackageLabel = 'package_label' - UrlCombo = 'url_combo' - - -# ============================================================================= -# Pydoc adjustments -# ============================================================================= -# This is needed to prevent pydoc raise an ErrorDuringImport when -# trying to import numpy. -# See spyder-ide/spyder#10740 -DIRECT_PYDOC_IMPORT_MODULES = ['numpy', 'numpy.core'] -try: - from pydoc import safeimport - - def spyder_safeimport(path, forceload=0, cache={}): - if path in DIRECT_PYDOC_IMPORT_MODULES: - forceload = 0 - return safeimport(path, forceload=forceload, cache=cache) - - pydoc.safeimport = spyder_safeimport -except Exception: - pass - - -class PydocServer(QThread): - """ - Pydoc server. - """ - sig_server_started = Signal() - - def __init__(self, parent, port): - QThread.__init__(self, parent) - - self.port = port - self.server = None - self.complete = False - self.closed = False - - def run(self): - self.callback( - _start_server( - _url_handler, - hostname='127.0.0.1', - port=self.port, - ) - ) - - def callback(self, server): - self.server = server - if self.closed: - self.quit_server() - else: - self.sig_server_started.emit() - - def completer(self): - self.complete = True - - def is_running(self): - """Check if the server is running""" - if self.isRunning(): - # Startup - return True - - if self.server is None: - return False - - return self.server.serving - - def quit_server(self): - self.closed = True - if self.server is None: - return - - if self.server.serving: - self.server.stop() - - -class PydocBrowser(PluginMainWidget): - """PyDoc browser widget.""" - - ENABLE_SPINNER = True - - # --- Signals - # ------------------------------------------------------------------------ - sig_load_finished = Signal() - """ - This signal is emitted to indicate the help page has finished loading. - """ - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent=parent) - - self._is_running = False - self.home_url = None - self.server = None - - # Widgets - self.label = QLabel(_("Package:")) - self.label.ID = PydocBrowserToolbarItems.PackageLabel - - self.url_combo = UrlComboBox( - self, id_=PydocBrowserToolbarItems.UrlCombo) - - # Setup web view frame - self.webview = FrameWebView( - self, - handle_links=self.get_conf('handle_links') - ) - self.webview.setup() - self.webview.set_zoom_factor(self.get_conf('zoom_factor')) - self.webview.loadStarted.connect(self._start) - self.webview.loadFinished.connect(self._finish) - self.webview.titleChanged.connect(self.setWindowTitle) - self.webview.urlChanged.connect(self._change_url) - if not WEBENGINE: - self.webview.iconChanged.connect(self._handle_icon_change) - - # Setup find widget - self.find_widget = FindReplace(self) - self.find_widget.set_editor(self.webview) - self.find_widget.hide() - self.url_combo.setMaxCount(self.get_conf('max_history_entries')) - tip = _('Write a package name here, e.g. pandas') - self.url_combo.lineEdit().setPlaceholderText(tip) - self.url_combo.lineEdit().setToolTip(tip) - self.url_combo.valid.connect( - lambda x: self._handle_url_combo_activation()) - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.webview) - layout.addSpacing(1) - layout.addWidget(self.find_widget) - self.setLayout(layout) - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Online help') - - def get_focus_widget(self): - self.url_combo.lineEdit().selectAll() - return self.url_combo - - def setup(self): - # Actions - home_action = self.create_action( - PydocBrowserActions.Home, - text=_("Home"), - tip=_("Home"), - icon=self.create_icon('home'), - triggered=self.go_home, - ) - find_action = self.create_action( - PydocBrowserActions.Find, - text=_("Find"), - tip=_("Find text"), - icon=self.create_icon('find'), - toggled=self.toggle_find_widget, - initial=False, - ) - stop_action = self.get_action(WebViewActions.Stop) - refresh_action = self.get_action(WebViewActions.Refresh) - - # Toolbar - toolbar = self.get_main_toolbar() - for item in [self.get_action(WebViewActions.Back), - self.get_action(WebViewActions.Forward), refresh_action, - stop_action, home_action, self.label, self.url_combo, - self.get_action(WebViewActions.ZoomIn), - self.get_action(WebViewActions.ZoomOut), find_action, - ]: - self.add_item_to_toolbar( - item, - toolbar=toolbar, - section=PydocBrowserMainToolbarSections.Main, - ) - - # Signals - self.find_widget.visibility_changed.connect(find_action.setChecked) - - for __, action in self.get_actions().items(): - if action: - # IMPORTANT: Since we are defining the main actions in here - # and the context is WidgetWithChildrenShortcut we need to - # assign the same actions to the children widgets in order - # for shortcuts to work - try: - self.webview.addAction(action) - except RuntimeError: - pass - - self.sig_toggle_view_changed.connect(self.initialize) - - def update_actions(self): - stop_action = self.get_action(WebViewActions.Stop) - refresh_action = self.get_action(WebViewActions.Refresh) - - refresh_action.setVisible(not self._is_running) - stop_action.setVisible(self._is_running) - - # --- Private API - # ------------------------------------------------------------------------ - def _start(self): - """Webview load started.""" - self._is_running = True - self.start_spinner() - self.update_actions() - - def _finish(self, code): - """Webview load finished.""" - self._is_running = False - self.stop_spinner() - self.update_actions() - self.sig_load_finished.emit() - - def _continue_initialization(self): - """Load home page.""" - self.go_home() - QApplication.restoreOverrideCursor() - - def _handle_url_combo_activation(self): - """Load URL from combo box first item.""" - if not self._is_running: - text = str(self.url_combo.currentText()) - self.go_to(self.text_to_url(text)) - else: - self.get_action(WebViewActions.Stop).trigger() - - self.get_focus_widget().setFocus() - - def _change_url(self, url): - """ - Displayed URL has changed -> updating URL combo box. - """ - self.url_combo.add_text(self.url_to_text(url)) - - def _handle_icon_change(self): - """ - Handle icon changes. - """ - self.url_combo.setItemIcon(self.url_combo.currentIndex(), - self.webview.icon()) - self.setWindowIcon(self.webview.icon()) - - # --- Qt overrides - # ------------------------------------------------------------------------ - def closeEvent(self, event): - self.webview.web_widget.stop() - if self.server: - self.server.finished.connect(self.deleteLater) - self.quit_server() - super().closeEvent(event) - - # --- Public API - # ------------------------------------------------------------------------ - def load_history(self, history): - """ - Load history. - - Parameters - ---------- - history: list - List of searched items. - """ - self.url_combo.addItems(history) - - @Slot(bool) - def initialize(self, checked=True, force=False): - """ - Start pydoc server. - - Parameters - ---------- - checked: bool, optional - This method is connected to the `sig_toggle_view_changed` signal, - so that the first time the widget is made visible it will start - the server. Default is True. - force: bool, optional - Force a server start even if the server is running. - Default is False. - """ - server_needed = checked and self.server is None - if force or server_needed or not self.is_server_running(): - self.sig_toggle_view_changed.disconnect(self.initialize) - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.start_server() - - def is_server_running(self): - """Return True if pydoc server is already running.""" - return self.server is not None and self.server.is_running() - - def start_server(self): - """Start pydoc server.""" - if self.server is None: - self.set_home_url('http://127.0.0.1:{}/'.format(PORT)) - elif self.server.is_running(): - self.server.sig_server_started.disconnect( - self._continue_initialization) - self.server.quit() - self.server.wait() - - self.server = PydocServer(None, port=PORT) - self.server.sig_server_started.connect( - self._continue_initialization) - self.server.start() - - def quit_server(self): - """Quit the server.""" - if self.server is None: - return - - if self.server.is_running(): - self.server.sig_server_started.disconnect( - self._continue_initialization) - self.server.quit_server() - self.server.quit() - self.server.wait() - - def get_label(self): - """Return address label text""" - return _("Package:") - - def reload(self): - """Reload page.""" - if self.server: - self.webview.reload() - - def text_to_url(self, text): - """ - Convert text address into QUrl object. - - Parameters - ---------- - text: str - Url address. - """ - if text != 'about:blank': - text += '.html' - - if text.startswith('/'): - text = text[1:] - - return QUrl(self.home_url.toString() + text) - - def url_to_text(self, url): - """ - Convert QUrl object to displayed text in combo box. - - Parameters - ---------- - url: QUrl - Url address. - """ - string_url = url.toString() - if 'about:blank' in string_url: - return 'about:blank' - elif 'get?key=' in string_url or 'search?key=' in string_url: - return url.toString().split('=')[-1] - - return osp.splitext(str(url.path()))[0][1:] - - def set_home_url(self, text): - """ - Set home URL. - - Parameters - ---------- - text: str - Home url address. - """ - self.home_url = QUrl(text) - - def set_url(self, url): - """ - Set current URL. - - Parameters - ---------- - url: QUrl or str - Url address. - """ - self._change_url(url) - self.go_to(url) - - def go_to(self, url_or_text): - """ - Go to page URL. - """ - if isinstance(url_or_text, str): - url = QUrl(url_or_text) - else: - url = url_or_text - - self.webview.load(url) - - @Slot() - def go_home(self): - """ - Go to home page. - """ - if self.home_url is not None: - self.set_url(self.home_url) - - def get_zoom_factor(self): - """ - Get the current zoom factor. - - Returns - ------- - int - Zoom factor. - """ - return self.webview.get_zoom_factor() - - def get_history(self): - """ - Return the list of history items in the combobox. - - Returns - ------- - list - List of strings. - """ - history = [] - for index in range(self.url_combo.count()): - history.append(str(self.url_combo.itemText(index))) - - return history - - @Slot(bool) - def toggle_find_widget(self, state): - """ - Show/hide the find widget. - - Parameters - ---------- - state: bool - True to show and False to hide the find widget. - """ - if state: - self.find_widget.show() - else: - self.find_widget.hide() - - -def test(): - """Run web browser.""" - from spyder.utils.qthelpers import qapplication - from unittest.mock import MagicMock - - plugin_mock = MagicMock() - plugin_mock.CONF_SECTION = 'onlinehelp' - app = qapplication(test_time=8) - widget = PydocBrowser(None, plugin=plugin_mock) - widget._setup() - widget.setup() - widget.show() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +PyDoc widget. +""" + +# Standard library imports +import os.path as osp +import pydoc +import sys + +# Third party imports +from qtpy.QtCore import Qt, QThread, QUrl, Signal, Slot +from qtpy.QtGui import QCursor +from qtpy.QtWebEngineWidgets import WEBENGINE +from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import PluginMainWidget +from spyder.plugins.onlinehelp.pydoc_patch import _start_server, _url_handler +from spyder.widgets.browser import FrameWebView, WebViewActions +from spyder.widgets.comboboxes import UrlComboBox +from spyder.widgets.findreplace import FindReplace + + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +PORT = 30128 + + +class PydocBrowserActions: + # Triggers + Home = 'home_action' + Find = 'find_action' + + +class PydocBrowserMainToolbarSections: + Main = 'main_section' + + +class PydocBrowserToolbarItems: + PackageLabel = 'package_label' + UrlCombo = 'url_combo' + + +# ============================================================================= +# Pydoc adjustments +# ============================================================================= +# This is needed to prevent pydoc raise an ErrorDuringImport when +# trying to import numpy. +# See spyder-ide/spyder#10740 +DIRECT_PYDOC_IMPORT_MODULES = ['numpy', 'numpy.core'] +try: + from pydoc import safeimport + + def spyder_safeimport(path, forceload=0, cache={}): + if path in DIRECT_PYDOC_IMPORT_MODULES: + forceload = 0 + return safeimport(path, forceload=forceload, cache=cache) + + pydoc.safeimport = spyder_safeimport +except Exception: + pass + + +class PydocServer(QThread): + """ + Pydoc server. + """ + sig_server_started = Signal() + + def __init__(self, parent, port): + QThread.__init__(self, parent) + + self.port = port + self.server = None + self.complete = False + self.closed = False + + def run(self): + self.callback( + _start_server( + _url_handler, + hostname='127.0.0.1', + port=self.port, + ) + ) + + def callback(self, server): + self.server = server + if self.closed: + self.quit_server() + else: + self.sig_server_started.emit() + + def completer(self): + self.complete = True + + def is_running(self): + """Check if the server is running""" + if self.isRunning(): + # Startup + return True + + if self.server is None: + return False + + return self.server.serving + + def quit_server(self): + self.closed = True + if self.server is None: + return + + if self.server.serving: + self.server.stop() + + +class PydocBrowser(PluginMainWidget): + """PyDoc browser widget.""" + + ENABLE_SPINNER = True + + # --- Signals + # ------------------------------------------------------------------------ + sig_load_finished = Signal() + """ + This signal is emitted to indicate the help page has finished loading. + """ + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent=parent) + + self._is_running = False + self.home_url = None + self.server = None + + # Widgets + self.label = QLabel(_("Package:")) + self.label.ID = PydocBrowserToolbarItems.PackageLabel + + self.url_combo = UrlComboBox( + self, id_=PydocBrowserToolbarItems.UrlCombo) + + # Setup web view frame + self.webview = FrameWebView( + self, + handle_links=self.get_conf('handle_links') + ) + self.webview.setup() + self.webview.set_zoom_factor(self.get_conf('zoom_factor')) + self.webview.loadStarted.connect(self._start) + self.webview.loadFinished.connect(self._finish) + self.webview.titleChanged.connect(self.setWindowTitle) + self.webview.urlChanged.connect(self._change_url) + if not WEBENGINE: + self.webview.iconChanged.connect(self._handle_icon_change) + + # Setup find widget + self.find_widget = FindReplace(self) + self.find_widget.set_editor(self.webview) + self.find_widget.hide() + self.url_combo.setMaxCount(self.get_conf('max_history_entries')) + tip = _('Write a package name here, e.g. pandas') + self.url_combo.lineEdit().setPlaceholderText(tip) + self.url_combo.lineEdit().setToolTip(tip) + self.url_combo.valid.connect( + lambda x: self._handle_url_combo_activation()) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.webview) + layout.addSpacing(1) + layout.addWidget(self.find_widget) + self.setLayout(layout) + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Online help') + + def get_focus_widget(self): + self.url_combo.lineEdit().selectAll() + return self.url_combo + + def setup(self): + # Actions + home_action = self.create_action( + PydocBrowserActions.Home, + text=_("Home"), + tip=_("Home"), + icon=self.create_icon('home'), + triggered=self.go_home, + ) + find_action = self.create_action( + PydocBrowserActions.Find, + text=_("Find"), + tip=_("Find text"), + icon=self.create_icon('find'), + toggled=self.toggle_find_widget, + initial=False, + ) + stop_action = self.get_action(WebViewActions.Stop) + refresh_action = self.get_action(WebViewActions.Refresh) + + # Toolbar + toolbar = self.get_main_toolbar() + for item in [self.get_action(WebViewActions.Back), + self.get_action(WebViewActions.Forward), refresh_action, + stop_action, home_action, self.label, self.url_combo, + self.get_action(WebViewActions.ZoomIn), + self.get_action(WebViewActions.ZoomOut), find_action, + ]: + self.add_item_to_toolbar( + item, + toolbar=toolbar, + section=PydocBrowserMainToolbarSections.Main, + ) + + # Signals + self.find_widget.visibility_changed.connect(find_action.setChecked) + + for __, action in self.get_actions().items(): + if action: + # IMPORTANT: Since we are defining the main actions in here + # and the context is WidgetWithChildrenShortcut we need to + # assign the same actions to the children widgets in order + # for shortcuts to work + try: + self.webview.addAction(action) + except RuntimeError: + pass + + self.sig_toggle_view_changed.connect(self.initialize) + + def update_actions(self): + stop_action = self.get_action(WebViewActions.Stop) + refresh_action = self.get_action(WebViewActions.Refresh) + + refresh_action.setVisible(not self._is_running) + stop_action.setVisible(self._is_running) + + # --- Private API + # ------------------------------------------------------------------------ + def _start(self): + """Webview load started.""" + self._is_running = True + self.start_spinner() + self.update_actions() + + def _finish(self, code): + """Webview load finished.""" + self._is_running = False + self.stop_spinner() + self.update_actions() + self.sig_load_finished.emit() + + def _continue_initialization(self): + """Load home page.""" + self.go_home() + QApplication.restoreOverrideCursor() + + def _handle_url_combo_activation(self): + """Load URL from combo box first item.""" + if not self._is_running: + text = str(self.url_combo.currentText()) + self.go_to(self.text_to_url(text)) + else: + self.get_action(WebViewActions.Stop).trigger() + + self.get_focus_widget().setFocus() + + def _change_url(self, url): + """ + Displayed URL has changed -> updating URL combo box. + """ + self.url_combo.add_text(self.url_to_text(url)) + + def _handle_icon_change(self): + """ + Handle icon changes. + """ + self.url_combo.setItemIcon(self.url_combo.currentIndex(), + self.webview.icon()) + self.setWindowIcon(self.webview.icon()) + + # --- Qt overrides + # ------------------------------------------------------------------------ + def closeEvent(self, event): + self.webview.web_widget.stop() + if self.server: + self.server.finished.connect(self.deleteLater) + self.quit_server() + super().closeEvent(event) + + # --- Public API + # ------------------------------------------------------------------------ + def load_history(self, history): + """ + Load history. + + Parameters + ---------- + history: list + List of searched items. + """ + self.url_combo.addItems(history) + + @Slot(bool) + def initialize(self, checked=True, force=False): + """ + Start pydoc server. + + Parameters + ---------- + checked: bool, optional + This method is connected to the `sig_toggle_view_changed` signal, + so that the first time the widget is made visible it will start + the server. Default is True. + force: bool, optional + Force a server start even if the server is running. + Default is False. + """ + server_needed = checked and self.server is None + if force or server_needed or not self.is_server_running(): + self.sig_toggle_view_changed.disconnect(self.initialize) + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + self.start_server() + + def is_server_running(self): + """Return True if pydoc server is already running.""" + return self.server is not None and self.server.is_running() + + def start_server(self): + """Start pydoc server.""" + if self.server is None: + self.set_home_url('http://127.0.0.1:{}/'.format(PORT)) + elif self.server.is_running(): + self.server.sig_server_started.disconnect( + self._continue_initialization) + self.server.quit() + self.server.wait() + + self.server = PydocServer(None, port=PORT) + self.server.sig_server_started.connect( + self._continue_initialization) + self.server.start() + + def quit_server(self): + """Quit the server.""" + if self.server is None: + return + + if self.server.is_running(): + self.server.sig_server_started.disconnect( + self._continue_initialization) + self.server.quit_server() + self.server.quit() + self.server.wait() + + def get_label(self): + """Return address label text""" + return _("Package:") + + def reload(self): + """Reload page.""" + if self.server: + self.webview.reload() + + def text_to_url(self, text): + """ + Convert text address into QUrl object. + + Parameters + ---------- + text: str + Url address. + """ + if text != 'about:blank': + text += '.html' + + if text.startswith('/'): + text = text[1:] + + return QUrl(self.home_url.toString() + text) + + def url_to_text(self, url): + """ + Convert QUrl object to displayed text in combo box. + + Parameters + ---------- + url: QUrl + Url address. + """ + string_url = url.toString() + if 'about:blank' in string_url: + return 'about:blank' + elif 'get?key=' in string_url or 'search?key=' in string_url: + return url.toString().split('=')[-1] + + return osp.splitext(str(url.path()))[0][1:] + + def set_home_url(self, text): + """ + Set home URL. + + Parameters + ---------- + text: str + Home url address. + """ + self.home_url = QUrl(text) + + def set_url(self, url): + """ + Set current URL. + + Parameters + ---------- + url: QUrl or str + Url address. + """ + self._change_url(url) + self.go_to(url) + + def go_to(self, url_or_text): + """ + Go to page URL. + """ + if isinstance(url_or_text, str): + url = QUrl(url_or_text) + else: + url = url_or_text + + self.webview.load(url) + + @Slot() + def go_home(self): + """ + Go to home page. + """ + if self.home_url is not None: + self.set_url(self.home_url) + + def get_zoom_factor(self): + """ + Get the current zoom factor. + + Returns + ------- + int + Zoom factor. + """ + return self.webview.get_zoom_factor() + + def get_history(self): + """ + Return the list of history items in the combobox. + + Returns + ------- + list + List of strings. + """ + history = [] + for index in range(self.url_combo.count()): + history.append(str(self.url_combo.itemText(index))) + + return history + + @Slot(bool) + def toggle_find_widget(self, state): + """ + Show/hide the find widget. + + Parameters + ---------- + state: bool + True to show and False to hide the find widget. + """ + if state: + self.find_widget.show() + else: + self.find_widget.hide() + + +def test(): + """Run web browser.""" + from spyder.utils.qthelpers import qapplication + from unittest.mock import MagicMock + + plugin_mock = MagicMock() + plugin_mock.CONF_SECTION = 'onlinehelp' + app = qapplication(test_time=8) + widget = PydocBrowser(None, plugin=plugin_mock) + widget._setup() + widget.setup() + widget.show() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/outlineexplorer/plugin.py b/spyder/plugins/outlineexplorer/plugin.py index 3839f01e0a5..378bf5daab4 100644 --- a/spyder/plugins/outlineexplorer/plugin.py +++ b/spyder/plugins/outlineexplorer/plugin.py @@ -1,108 +1,108 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Outline Explorer Plugin.""" - -# Third party imports -from qtpy.QtCore import Slot - -# Local imports -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.plugins import SpyderDockablePlugin, Plugins -from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget - -# Localization -_ = get_translation('spyder') - - -class OutlineExplorer(SpyderDockablePlugin): - NAME = 'outline_explorer' - CONF_SECTION = 'outline_explorer' - REQUIRES = [Plugins.Completions, Plugins.Editor] - OPTIONAL = [] - - CONF_FILE = False - WIDGET_CLASS = OutlineExplorerWidget - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name() -> str: - """Return widget title.""" - return _('Outline Explorer') - - def get_description(self) -> str: - """Return the description of the outline explorer widget.""" - return _("Explore a file's functions, classes and methods") - - def get_icon(self): - """Return the outline explorer icon.""" - return self.create_icon('outline_explorer') - - def on_initialize(self): - if self.main: - self.main.restore_scrollbar_position.connect( - self.restore_scrollbar_position) - - @on_plugin_available(plugin=Plugins.Completions) - def on_completions_available(self): - completions = self.get_plugin(Plugins.Completions) - - completions.sig_language_completions_available.connect( - self.start_symbol_services) - completions.sig_stop_completions.connect( - self.stop_symbol_services) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_open_files_finished.connect( - self.update_all_editors) - - @on_plugin_teardown(plugin=Plugins.Completions) - def on_completions_teardown(self): - completions = self.get_plugin(Plugins.Completions) - - completions.sig_language_completions_available.disconnect( - self.start_symbol_services) - completions.sig_stop_completions.disconnect( - self.stop_symbol_services) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - - editor.sig_open_files_finished.disconnect( - self.update_all_editors) - - #------ Public API --------------------------------------------------------- - def restore_scrollbar_position(self): - """Restoring scrollbar position after main window is visible""" - scrollbar_pos = self.get_conf('scrollbar_position', None) - explorer = self.get_widget() - if scrollbar_pos is not None: - explorer.treewidget.set_scrollbar_position(scrollbar_pos) - - @Slot(dict, str) - def start_symbol_services(self, capabilities, language): - """Enable LSP symbols functionality.""" - explorer = self.get_widget() - symbol_provider = capabilities.get('documentSymbolProvider', False) - if symbol_provider: - explorer.start_symbol_services(language) - - def stop_symbol_services(self, language): - """Disable LSP symbols functionality.""" - explorer = self.get_widget() - explorer.stop_symbol_services(language) - - def update_all_editors(self): - """Update all editors with an associated LSP server.""" - explorer = self.get_widget() - explorer.update_all_editors() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Outline Explorer Plugin.""" + +# Third party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.plugins import SpyderDockablePlugin, Plugins +from spyder.plugins.outlineexplorer.main_widget import OutlineExplorerWidget + +# Localization +_ = get_translation('spyder') + + +class OutlineExplorer(SpyderDockablePlugin): + NAME = 'outline_explorer' + CONF_SECTION = 'outline_explorer' + REQUIRES = [Plugins.Completions, Plugins.Editor] + OPTIONAL = [] + + CONF_FILE = False + WIDGET_CLASS = OutlineExplorerWidget + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name() -> str: + """Return widget title.""" + return _('Outline Explorer') + + def get_description(self) -> str: + """Return the description of the outline explorer widget.""" + return _("Explore a file's functions, classes and methods") + + def get_icon(self): + """Return the outline explorer icon.""" + return self.create_icon('outline_explorer') + + def on_initialize(self): + if self.main: + self.main.restore_scrollbar_position.connect( + self.restore_scrollbar_position) + + @on_plugin_available(plugin=Plugins.Completions) + def on_completions_available(self): + completions = self.get_plugin(Plugins.Completions) + + completions.sig_language_completions_available.connect( + self.start_symbol_services) + completions.sig_stop_completions.connect( + self.stop_symbol_services) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_open_files_finished.connect( + self.update_all_editors) + + @on_plugin_teardown(plugin=Plugins.Completions) + def on_completions_teardown(self): + completions = self.get_plugin(Plugins.Completions) + + completions.sig_language_completions_available.disconnect( + self.start_symbol_services) + completions.sig_stop_completions.disconnect( + self.stop_symbol_services) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + + editor.sig_open_files_finished.disconnect( + self.update_all_editors) + + #------ Public API --------------------------------------------------------- + def restore_scrollbar_position(self): + """Restoring scrollbar position after main window is visible""" + scrollbar_pos = self.get_conf('scrollbar_position', None) + explorer = self.get_widget() + if scrollbar_pos is not None: + explorer.treewidget.set_scrollbar_position(scrollbar_pos) + + @Slot(dict, str) + def start_symbol_services(self, capabilities, language): + """Enable LSP symbols functionality.""" + explorer = self.get_widget() + symbol_provider = capabilities.get('documentSymbolProvider', False) + if symbol_provider: + explorer.start_symbol_services(language) + + def stop_symbol_services(self, language): + """Disable LSP symbols functionality.""" + explorer = self.get_widget() + explorer.stop_symbol_services(language) + + def update_all_editors(self): + """Update all editors with an associated LSP server.""" + explorer = self.get_widget() + explorer.update_all_editors() diff --git a/spyder/plugins/outlineexplorer/widgets.py b/spyder/plugins/outlineexplorer/widgets.py index e9ff550afdd..3d5ddf4391a 100644 --- a/spyder/plugins/outlineexplorer/widgets.py +++ b/spyder/plugins/outlineexplorer/widgets.py @@ -1,887 +1,887 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Outline explorer widgets.""" - -# Standard library imports -import bisect -import os.path as osp -import uuid - -# Third party imports -from intervaltree import IntervalTree -from pkg_resources import parse_version -from qtpy import PYSIDE2 -from qtpy.compat import from_qvariant -from qtpy.QtCore import Qt, QTimer, Signal, Slot -from qtpy.QtWidgets import QTreeWidgetItem, QTreeWidgetItemIterator - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.config.base import _ -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.plugins.completion.api import SymbolKind, SYMBOL_KIND_ICON -from spyder.utils.qthelpers import set_item_user_text -from spyder.widgets.onecolumntree import OneColumnTree - -if PYSIDE2: - from qtpy import PYSIDE_VERSION - - -# ---- Constants -# ----------------------------------------------------------------------------- -SYMBOL_NAME_MAP = { - SymbolKind.FILE: _('File'), - SymbolKind.MODULE: _('Module'), - SymbolKind.NAMESPACE: _('Namespace'), - SymbolKind.PACKAGE: _('Package'), - SymbolKind.CLASS: _('Class'), - SymbolKind.METHOD: _('Method'), - SymbolKind.PROPERTY: _('Property'), - SymbolKind.FIELD: _('Attribute'), - SymbolKind.CONSTRUCTOR: _('constructor'), - SymbolKind.ENUM: _('Enum'), - SymbolKind.INTERFACE: _('Interface'), - SymbolKind.FUNCTION: _('Function'), - SymbolKind.VARIABLE: _('Variable'), - SymbolKind.CONSTANT: _('Constant'), - SymbolKind.STRING: _('String'), - SymbolKind.NUMBER: _('Number'), - SymbolKind.BOOLEAN: _('Boolean'), - SymbolKind.ARRAY: _('Array'), - SymbolKind.OBJECT: _('Object'), - SymbolKind.KEY: _('Key'), - SymbolKind.NULL: _('Null'), - SymbolKind.ENUM_MEMBER: _('Enum member'), - SymbolKind.STRUCT: _('Struct'), - SymbolKind.EVENT: _('Event'), - SymbolKind.OPERATOR: _('Operator'), - SymbolKind.TYPE_PARAMETER: _('Type parameter'), - SymbolKind.CELL: _('Cell'), - SymbolKind.BLOCK_COMMENT: _('Block comment') -} - - -# ---- Symbol status -# ----------------------------------------------------------------------------- -class SymbolStatus: - def __init__(self, name, kind, position, path, node=None): - self.name = name - self.position = position - self.kind = kind - self.node = node - self.path = path - self.id = str(uuid.uuid4()) - self.index = None - self.children = [] - self.status = False - self.selected = False - self.parent = None - - def delete(self): - for child in self.children: - child.parent = None - - self.children = [] - self.node.takeChildren() - - if self.parent is not None: - self.parent.remove_node(self) - self.parent = None - - if self.node.parent is not None: - self.node.parent.remove_children(self.node) - - def add_node(self, node): - if node.position == self.position: - # The nodes should be at the same level - self.parent.add_node(node) - else: - node.parent = self - node.path = self.path - this_node = self.node - children_ranges = [c.position[0] for c in self.children] - node_range = node.position[0] - new_index = bisect.bisect_left(children_ranges, node_range) - node.index = new_index - for child in self.children[new_index:]: - child.index += 1 - this_node.append_children(new_index, node.node) - self.children.insert(new_index, node) - for idx, next_idx in zip(self.children, self.children[1:]): - assert idx.index < next_idx.index - - def remove_node(self, node): - for child in self.children[node.index + 1:]: - child.index -= 1 - self.children.pop(node.index) - for idx, next_idx in zip(self.children, self.children[1:]): - assert idx.index < next_idx.index - - def clone_node(self, node): - self.id = node.id - self.index = node.index - self.path = node.path - self.children = node.children - self.status = node.status - self.selected = node.selected - self.node = node.node - self.parent = node.parent - self.node.update_info(self.name, self.kind, self.position[0] + 1, - self.status, self.selected) - self.node.ref = self - - for child in self.children: - child.parent = self - - if self.parent is not None: - self.parent.replace_node(self.index, self) - - def refresh(self): - self.node.update_info(self.name, self.kind, self.position[0] + 1, - self.status, self.selected) - - def replace_node(self, index, node): - self.children[index] = node - - def create_node(self): - self.node = SymbolItem(None, self, self.name, self.kind, - self.position[0] + 1, self.status, - self.selected) - - def __repr__(self): - return str(self) - - def __str__(self): - return '({0}, {1}, {2}, {3})'.format( - self.position, self.name, self.id, self.status) - - -# ---- Items -# ----------------------------------------------------------------------------- -class BaseTreeItem(QTreeWidgetItem): - def clear(self): - self.takeChildren() - - def append_children(self, index, node): - self.insertChild(index, node) - node.parent = self - - def remove_children(self, node): - self.removeChild(node) - node.parent = None - - -class FileRootItem(BaseTreeItem): - def __init__(self, path, ref, treewidget, is_python=True): - QTreeWidgetItem.__init__(self, treewidget, QTreeWidgetItem.Type) - self.path = path - self.ref = ref - self.setIcon( - 0, ima.icon('python') if is_python else ima.icon('TextFileIcon')) - self.setToolTip(0, path) - set_item_user_text(self, path) - - def set_path(self, path, fullpath): - self.path = path - self.set_text(fullpath) - - def set_text(self, fullpath): - self.setText(0, self.path if fullpath else osp.basename(self.path)) - - -class SymbolItem(BaseTreeItem): - """Generic symbol tree item.""" - def __init__(self, parent, ref, name, kind, position, status, selected): - QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) - self.parent = parent - self.ref = ref - self.num_children = 0 - self.update_info(name, kind, position, status, selected) - - def update_info(self, name, kind, position, status, selected): - self.setIcon(0, ima.icon(SYMBOL_KIND_ICON.get(kind, 'no_match'))) - identifier = SYMBOL_NAME_MAP.get(kind, '') - identifier = identifier.replace('_', ' ').capitalize() - self.setToolTip(0, '{3} {2}: {0} {1}'.format( - identifier, name, position, _('Line'))) - set_item_user_text(self, name) - self.setText(0, name) - self.setExpanded(status) - self.setSelected(selected) - - -class TreeItem(QTreeWidgetItem): - """Class browser item base class.""" - def __init__(self, oedata, parent, preceding): - if preceding is None: - QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) - else: - if preceding is not parent: - # Preceding must be either the same as item's parent - # or have the same parent as item - while preceding.parent() is not parent: - preceding = preceding.parent() - if preceding is None: - break - if preceding is None: - QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) - else: - QTreeWidgetItem.__init__(self, parent, preceding, - QTreeWidgetItem.Type) - self.parent_item = parent - self.oedata = oedata - oedata.sig_update.connect(self.update) - self.update() - - def level(self): - """Get fold level.""" - return self.oedata.fold_level - - def get_name(self): - """Get the item name.""" - return self.oedata.def_name - - def set_icon(self, icon): - self.setIcon(0, icon) - - def setup(self): - self.setToolTip(0, _("Line %s") % str(self.line)) - - @property - def line(self): - """Get line number.""" - block_number = self.oedata.get_block_number() - if block_number is not None: - return block_number + 1 - return None - - def update(self): - """Update the tree element.""" - name = self.get_name() - self.setText(0, name) - parent_text = from_qvariant(self.parent_item.data(0, Qt.UserRole), - to_text_string) - set_item_user_text(self, parent_text + '/' + name) - self.setup() - - -# ---- Treewidget -# ----------------------------------------------------------------------------- -class OutlineExplorerTreeWidget(OneColumnTree): - # Used only for debug purposes - sig_tree_updated = Signal() - sig_display_spinner = Signal() - sig_hide_spinner = Signal() - sig_update_configuration = Signal() - - CONF_SECTION = 'outline_explorer' - - def __init__(self, parent): - if hasattr(parent, 'CONTEXT_NAME'): - self.CONTEXT_NAME = parent.CONTEXT_NAME - - self.show_fullpath = self.get_conf('show_fullpath') - self.show_all_files = self.get_conf('show_all_files') - self.group_cells = self.get_conf('group_cells') - self.show_comments = self.get_conf('show_comments') - self.sort_files_alphabetically = self.get_conf( - 'sort_files_alphabetically') - self.follow_cursor = self.get_conf('follow_cursor') - self.display_variables = self.get_conf('display_variables') - - super().__init__(parent) - - self.freeze = False # Freezing widget to avoid any unwanted update - self.editor_items = {} - self.editor_tree_cache = {} - self.editor_ids = {} - self.update_timers = {} - self.editors_to_update = {} - self.ordered_editor_ids = [] - self._current_editor = None - self._languages = [] - self.is_visible = True - - self.currentItemChanged.connect(self.selection_switched) - self.itemExpanded.connect(self.tree_item_expanded) - self.itemCollapsed.connect(self.tree_item_collapsed) - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------ - @property - def current_editor(self): - """Get current editor.""" - return self._current_editor - - @current_editor.setter - def current_editor(self, value): - """Set current editor and connect the necessary signals.""" - if self._current_editor == value: - return - # Disconnect previous editor - self.connect_current_editor(False) - self._current_editor = value - # Connect new editor - self.connect_current_editor(True) - - def __hide_or_show_root_items(self, item): - """ - show_all_files option is disabled: hide all root items except *item* - show_all_files option is enabled: do nothing - """ - for _it in self.get_top_level_items(): - _it.setHidden(_it is not item and not self.show_all_files) - - @on_conf_change(option='show_fullpath') - def toggle_fullpath_mode(self, state): - self.show_fullpath = state - self.setTextElideMode(Qt.ElideMiddle if state else Qt.ElideRight) - for index in range(self.topLevelItemCount()): - self.topLevelItem(index).set_text(fullpath=self.show_fullpath) - - @on_conf_change(option='show_all_files') - def toggle_show_all_files(self, state): - self.show_all_files = state - if self.current_editor is not None: - editor_id = self.editor_ids[self.current_editor] - item = self.editor_items[editor_id].node - self.__hide_or_show_root_items(item) - self.__sort_toplevel_items() - if self.show_all_files is False: - self.root_item_selected( - self.editor_items[self.editor_ids[self.current_editor]]) - self.do_follow_cursor() - - @on_conf_change(option='show_comments') - def toggle_show_comments(self, state): - self.show_comments = state - self.sig_update_configuration.emit() - self.update_editors(language='python') - - @on_conf_change(option='group_cells') - def toggle_group_cells(self, state): - self.group_cells = state - self.sig_update_configuration.emit() - self.update_editors(language='python') - - @on_conf_change(option='display_variables') - def toggle_variables(self, state): - self.display_variables = state - for editor in self.editor_ids.keys(): - self.update_editor(editor.info, editor) - - @on_conf_change(option='sort_files_alphabetically') - def toggle_sort_files_alphabetically(self, state): - self.sort_files_alphabetically = state - self.__sort_toplevel_items() - - @on_conf_change(option='follow_cursor') - def toggle_follow_cursor(self, state): - """Follow the cursor.""" - self.follow_cursor = state - self.do_follow_cursor() - - @Slot() - def do_follow_cursor(self): - """Go to cursor position.""" - if self.follow_cursor: - self.go_to_cursor_position() - - @Slot() - def go_to_cursor_position(self): - if self.current_editor is not None: - editor_id = self.editor_ids[self.current_editor] - line = self.current_editor.get_cursor_line_number() - tree = self.editor_tree_cache[editor_id] - root = self.editor_items[editor_id] - overlap = tree[line - 1] - if len(overlap) == 0: - item = root.node - self.setCurrentItem(item) - self.scrollToItem(item) - self.expandItem(item) - else: - sorted_nodes = sorted(overlap) - # The last item of the sorted elements correspond to the - # current node if expanding, otherwise it is the first stopper - # found - idx = -1 - self.switch_to_node(sorted_nodes, idx) - - def switch_to_node(self, sorted_nodes, idx): - """Given a set of tree nodes, highlight the node on index `idx`.""" - item_interval = sorted_nodes[idx] - item_ref = item_interval.data - item = item_ref.node - self.setCurrentItem(item) - self.scrollToItem(item) - self.expandItem(item) - - def connect_current_editor(self, state): - """Connect or disconnect the editor from signals.""" - editor = self.current_editor - if editor is None: - return - - # Connect syntax highlighter - sig_update = editor.sig_outline_explorer_data_changed - sig_move = editor.sig_cursor_position_changed - sig_display_spinner = editor.sig_start_outline_spinner - if state: - sig_update.connect(self.update_editor) - sig_move.connect(self.do_follow_cursor) - sig_display_spinner.connect(self.sig_display_spinner) - self.do_follow_cursor() - else: - try: - sig_update.disconnect(self.update_editor) - sig_move.disconnect(self.do_follow_cursor) - sig_display_spinner.disconnect(self.sig_display_spinner) - except TypeError: - # This catches an error while performing - # teardown in one of our tests. - pass - - def clear(self): - """Reimplemented Qt method""" - self.set_title('') - OneColumnTree.clear(self) - - def set_current_editor(self, editor, update): - """Bind editor instance""" - editor_id = editor.get_id() - - # Don't fail if editor doesn't exist anymore. This - # happens when switching projects. - try: - item = self.editor_items[editor_id].node - except KeyError: - return - - if not self.freeze: - self.scrollToItem(item) - self.root_item_selected(item) - self.__hide_or_show_root_items(item) - if update: - self.save_expanded_state() - self.restore_expanded_state() - - self.current_editor = editor - - # Update tree with currently stored info or require symbols if - # necessary. - if (editor.get_language().lower() in self._languages and - len(self.editor_tree_cache[editor_id]) == 0): - if editor.info is not None: - self.update_editor(editor.info) - elif editor.is_cloned: - editor.request_symbols() - - def register_editor(self, editor): - """ - Register editor attributes and create basic objects associated - to it. - """ - editor_id = editor.get_id() - self.editor_ids[editor] = editor_id - self.ordered_editor_ids.append(editor_id) - - this_root = SymbolStatus(editor.fname, None, None, editor.fname) - self.editor_items[editor_id] = this_root - - root_item = FileRootItem(editor.fname, this_root, - self, editor.is_python()) - this_root.node = root_item - root_item.set_text(fullpath=self.show_fullpath) - self.resizeColumnToContents(0) - if not self.show_all_files: - root_item.setHidden(True) - - editor_tree = IntervalTree() - self.editor_tree_cache[editor_id] = editor_tree - - self.__sort_toplevel_items() - - def file_renamed(self, editor, new_filename): - """File was renamed, updating outline explorer tree""" - if editor is None: - # This is needed when we can't find an editor to attach - # the outline explorer to. - # Fix spyder-ide/spyder#8813. - return - editor_id = editor.get_id() - if editor_id in list(self.editor_ids.values()): - root_item = self.editor_items[editor_id].node - root_item.set_path(new_filename, fullpath=self.show_fullpath) - self.__sort_toplevel_items() - - def update_editors(self, language): - """ - Update all editors for a given language sequentially. - - This is done through a timer to avoid lags in the interface. - """ - if self.editors_to_update.get(language): - editor = self.editors_to_update[language][0] - if editor.info is not None: - # Editor could be not there anymore after switching - # projects - try: - self.update_editor(editor.info, editor) - except KeyError: - pass - self.editors_to_update[language].remove(editor) - self.update_timers[language].start() - - def update_all_editors(self, reset_info=False): - """Update all editors with LSP support.""" - for language in self._languages: - self.set_editors_to_update(language, reset_info=reset_info) - self.update_timers[language].start() - - @Slot(list) - def update_editor(self, items, editor=None): - """ - Update the outline explorer for `editor` preserving the tree - state. - """ - if items is None: - return - - # Only perform an update if the widget is visible. - if not self.is_visible: - self.sig_hide_spinner.emit() - return - - if editor is None: - editor = self.current_editor - editor_id = editor.get_id() - language = editor.get_language() - - update = self.update_tree(items, editor_id, language) - - if update: - self.save_expanded_state() - self.restore_expanded_state() - self.do_follow_cursor() - - def merge_interval(self, parent, node): - """Add node into an existing tree structure.""" - match = False - start, end = node.position - while parent.parent is not None and not match: - parent_start, parent_end = parent.position - if parent_end <= start: - parent = parent.parent - else: - match = True - - if node.parent is not None: - node.parent.remove_node(node) - node.parent = None - if node.node.parent is not None: - node.node.parent.remove_children(node.node) - - parent.add_node(node) - node.refresh() - return node - - def update_tree(self, items, editor_id, language): - """Update tree with new items that come from the LSP.""" - current_tree = self.editor_tree_cache[editor_id] - tree_info = [] - for symbol in items: - symbol_name = symbol['name'] - symbol_kind = symbol['kind'] - if language.lower() == 'python': - if symbol_kind == SymbolKind.MODULE: - continue - if (symbol_kind == SymbolKind.VARIABLE and - not self.display_variables): - continue - if (symbol_kind == SymbolKind.FIELD and - not self.display_variables): - continue - # NOTE: This could be also a DocumentSymbol - symbol_range = symbol['location']['range'] - symbol_start = symbol_range['start']['line'] - symbol_end = symbol_range['end']['line'] - symbol_repr = SymbolStatus(symbol_name, symbol_kind, - (symbol_start, symbol_end), None) - tree_info.append((symbol_start, symbol_end + 1, symbol_repr)) - - tree = IntervalTree.from_tuples(tree_info) - changes = tree - current_tree - deleted = current_tree - tree - - if len(changes) == 0 and len(deleted) == 0: - self.sig_hide_spinner.emit() - return False - - adding_symbols = len(changes) > len(deleted) - deleted_iter = iter(sorted(deleted)) - changes_iter = iter(sorted(changes)) - - deleted_entry = next(deleted_iter, None) - changed_entry = next(changes_iter, None) - non_merged = 0 - - while deleted_entry is not None and changed_entry is not None: - deleted_entry_i = deleted_entry.data - changed_entry_i = changed_entry.data - - if deleted_entry_i.name == changed_entry_i.name: - # Copy symbol status - changed_entry_i.clone_node(deleted_entry_i) - deleted_entry = next(deleted_iter, None) - changed_entry = next(changes_iter, None) - else: - if adding_symbols: - # New symbol added - changed_entry_i.create_node() - non_merged += 1 - changed_entry = next(changes_iter, None) - else: - # Symbol removed - deleted_entry_i.delete() - non_merged += 1 - deleted_entry = next(deleted_iter, None) - - if deleted_entry is not None: - while deleted_entry is not None: - # Symbol removed - deleted_entry_i = deleted_entry.data - deleted_entry_i.delete() - non_merged += 1 - deleted_entry = next(deleted_iter, None) - - root = self.editor_items[editor_id] - # tree_merge - if changed_entry is not None: - while changed_entry is not None: - # New symbol added - changed_entry_i = changed_entry.data - changed_entry_i.create_node() - non_merged += 1 - changed_entry = next(changes_iter, None) - - tree_copy = IntervalTree(tree) - tree_copy.merge_overlaps( - data_reducer=self.merge_interval, data_initializer=root) - - self.editor_tree_cache[editor_id] = tree - self.sig_tree_updated.emit() - self.sig_hide_spinner.emit() - return True - - def remove_editor(self, editor): - if editor in self.editor_ids: - if self.current_editor is editor: - self.current_editor = None - editor_id = self.editor_ids.pop(editor) - if editor_id in self.ordered_editor_ids: - self.ordered_editor_ids.remove(editor_id) - if editor_id not in list(self.editor_ids.values()): - root_item = self.editor_items.pop(editor_id) - self.editor_tree_cache.pop(editor_id) - try: - self.takeTopLevelItem( - self.indexOfTopLevelItem(root_item.node)) - except RuntimeError: - # item has already been removed - pass - - def set_editor_ids_order(self, ordered_editor_ids): - """ - Order the root file items in the Outline Explorer following the - provided list of editor ids. - """ - if self.ordered_editor_ids != ordered_editor_ids: - self.ordered_editor_ids = ordered_editor_ids - if self.sort_files_alphabetically is False: - self.__sort_toplevel_items() - - def __sort_toplevel_items(self): - """ - Sort the root file items in alphabetical order if - 'sort_files_alphabetically' is True, else order the items as - specified in the 'self.ordered_editor_ids' list. - """ - if self.show_all_files is False: - return - - current_ordered_items = [self.topLevelItem(index) for index in - range(self.topLevelItemCount())] - - # Convert list to a dictionary in order to remove duplicated entries - # when having multiple editors (splitted or in new windows). - # See spyder-ide/spyder#14646 - current_ordered_items_dict = { - item.path.lower(): item for item in current_ordered_items} - - if self.sort_files_alphabetically: - new_ordered_items = sorted( - current_ordered_items_dict.values(), - key=lambda item: osp.basename(item.path.lower())) - else: - new_ordered_items = [ - self.editor_items.get(e_id).node for e_id in - self.ordered_editor_ids if - self.editor_items.get(e_id) is not None] - - # PySide <= 5.15.0 doesn’t support == and != comparison for the data - # types inside the compared lists (see [1], [2]) - # - # [1] https://bugreports.qt.io/browse/PYSIDE-74 - # [2] https://codereview.qt-project.org/c/pyside/pyside-setup/+/312945 - update = ( - (PYSIDE2 and parse_version(PYSIDE_VERSION) <= parse_version("5.15.0")) - or (current_ordered_items != new_ordered_items) - ) - if update: - selected_items = self.selectedItems() - self.save_expanded_state() - for index in range(self.topLevelItemCount()): - self.takeTopLevelItem(0) - for index, item in enumerate(new_ordered_items): - self.insertTopLevelItem(index, item) - self.restore_expanded_state() - self.clearSelection() - if selected_items: - selected_items[-1].setSelected(True) - - def root_item_selected(self, item): - """Root item has been selected: expanding it and collapsing others""" - if self.show_all_files: - return - for root_item in self.get_top_level_items(): - if root_item is item: - self.expandItem(root_item) - else: - self.collapseItem(root_item) - - def restore(self): - """Reimplemented OneColumnTree method""" - if self.current_editor is not None: - self.collapseAll() - editor_id = self.editor_ids[self.current_editor] - self.root_item_selected(self.editor_items[editor_id].node) - - def get_root_item(self, item): - """Return the root item of the specified item.""" - root_item = item - while isinstance(root_item.parent(), QTreeWidgetItem): - root_item = root_item.parent() - return root_item - - def get_visible_items(self): - """Return a list of all visible items in the treewidget.""" - items = [] - iterator = QTreeWidgetItemIterator(self) - while iterator.value(): - item = iterator.value() - if not item.isHidden(): - if item.parent(): - if item.parent().isExpanded(): - items.append(item) - else: - items.append(item) - iterator += 1 - return items - - def activated(self, item): - """Double-click event""" - editor_root = self.editor_items.get( - self.editor_ids.get(self.current_editor)) - root_item = editor_root.node - text = '' - if isinstance(item, FileRootItem): - line = None - if id(root_item) != id(item): - root_item = item - else: - line = item.ref.position[0] + 1 - text = item.ref.name - - path = item.ref.path - self.freeze = True - if line: - self.parent().edit_goto.emit(path, line, text) - else: - self.parent().edit.emit(path) - self.freeze = False - - for editor_id, i_item in list(self.editor_items.items()): - if i_item.path == path: - for editor, _id in list(self.editor_ids.items()): - self.current_editor = editor - break - break - - def clicked(self, item): - """Click event""" - if isinstance(item, FileRootItem): - self.root_item_selected(item) - self.activated(item) - - def selection_switched(self, current_item, previous_item): - if current_item is not None: - current_ref = current_item.ref - current_ref.selected = True - if previous_item is not None: - previous_ref = previous_item.ref - previous_ref.selected = False - - def tree_item_collapsed(self, item): - ref = item.ref - ref.status = False - - def tree_item_expanded(self, item): - ref = item.ref - ref.status = True - - def set_editors_to_update(self, language, reset_info=False): - """Set editors to update per language.""" - to_update = [] - for editor in self.editor_ids.keys(): - if editor.get_language().lower() == language: - to_update.append(editor) - if reset_info: - editor.info = None - self.editors_to_update[language] = to_update - - def start_symbol_services(self, language): - """Show symbols for all `language` files.""" - # Save all languages that can send info to this pane. - self._languages.append(language) - - # Update all files associated to `language` through a timer - # that allows to wait a bit between updates. That doesn't block - # the interface at startup. - timer = QTimer(self) - timer.setSingleShot(True) - timer.setInterval(700) - timer.timeout.connect(lambda: self.update_editors(language)) - self.update_timers[language] = timer - - # Set editors that need to be updated per language - self.set_editors_to_update(language) - - # Start timer - timer.start() - - def stop_symbol_services(self, language): - """Disable LSP symbols functionality.""" - try: - self._languages.remove(language) - except ValueError: - pass - - for editor in self.editor_ids.keys(): - if editor.get_language().lower() == language: - editor.info = None +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Outline explorer widgets.""" + +# Standard library imports +import bisect +import os.path as osp +import uuid + +# Third party imports +from intervaltree import IntervalTree +from pkg_resources import parse_version +from qtpy import PYSIDE2 +from qtpy.compat import from_qvariant +from qtpy.QtCore import Qt, QTimer, Signal, Slot +from qtpy.QtWidgets import QTreeWidgetItem, QTreeWidgetItemIterator + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.config.base import _ +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.plugins.completion.api import SymbolKind, SYMBOL_KIND_ICON +from spyder.utils.qthelpers import set_item_user_text +from spyder.widgets.onecolumntree import OneColumnTree + +if PYSIDE2: + from qtpy import PYSIDE_VERSION + + +# ---- Constants +# ----------------------------------------------------------------------------- +SYMBOL_NAME_MAP = { + SymbolKind.FILE: _('File'), + SymbolKind.MODULE: _('Module'), + SymbolKind.NAMESPACE: _('Namespace'), + SymbolKind.PACKAGE: _('Package'), + SymbolKind.CLASS: _('Class'), + SymbolKind.METHOD: _('Method'), + SymbolKind.PROPERTY: _('Property'), + SymbolKind.FIELD: _('Attribute'), + SymbolKind.CONSTRUCTOR: _('constructor'), + SymbolKind.ENUM: _('Enum'), + SymbolKind.INTERFACE: _('Interface'), + SymbolKind.FUNCTION: _('Function'), + SymbolKind.VARIABLE: _('Variable'), + SymbolKind.CONSTANT: _('Constant'), + SymbolKind.STRING: _('String'), + SymbolKind.NUMBER: _('Number'), + SymbolKind.BOOLEAN: _('Boolean'), + SymbolKind.ARRAY: _('Array'), + SymbolKind.OBJECT: _('Object'), + SymbolKind.KEY: _('Key'), + SymbolKind.NULL: _('Null'), + SymbolKind.ENUM_MEMBER: _('Enum member'), + SymbolKind.STRUCT: _('Struct'), + SymbolKind.EVENT: _('Event'), + SymbolKind.OPERATOR: _('Operator'), + SymbolKind.TYPE_PARAMETER: _('Type parameter'), + SymbolKind.CELL: _('Cell'), + SymbolKind.BLOCK_COMMENT: _('Block comment') +} + + +# ---- Symbol status +# ----------------------------------------------------------------------------- +class SymbolStatus: + def __init__(self, name, kind, position, path, node=None): + self.name = name + self.position = position + self.kind = kind + self.node = node + self.path = path + self.id = str(uuid.uuid4()) + self.index = None + self.children = [] + self.status = False + self.selected = False + self.parent = None + + def delete(self): + for child in self.children: + child.parent = None + + self.children = [] + self.node.takeChildren() + + if self.parent is not None: + self.parent.remove_node(self) + self.parent = None + + if self.node.parent is not None: + self.node.parent.remove_children(self.node) + + def add_node(self, node): + if node.position == self.position: + # The nodes should be at the same level + self.parent.add_node(node) + else: + node.parent = self + node.path = self.path + this_node = self.node + children_ranges = [c.position[0] for c in self.children] + node_range = node.position[0] + new_index = bisect.bisect_left(children_ranges, node_range) + node.index = new_index + for child in self.children[new_index:]: + child.index += 1 + this_node.append_children(new_index, node.node) + self.children.insert(new_index, node) + for idx, next_idx in zip(self.children, self.children[1:]): + assert idx.index < next_idx.index + + def remove_node(self, node): + for child in self.children[node.index + 1:]: + child.index -= 1 + self.children.pop(node.index) + for idx, next_idx in zip(self.children, self.children[1:]): + assert idx.index < next_idx.index + + def clone_node(self, node): + self.id = node.id + self.index = node.index + self.path = node.path + self.children = node.children + self.status = node.status + self.selected = node.selected + self.node = node.node + self.parent = node.parent + self.node.update_info(self.name, self.kind, self.position[0] + 1, + self.status, self.selected) + self.node.ref = self + + for child in self.children: + child.parent = self + + if self.parent is not None: + self.parent.replace_node(self.index, self) + + def refresh(self): + self.node.update_info(self.name, self.kind, self.position[0] + 1, + self.status, self.selected) + + def replace_node(self, index, node): + self.children[index] = node + + def create_node(self): + self.node = SymbolItem(None, self, self.name, self.kind, + self.position[0] + 1, self.status, + self.selected) + + def __repr__(self): + return str(self) + + def __str__(self): + return '({0}, {1}, {2}, {3})'.format( + self.position, self.name, self.id, self.status) + + +# ---- Items +# ----------------------------------------------------------------------------- +class BaseTreeItem(QTreeWidgetItem): + def clear(self): + self.takeChildren() + + def append_children(self, index, node): + self.insertChild(index, node) + node.parent = self + + def remove_children(self, node): + self.removeChild(node) + node.parent = None + + +class FileRootItem(BaseTreeItem): + def __init__(self, path, ref, treewidget, is_python=True): + QTreeWidgetItem.__init__(self, treewidget, QTreeWidgetItem.Type) + self.path = path + self.ref = ref + self.setIcon( + 0, ima.icon('python') if is_python else ima.icon('TextFileIcon')) + self.setToolTip(0, path) + set_item_user_text(self, path) + + def set_path(self, path, fullpath): + self.path = path + self.set_text(fullpath) + + def set_text(self, fullpath): + self.setText(0, self.path if fullpath else osp.basename(self.path)) + + +class SymbolItem(BaseTreeItem): + """Generic symbol tree item.""" + def __init__(self, parent, ref, name, kind, position, status, selected): + QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) + self.parent = parent + self.ref = ref + self.num_children = 0 + self.update_info(name, kind, position, status, selected) + + def update_info(self, name, kind, position, status, selected): + self.setIcon(0, ima.icon(SYMBOL_KIND_ICON.get(kind, 'no_match'))) + identifier = SYMBOL_NAME_MAP.get(kind, '') + identifier = identifier.replace('_', ' ').capitalize() + self.setToolTip(0, '{3} {2}: {0} {1}'.format( + identifier, name, position, _('Line'))) + set_item_user_text(self, name) + self.setText(0, name) + self.setExpanded(status) + self.setSelected(selected) + + +class TreeItem(QTreeWidgetItem): + """Class browser item base class.""" + def __init__(self, oedata, parent, preceding): + if preceding is None: + QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) + else: + if preceding is not parent: + # Preceding must be either the same as item's parent + # or have the same parent as item + while preceding.parent() is not parent: + preceding = preceding.parent() + if preceding is None: + break + if preceding is None: + QTreeWidgetItem.__init__(self, parent, QTreeWidgetItem.Type) + else: + QTreeWidgetItem.__init__(self, parent, preceding, + QTreeWidgetItem.Type) + self.parent_item = parent + self.oedata = oedata + oedata.sig_update.connect(self.update) + self.update() + + def level(self): + """Get fold level.""" + return self.oedata.fold_level + + def get_name(self): + """Get the item name.""" + return self.oedata.def_name + + def set_icon(self, icon): + self.setIcon(0, icon) + + def setup(self): + self.setToolTip(0, _("Line %s") % str(self.line)) + + @property + def line(self): + """Get line number.""" + block_number = self.oedata.get_block_number() + if block_number is not None: + return block_number + 1 + return None + + def update(self): + """Update the tree element.""" + name = self.get_name() + self.setText(0, name) + parent_text = from_qvariant(self.parent_item.data(0, Qt.UserRole), + to_text_string) + set_item_user_text(self, parent_text + '/' + name) + self.setup() + + +# ---- Treewidget +# ----------------------------------------------------------------------------- +class OutlineExplorerTreeWidget(OneColumnTree): + # Used only for debug purposes + sig_tree_updated = Signal() + sig_display_spinner = Signal() + sig_hide_spinner = Signal() + sig_update_configuration = Signal() + + CONF_SECTION = 'outline_explorer' + + def __init__(self, parent): + if hasattr(parent, 'CONTEXT_NAME'): + self.CONTEXT_NAME = parent.CONTEXT_NAME + + self.show_fullpath = self.get_conf('show_fullpath') + self.show_all_files = self.get_conf('show_all_files') + self.group_cells = self.get_conf('group_cells') + self.show_comments = self.get_conf('show_comments') + self.sort_files_alphabetically = self.get_conf( + 'sort_files_alphabetically') + self.follow_cursor = self.get_conf('follow_cursor') + self.display_variables = self.get_conf('display_variables') + + super().__init__(parent) + + self.freeze = False # Freezing widget to avoid any unwanted update + self.editor_items = {} + self.editor_tree_cache = {} + self.editor_ids = {} + self.update_timers = {} + self.editors_to_update = {} + self.ordered_editor_ids = [] + self._current_editor = None + self._languages = [] + self.is_visible = True + + self.currentItemChanged.connect(self.selection_switched) + self.itemExpanded.connect(self.tree_item_expanded) + self.itemCollapsed.connect(self.tree_item_collapsed) + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------ + @property + def current_editor(self): + """Get current editor.""" + return self._current_editor + + @current_editor.setter + def current_editor(self, value): + """Set current editor and connect the necessary signals.""" + if self._current_editor == value: + return + # Disconnect previous editor + self.connect_current_editor(False) + self._current_editor = value + # Connect new editor + self.connect_current_editor(True) + + def __hide_or_show_root_items(self, item): + """ + show_all_files option is disabled: hide all root items except *item* + show_all_files option is enabled: do nothing + """ + for _it in self.get_top_level_items(): + _it.setHidden(_it is not item and not self.show_all_files) + + @on_conf_change(option='show_fullpath') + def toggle_fullpath_mode(self, state): + self.show_fullpath = state + self.setTextElideMode(Qt.ElideMiddle if state else Qt.ElideRight) + for index in range(self.topLevelItemCount()): + self.topLevelItem(index).set_text(fullpath=self.show_fullpath) + + @on_conf_change(option='show_all_files') + def toggle_show_all_files(self, state): + self.show_all_files = state + if self.current_editor is not None: + editor_id = self.editor_ids[self.current_editor] + item = self.editor_items[editor_id].node + self.__hide_or_show_root_items(item) + self.__sort_toplevel_items() + if self.show_all_files is False: + self.root_item_selected( + self.editor_items[self.editor_ids[self.current_editor]]) + self.do_follow_cursor() + + @on_conf_change(option='show_comments') + def toggle_show_comments(self, state): + self.show_comments = state + self.sig_update_configuration.emit() + self.update_editors(language='python') + + @on_conf_change(option='group_cells') + def toggle_group_cells(self, state): + self.group_cells = state + self.sig_update_configuration.emit() + self.update_editors(language='python') + + @on_conf_change(option='display_variables') + def toggle_variables(self, state): + self.display_variables = state + for editor in self.editor_ids.keys(): + self.update_editor(editor.info, editor) + + @on_conf_change(option='sort_files_alphabetically') + def toggle_sort_files_alphabetically(self, state): + self.sort_files_alphabetically = state + self.__sort_toplevel_items() + + @on_conf_change(option='follow_cursor') + def toggle_follow_cursor(self, state): + """Follow the cursor.""" + self.follow_cursor = state + self.do_follow_cursor() + + @Slot() + def do_follow_cursor(self): + """Go to cursor position.""" + if self.follow_cursor: + self.go_to_cursor_position() + + @Slot() + def go_to_cursor_position(self): + if self.current_editor is not None: + editor_id = self.editor_ids[self.current_editor] + line = self.current_editor.get_cursor_line_number() + tree = self.editor_tree_cache[editor_id] + root = self.editor_items[editor_id] + overlap = tree[line - 1] + if len(overlap) == 0: + item = root.node + self.setCurrentItem(item) + self.scrollToItem(item) + self.expandItem(item) + else: + sorted_nodes = sorted(overlap) + # The last item of the sorted elements correspond to the + # current node if expanding, otherwise it is the first stopper + # found + idx = -1 + self.switch_to_node(sorted_nodes, idx) + + def switch_to_node(self, sorted_nodes, idx): + """Given a set of tree nodes, highlight the node on index `idx`.""" + item_interval = sorted_nodes[idx] + item_ref = item_interval.data + item = item_ref.node + self.setCurrentItem(item) + self.scrollToItem(item) + self.expandItem(item) + + def connect_current_editor(self, state): + """Connect or disconnect the editor from signals.""" + editor = self.current_editor + if editor is None: + return + + # Connect syntax highlighter + sig_update = editor.sig_outline_explorer_data_changed + sig_move = editor.sig_cursor_position_changed + sig_display_spinner = editor.sig_start_outline_spinner + if state: + sig_update.connect(self.update_editor) + sig_move.connect(self.do_follow_cursor) + sig_display_spinner.connect(self.sig_display_spinner) + self.do_follow_cursor() + else: + try: + sig_update.disconnect(self.update_editor) + sig_move.disconnect(self.do_follow_cursor) + sig_display_spinner.disconnect(self.sig_display_spinner) + except TypeError: + # This catches an error while performing + # teardown in one of our tests. + pass + + def clear(self): + """Reimplemented Qt method""" + self.set_title('') + OneColumnTree.clear(self) + + def set_current_editor(self, editor, update): + """Bind editor instance""" + editor_id = editor.get_id() + + # Don't fail if editor doesn't exist anymore. This + # happens when switching projects. + try: + item = self.editor_items[editor_id].node + except KeyError: + return + + if not self.freeze: + self.scrollToItem(item) + self.root_item_selected(item) + self.__hide_or_show_root_items(item) + if update: + self.save_expanded_state() + self.restore_expanded_state() + + self.current_editor = editor + + # Update tree with currently stored info or require symbols if + # necessary. + if (editor.get_language().lower() in self._languages and + len(self.editor_tree_cache[editor_id]) == 0): + if editor.info is not None: + self.update_editor(editor.info) + elif editor.is_cloned: + editor.request_symbols() + + def register_editor(self, editor): + """ + Register editor attributes and create basic objects associated + to it. + """ + editor_id = editor.get_id() + self.editor_ids[editor] = editor_id + self.ordered_editor_ids.append(editor_id) + + this_root = SymbolStatus(editor.fname, None, None, editor.fname) + self.editor_items[editor_id] = this_root + + root_item = FileRootItem(editor.fname, this_root, + self, editor.is_python()) + this_root.node = root_item + root_item.set_text(fullpath=self.show_fullpath) + self.resizeColumnToContents(0) + if not self.show_all_files: + root_item.setHidden(True) + + editor_tree = IntervalTree() + self.editor_tree_cache[editor_id] = editor_tree + + self.__sort_toplevel_items() + + def file_renamed(self, editor, new_filename): + """File was renamed, updating outline explorer tree""" + if editor is None: + # This is needed when we can't find an editor to attach + # the outline explorer to. + # Fix spyder-ide/spyder#8813. + return + editor_id = editor.get_id() + if editor_id in list(self.editor_ids.values()): + root_item = self.editor_items[editor_id].node + root_item.set_path(new_filename, fullpath=self.show_fullpath) + self.__sort_toplevel_items() + + def update_editors(self, language): + """ + Update all editors for a given language sequentially. + + This is done through a timer to avoid lags in the interface. + """ + if self.editors_to_update.get(language): + editor = self.editors_to_update[language][0] + if editor.info is not None: + # Editor could be not there anymore after switching + # projects + try: + self.update_editor(editor.info, editor) + except KeyError: + pass + self.editors_to_update[language].remove(editor) + self.update_timers[language].start() + + def update_all_editors(self, reset_info=False): + """Update all editors with LSP support.""" + for language in self._languages: + self.set_editors_to_update(language, reset_info=reset_info) + self.update_timers[language].start() + + @Slot(list) + def update_editor(self, items, editor=None): + """ + Update the outline explorer for `editor` preserving the tree + state. + """ + if items is None: + return + + # Only perform an update if the widget is visible. + if not self.is_visible: + self.sig_hide_spinner.emit() + return + + if editor is None: + editor = self.current_editor + editor_id = editor.get_id() + language = editor.get_language() + + update = self.update_tree(items, editor_id, language) + + if update: + self.save_expanded_state() + self.restore_expanded_state() + self.do_follow_cursor() + + def merge_interval(self, parent, node): + """Add node into an existing tree structure.""" + match = False + start, end = node.position + while parent.parent is not None and not match: + parent_start, parent_end = parent.position + if parent_end <= start: + parent = parent.parent + else: + match = True + + if node.parent is not None: + node.parent.remove_node(node) + node.parent = None + if node.node.parent is not None: + node.node.parent.remove_children(node.node) + + parent.add_node(node) + node.refresh() + return node + + def update_tree(self, items, editor_id, language): + """Update tree with new items that come from the LSP.""" + current_tree = self.editor_tree_cache[editor_id] + tree_info = [] + for symbol in items: + symbol_name = symbol['name'] + symbol_kind = symbol['kind'] + if language.lower() == 'python': + if symbol_kind == SymbolKind.MODULE: + continue + if (symbol_kind == SymbolKind.VARIABLE and + not self.display_variables): + continue + if (symbol_kind == SymbolKind.FIELD and + not self.display_variables): + continue + # NOTE: This could be also a DocumentSymbol + symbol_range = symbol['location']['range'] + symbol_start = symbol_range['start']['line'] + symbol_end = symbol_range['end']['line'] + symbol_repr = SymbolStatus(symbol_name, symbol_kind, + (symbol_start, symbol_end), None) + tree_info.append((symbol_start, symbol_end + 1, symbol_repr)) + + tree = IntervalTree.from_tuples(tree_info) + changes = tree - current_tree + deleted = current_tree - tree + + if len(changes) == 0 and len(deleted) == 0: + self.sig_hide_spinner.emit() + return False + + adding_symbols = len(changes) > len(deleted) + deleted_iter = iter(sorted(deleted)) + changes_iter = iter(sorted(changes)) + + deleted_entry = next(deleted_iter, None) + changed_entry = next(changes_iter, None) + non_merged = 0 + + while deleted_entry is not None and changed_entry is not None: + deleted_entry_i = deleted_entry.data + changed_entry_i = changed_entry.data + + if deleted_entry_i.name == changed_entry_i.name: + # Copy symbol status + changed_entry_i.clone_node(deleted_entry_i) + deleted_entry = next(deleted_iter, None) + changed_entry = next(changes_iter, None) + else: + if adding_symbols: + # New symbol added + changed_entry_i.create_node() + non_merged += 1 + changed_entry = next(changes_iter, None) + else: + # Symbol removed + deleted_entry_i.delete() + non_merged += 1 + deleted_entry = next(deleted_iter, None) + + if deleted_entry is not None: + while deleted_entry is not None: + # Symbol removed + deleted_entry_i = deleted_entry.data + deleted_entry_i.delete() + non_merged += 1 + deleted_entry = next(deleted_iter, None) + + root = self.editor_items[editor_id] + # tree_merge + if changed_entry is not None: + while changed_entry is not None: + # New symbol added + changed_entry_i = changed_entry.data + changed_entry_i.create_node() + non_merged += 1 + changed_entry = next(changes_iter, None) + + tree_copy = IntervalTree(tree) + tree_copy.merge_overlaps( + data_reducer=self.merge_interval, data_initializer=root) + + self.editor_tree_cache[editor_id] = tree + self.sig_tree_updated.emit() + self.sig_hide_spinner.emit() + return True + + def remove_editor(self, editor): + if editor in self.editor_ids: + if self.current_editor is editor: + self.current_editor = None + editor_id = self.editor_ids.pop(editor) + if editor_id in self.ordered_editor_ids: + self.ordered_editor_ids.remove(editor_id) + if editor_id not in list(self.editor_ids.values()): + root_item = self.editor_items.pop(editor_id) + self.editor_tree_cache.pop(editor_id) + try: + self.takeTopLevelItem( + self.indexOfTopLevelItem(root_item.node)) + except RuntimeError: + # item has already been removed + pass + + def set_editor_ids_order(self, ordered_editor_ids): + """ + Order the root file items in the Outline Explorer following the + provided list of editor ids. + """ + if self.ordered_editor_ids != ordered_editor_ids: + self.ordered_editor_ids = ordered_editor_ids + if self.sort_files_alphabetically is False: + self.__sort_toplevel_items() + + def __sort_toplevel_items(self): + """ + Sort the root file items in alphabetical order if + 'sort_files_alphabetically' is True, else order the items as + specified in the 'self.ordered_editor_ids' list. + """ + if self.show_all_files is False: + return + + current_ordered_items = [self.topLevelItem(index) for index in + range(self.topLevelItemCount())] + + # Convert list to a dictionary in order to remove duplicated entries + # when having multiple editors (splitted or in new windows). + # See spyder-ide/spyder#14646 + current_ordered_items_dict = { + item.path.lower(): item for item in current_ordered_items} + + if self.sort_files_alphabetically: + new_ordered_items = sorted( + current_ordered_items_dict.values(), + key=lambda item: osp.basename(item.path.lower())) + else: + new_ordered_items = [ + self.editor_items.get(e_id).node for e_id in + self.ordered_editor_ids if + self.editor_items.get(e_id) is not None] + + # PySide <= 5.15.0 doesn’t support == and != comparison for the data + # types inside the compared lists (see [1], [2]) + # + # [1] https://bugreports.qt.io/browse/PYSIDE-74 + # [2] https://codereview.qt-project.org/c/pyside/pyside-setup/+/312945 + update = ( + (PYSIDE2 and parse_version(PYSIDE_VERSION) <= parse_version("5.15.0")) + or (current_ordered_items != new_ordered_items) + ) + if update: + selected_items = self.selectedItems() + self.save_expanded_state() + for index in range(self.topLevelItemCount()): + self.takeTopLevelItem(0) + for index, item in enumerate(new_ordered_items): + self.insertTopLevelItem(index, item) + self.restore_expanded_state() + self.clearSelection() + if selected_items: + selected_items[-1].setSelected(True) + + def root_item_selected(self, item): + """Root item has been selected: expanding it and collapsing others""" + if self.show_all_files: + return + for root_item in self.get_top_level_items(): + if root_item is item: + self.expandItem(root_item) + else: + self.collapseItem(root_item) + + def restore(self): + """Reimplemented OneColumnTree method""" + if self.current_editor is not None: + self.collapseAll() + editor_id = self.editor_ids[self.current_editor] + self.root_item_selected(self.editor_items[editor_id].node) + + def get_root_item(self, item): + """Return the root item of the specified item.""" + root_item = item + while isinstance(root_item.parent(), QTreeWidgetItem): + root_item = root_item.parent() + return root_item + + def get_visible_items(self): + """Return a list of all visible items in the treewidget.""" + items = [] + iterator = QTreeWidgetItemIterator(self) + while iterator.value(): + item = iterator.value() + if not item.isHidden(): + if item.parent(): + if item.parent().isExpanded(): + items.append(item) + else: + items.append(item) + iterator += 1 + return items + + def activated(self, item): + """Double-click event""" + editor_root = self.editor_items.get( + self.editor_ids.get(self.current_editor)) + root_item = editor_root.node + text = '' + if isinstance(item, FileRootItem): + line = None + if id(root_item) != id(item): + root_item = item + else: + line = item.ref.position[0] + 1 + text = item.ref.name + + path = item.ref.path + self.freeze = True + if line: + self.parent().edit_goto.emit(path, line, text) + else: + self.parent().edit.emit(path) + self.freeze = False + + for editor_id, i_item in list(self.editor_items.items()): + if i_item.path == path: + for editor, _id in list(self.editor_ids.items()): + self.current_editor = editor + break + break + + def clicked(self, item): + """Click event""" + if isinstance(item, FileRootItem): + self.root_item_selected(item) + self.activated(item) + + def selection_switched(self, current_item, previous_item): + if current_item is not None: + current_ref = current_item.ref + current_ref.selected = True + if previous_item is not None: + previous_ref = previous_item.ref + previous_ref.selected = False + + def tree_item_collapsed(self, item): + ref = item.ref + ref.status = False + + def tree_item_expanded(self, item): + ref = item.ref + ref.status = True + + def set_editors_to_update(self, language, reset_info=False): + """Set editors to update per language.""" + to_update = [] + for editor in self.editor_ids.keys(): + if editor.get_language().lower() == language: + to_update.append(editor) + if reset_info: + editor.info = None + self.editors_to_update[language] = to_update + + def start_symbol_services(self, language): + """Show symbols for all `language` files.""" + # Save all languages that can send info to this pane. + self._languages.append(language) + + # Update all files associated to `language` through a timer + # that allows to wait a bit between updates. That doesn't block + # the interface at startup. + timer = QTimer(self) + timer.setSingleShot(True) + timer.setInterval(700) + timer.timeout.connect(lambda: self.update_editors(language)) + self.update_timers[language] = timer + + # Set editors that need to be updated per language + self.set_editors_to_update(language) + + # Start timer + timer.start() + + def stop_symbol_services(self, language): + """Disable LSP symbols functionality.""" + try: + self._languages.remove(language) + except ValueError: + pass + + for editor in self.editor_ids.keys(): + if editor.get_language().lower() == language: + editor.info = None diff --git a/spyder/plugins/preferences/api.py b/spyder/plugins/preferences/api.py index b15c3c5444a..d9886f9006c 100644 --- a/spyder/plugins/preferences/api.py +++ b/spyder/plugins/preferences/api.py @@ -1,892 +1,892 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Preferences plugin public facing API -""" - -# Standard library imports -import ast -import os.path as osp - -# Third party imports -from qtpy import API -from qtpy.compat import (getexistingdirectory, getopenfilename, from_qvariant, - to_qvariant) -from qtpy.QtCore import Qt, Signal, Slot, QRegExp -from qtpy.QtGui import QColor, QRegExpValidator, QTextOption -from qtpy.QtWidgets import (QButtonGroup, QCheckBox, QComboBox, QDoubleSpinBox, - QFileDialog, QFontComboBox, QGridLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, QMessageBox, - QPlainTextEdit, QPushButton, QRadioButton, - QSpinBox, QTabWidget, QVBoxLayout, QWidget) - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.config.user import NoDefault -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.widgets.colors import ColorLayout -from spyder.widgets.comboboxes import FileComboBox - - -class BaseConfigTab(QWidget): - """Stub class to declare a config tab.""" - pass - - -class ConfigAccessMixin(object): - """Namespace for methods that access config storage""" - CONF_SECTION = None - - def set_option(self, option, value, section=None, - recursive_notification=False): - section = self.CONF_SECTION if section is None else section - CONF.set(section, option, value, - recursive_notification=recursive_notification) - - def get_option(self, option, default=NoDefault, section=None): - section = self.CONF_SECTION if section is None else section - return CONF.get(section, option, default) - - def remove_option(self, option, section=None): - section = self.CONF_SECTION if section is None else section - CONF.remove_option(section, option) - - -class ConfigPage(QWidget): - """Base class for configuration page in Preferences""" - - # Signals - apply_button_enabled = Signal(bool) - show_this_page = Signal() - - def __init__(self, parent, apply_callback=None): - QWidget.__init__(self, parent) - self.apply_callback = apply_callback - self.is_modified = False - - def initialize(self): - """ - Initialize configuration page: - * setup GUI widgets - * load settings and change widgets accordingly - """ - self.setup_page() - self.load_from_conf() - - def get_name(self): - """Return configuration page name""" - raise NotImplementedError - - def get_icon(self): - """Return configuration page icon (24x24)""" - raise NotImplementedError - - def setup_page(self): - """Setup configuration page widget""" - raise NotImplementedError - - def set_modified(self, state): - self.is_modified = state - self.apply_button_enabled.emit(state) - - def is_valid(self): - """Return True if all widget contents are valid""" - raise NotImplementedError - - def apply_changes(self): - """Apply changes callback""" - if self.is_modified: - self.save_to_conf() - if self.apply_callback is not None: - self.apply_callback() - - # Since the language cannot be retrieved by CONF and the language - # is needed before loading CONF, this is an extra method needed to - # ensure that when changes are applied, they are copied to a - # specific file storing the language value. This only applies to - # the main section config. - if self.CONF_SECTION == u'main': - self._save_lang() - - for restart_option in self.restart_options: - if restart_option in self.changed_options: - self.prompt_restart_required() - break # Ensure a single popup is displayed - self.set_modified(False) - - def load_from_conf(self): - """Load settings from configuration file""" - raise NotImplementedError - - def save_to_conf(self): - """Save settings to configuration file""" - raise NotImplementedError - - -class SpyderConfigPage(ConfigPage, ConfigAccessMixin): - """Plugin configuration dialog box page widget""" - CONF_SECTION = None - - def __init__(self, parent): - ConfigPage.__init__(self, parent, - apply_callback=lambda: - self._apply_settings_tabs(self.changed_options)) - self.checkboxes = {} - self.radiobuttons = {} - self.lineedits = {} - self.textedits = {} - self.validate_data = {} - self.spinboxes = {} - self.comboboxes = {} - self.fontboxes = {} - self.coloredits = {} - self.scedits = {} - self.cross_section_options = {} - self.changed_options = set() - self.restart_options = dict() # Dict to store name and localized text - self.default_button_group = None - self.main = parent.main - self.tabs = None - - def _apply_settings_tabs(self, options): - if self.tabs is not None: - for i in range(self.tabs.count()): - tab = self.tabs.widget(i) - layout = tab.layout() - for i in range(layout.count()): - widget = layout.itemAt(i).widget() - if hasattr(widget, 'apply_settings'): - if issubclass(type(widget), BaseConfigTab): - options |= widget.apply_settings() - self.apply_settings(options) - - def apply_settings(self, options): - raise NotImplementedError - - def check_settings(self): - """This method is called to check settings after configuration - dialog has been shown""" - pass - - def set_modified(self, state): - ConfigPage.set_modified(self, state) - if not state: - self.changed_options = set() - - def is_valid(self): - """Return True if all widget contents are valid""" - status = True - for lineedit in self.lineedits: - if lineedit in self.validate_data and lineedit.isEnabled(): - validator, invalid_msg = self.validate_data[lineedit] - text = to_text_string(lineedit.text()) - if not validator(text): - QMessageBox.critical(self, self.get_name(), - f"{invalid_msg}:
{text}", - QMessageBox.Ok) - return False - - if self.tabs is not None and status: - for i in range(self.tabs.count()): - tab = self.tabs.widget(i) - layout = tab.layout() - for i in range(layout.count()): - widget = layout.itemAt(i).widget() - if issubclass(type(widget), BaseConfigTab): - status &= widget.is_valid() - if not status: - return status - return status - - def load_from_conf(self): - """Load settings from configuration file.""" - for checkbox, (sec, option, default) in list(self.checkboxes.items()): - checkbox.setChecked(self.get_option(option, default, section=sec)) - checkbox.clicked[bool].connect(lambda _, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if checkbox.restart_required: - if sec is None: - self.restart_options[option] = checkbox.text() - else: - self.restart_options[(sec, option)] = checkbox.text() - for radiobutton, (sec, option, default) in list( - self.radiobuttons.items()): - radiobutton.setChecked(self.get_option(option, default, - section=sec)) - radiobutton.toggled.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if radiobutton.restart_required: - if sec is None: - self.restart_options[option] = radiobutton.label_text - else: - self.restart_options[(sec, option)] = radiobutton.label_text - for lineedit, (sec, option, default) in list(self.lineedits.items()): - data = self.get_option(option, default, section=sec) - if getattr(lineedit, 'content_type', None) == list: - data = ', '.join(data) - lineedit.setText(data) - lineedit.textChanged.connect(lambda _, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if lineedit.restart_required: - if sec is None: - self.restart_options[option] = lineedit.label_text - else: - self.restart_options[(sec, option)] = lineedit.label_text - for textedit, (sec, option, default) in list(self.textedits.items()): - data = self.get_option(option, default, section=sec) - if getattr(textedit, 'content_type', None) == list: - data = ', '.join(data) - elif getattr(textedit, 'content_type', None) == dict: - data = to_text_string(data) - textedit.setPlainText(data) - textedit.textChanged.connect(lambda opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if textedit.restart_required: - if sec is None: - self.restart_options[option] = textedit.label_text - else: - self.restart_options[(sec, option)] = textedit.label_text - for spinbox, (sec, option, default) in list(self.spinboxes.items()): - spinbox.setValue(self.get_option(option, default, section=sec)) - spinbox.valueChanged.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - for combobox, (sec, option, default) in list(self.comboboxes.items()): - value = self.get_option(option, default, section=sec) - for index in range(combobox.count()): - data = from_qvariant(combobox.itemData(index), to_text_string) - # For PyQt API v2, it is necessary to convert `data` to - # unicode in case the original type was not a string, like an - # integer for example (see qtpy.compat.from_qvariant): - if to_text_string(data) == to_text_string(value): - break - else: - if combobox.count() == 0: - index = None - if index: - combobox.setCurrentIndex(index) - combobox.currentIndexChanged.connect( - lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if combobox.restart_required: - if sec is None: - self.restart_options[option] = combobox.label_text - else: - self.restart_options[(sec, option)] = combobox.label_text - - for (fontbox, sizebox), option in list(self.fontboxes.items()): - rich_font = True if "rich" in option.lower() else False - font = self.get_font(rich_font) - fontbox.setCurrentFont(font) - sizebox.setValue(font.pointSize()) - if option is None: - property = 'plugin_font' - else: - property = option - fontbox.currentIndexChanged.connect(lambda _foo, opt=property: - self.has_been_modified( - self.CONF_SECTION, opt)) - sizebox.valueChanged.connect(lambda _foo, opt=property: - self.has_been_modified( - self.CONF_SECTION, opt)) - for clayout, (sec, option, default) in list(self.coloredits.items()): - property = to_qvariant(option) - edit = clayout.lineedit - btn = clayout.colorbtn - edit.setText(self.get_option(option, default, section=sec)) - # QAbstractButton works differently for PySide and PyQt - if not API == 'pyside': - btn.clicked.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - else: - btn.clicked.connect(lambda opt=option, sect=sec: - self.has_been_modified(sect, opt)) - edit.textChanged.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - for (clayout, cb_bold, cb_italic - ), (sec, option, default) in list(self.scedits.items()): - edit = clayout.lineedit - btn = clayout.colorbtn - options = self.get_option(option, default, section=sec) - if options: - color, bold, italic = options - edit.setText(color) - cb_bold.setChecked(bold) - cb_italic.setChecked(italic) - - edit.textChanged.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - btn.clicked[bool].connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - cb_bold.clicked[bool].connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - cb_italic.clicked[bool].connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - - def save_to_conf(self): - """Save settings to configuration file""" - for checkbox, (sec, option, _default) in list( - self.checkboxes.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - value = checkbox.isChecked() - self.set_option(option, value, section=sec, - recursive_notification=False) - for radiobutton, (sec, option, _default) in list( - self.radiobuttons.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - self.set_option(option, radiobutton.isChecked(), section=sec, - recursive_notification=False) - for lineedit, (sec, option, _default) in list(self.lineedits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - data = lineedit.text() - content_type = getattr(lineedit, 'content_type', None) - if content_type == list: - data = [item.strip() for item in data.split(',')] - else: - data = to_text_string(data) - self.set_option(option, data, section=sec, - recursive_notification=False) - for textedit, (sec, option, _default) in list(self.textedits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - data = textedit.toPlainText() - content_type = getattr(textedit, 'content_type', None) - if content_type == dict: - if data: - data = ast.literal_eval(data) - else: - data = textedit.content_type() - elif content_type in (tuple, list): - data = [item.strip() for item in data.split(',')] - else: - data = to_text_string(data) - self.set_option(option, data, section=sec, - recursive_notification=False) - for spinbox, (sec, option, _default) in list(self.spinboxes.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - self.set_option(option, spinbox.value(), section=sec, - recursive_notification=False) - for combobox, (sec, option, _default) in list(self.comboboxes.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - data = combobox.itemData(combobox.currentIndex()) - self.set_option(option, from_qvariant(data, to_text_string), - section=sec, recursive_notification=False) - for (fontbox, sizebox), option in list(self.fontboxes.items()): - if (self.CONF_SECTION, option) in self.changed_options: - font = fontbox.currentFont() - font.setPointSize(sizebox.value()) - self.set_font(font, option) - for clayout, (sec, option, _default) in list(self.coloredits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - self.set_option(option, - to_text_string(clayout.lineedit.text()), - section=sec, recursive_notification=False) - for (clayout, cb_bold, cb_italic), (sec, option, _default) in list( - self.scedits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - color = to_text_string(clayout.lineedit.text()) - bold = cb_bold.isChecked() - italic = cb_italic.isChecked() - self.set_option(option, (color, bold, italic), section=sec, - recursive_notification=False) - - @Slot(str) - def has_been_modified(self, section, option): - self.set_modified(True) - if section is None: - self.changed_options.add(option) - else: - self.changed_options.add((section, option)) - - def create_checkbox(self, text, option, default=NoDefault, - tip=None, msg_warning=None, msg_info=None, - msg_if_enabled=False, section=None, restart=False): - checkbox = QCheckBox(text) - self.checkboxes[checkbox] = (section, option, default) - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - if tip is not None: - checkbox.setToolTip(tip) - if msg_warning is not None or msg_info is not None: - def show_message(is_checked=False): - if is_checked or not msg_if_enabled: - if msg_warning is not None: - QMessageBox.warning(self, self.get_name(), - msg_warning, QMessageBox.Ok) - if msg_info is not None: - QMessageBox.information(self, self.get_name(), - msg_info, QMessageBox.Ok) - checkbox.clicked.connect(show_message) - checkbox.restart_required = restart - return checkbox - - def create_radiobutton(self, text, option, default=NoDefault, - tip=None, msg_warning=None, msg_info=None, - msg_if_enabled=False, button_group=None, - restart=False, section=None): - radiobutton = QRadioButton(text) - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - if button_group is None: - if self.default_button_group is None: - self.default_button_group = QButtonGroup(self) - button_group = self.default_button_group - button_group.addButton(radiobutton) - if tip is not None: - radiobutton.setToolTip(tip) - self.radiobuttons[radiobutton] = (section, option, default) - if msg_warning is not None or msg_info is not None: - def show_message(is_checked): - if is_checked or not msg_if_enabled: - if msg_warning is not None: - QMessageBox.warning(self, self.get_name(), - msg_warning, QMessageBox.Ok) - if msg_info is not None: - QMessageBox.information(self, self.get_name(), - msg_info, QMessageBox.Ok) - radiobutton.toggled.connect(show_message) - radiobutton.restart_required = restart - radiobutton.label_text = text - return radiobutton - - def create_lineedit(self, text, option, default=NoDefault, - tip=None, alignment=Qt.Vertical, regex=None, - restart=False, word_wrap=True, placeholder=None, - content_type=None, section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - label.setWordWrap(word_wrap) - edit = QLineEdit() - edit.content_type = content_type - layout = QVBoxLayout() if alignment == Qt.Vertical else QHBoxLayout() - layout.addWidget(label) - layout.addWidget(edit) - layout.setContentsMargins(0, 0, 0, 0) - if tip: - edit.setToolTip(tip) - if regex: - edit.setValidator(QRegExpValidator(QRegExp(regex))) - if placeholder: - edit.setPlaceholderText(placeholder) - self.lineedits[edit] = (section, option, default) - widget = QWidget(self) - widget.label = label - widget.textbox = edit - widget.setLayout(layout) - edit.restart_required = restart - edit.label_text = text - return widget - - def create_textedit(self, text, option, default=NoDefault, - tip=None, restart=False, content_type=None, - section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - label.setWordWrap(True) - edit = QPlainTextEdit() - edit.content_type = content_type - edit.setWordWrapMode(QTextOption.WordWrap) - layout = QVBoxLayout() - layout.addWidget(label) - layout.addWidget(edit) - layout.setContentsMargins(0, 0, 0, 0) - if tip: - edit.setToolTip(tip) - self.textedits[edit] = (section, option, default) - widget = QWidget(self) - widget.label = label - widget.textbox = edit - widget.setLayout(layout) - edit.restart_required = restart - edit.label_text = text - return widget - - def create_browsedir(self, text, option, default=NoDefault, tip=None, - section=None): - widget = self.create_lineedit(text, option, default, section=section, - alignment=Qt.Horizontal) - for edit in self.lineedits: - if widget.isAncestorOf(edit): - break - msg = _("Invalid directory path") - self.validate_data[edit] = (osp.isdir, msg) - browse_btn = QPushButton(ima.icon('DirOpenIcon'), '', self) - browse_btn.setToolTip(_("Select directory")) - browse_btn.clicked.connect(lambda: self.select_directory(edit)) - layout = QHBoxLayout() - layout.addWidget(widget) - layout.addWidget(browse_btn) - layout.setContentsMargins(0, 0, 0, 0) - browsedir = QWidget(self) - browsedir.setLayout(layout) - return browsedir - - def select_directory(self, edit): - """Select directory""" - basedir = to_text_string(edit.text()) - if not osp.isdir(basedir): - basedir = getcwd_or_home() - title = _("Select directory") - directory = getexistingdirectory(self, title, basedir) - if directory: - edit.setText(directory) - - def create_browsefile(self, text, option, default=NoDefault, tip=None, - filters=None, section=None): - widget = self.create_lineedit(text, option, default, section=section, - alignment=Qt.Horizontal) - for edit in self.lineedits: - if widget.isAncestorOf(edit): - break - msg = _('Invalid file path') - self.validate_data[edit] = (osp.isfile, msg) - browse_btn = QPushButton(ima.icon('FileIcon'), '', self) - browse_btn.setToolTip(_("Select file")) - browse_btn.clicked.connect(lambda: self.select_file(edit, filters)) - layout = QHBoxLayout() - layout.addWidget(widget) - layout.addWidget(browse_btn) - layout.setContentsMargins(0, 0, 0, 0) - browsedir = QWidget(self) - browsedir.setLayout(layout) - return browsedir - - def select_file(self, edit, filters=None, **kwargs): - """Select File""" - basedir = osp.dirname(to_text_string(edit.text())) - if not osp.isdir(basedir): - basedir = getcwd_or_home() - if filters is None: - filters = _("All files (*)") - title = _("Select file") - filename, _selfilter = getopenfilename(self, title, basedir, filters, - **kwargs) - if filename: - edit.setText(filename) - - def create_spinbox(self, prefix, suffix, option, default=NoDefault, - min_=None, max_=None, step=None, tip=None, - section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - widget = QWidget(self) - if prefix: - plabel = QLabel(prefix) - widget.plabel = plabel - else: - plabel = None - if suffix: - slabel = QLabel(suffix) - widget.slabel = slabel - else: - slabel = None - if step is not None: - if type(step) is int: - spinbox = QSpinBox() - else: - spinbox = QDoubleSpinBox() - spinbox.setDecimals(1) - spinbox.setSingleStep(step) - else: - spinbox = QSpinBox() - if min_ is not None: - spinbox.setMinimum(min_) - if max_ is not None: - spinbox.setMaximum(max_) - if tip is not None: - spinbox.setToolTip(tip) - self.spinboxes[spinbox] = (section, option, default) - layout = QHBoxLayout() - for subwidget in (plabel, spinbox, slabel): - if subwidget is not None: - layout.addWidget(subwidget) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - widget.spinbox = spinbox - widget.setLayout(layout) - return widget - - def create_coloredit(self, text, option, default=NoDefault, tip=None, - without_layout=False, section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - clayout = ColorLayout(QColor(Qt.black), self) - clayout.lineedit.setMaximumWidth(80) - if tip is not None: - clayout.setToolTip(tip) - self.coloredits[clayout] = (section, option, default) - if without_layout: - return label, clayout - layout = QHBoxLayout() - layout.addWidget(label) - layout.addLayout(clayout) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - widget = QWidget(self) - widget.setLayout(layout) - return widget - - def create_scedit(self, text, option, default=NoDefault, tip=None, - without_layout=False, section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - clayout = ColorLayout(QColor(Qt.black), self) - clayout.lineedit.setMaximumWidth(80) - if tip is not None: - clayout.setToolTip(tip) - cb_bold = QCheckBox() - cb_bold.setIcon(ima.icon('bold')) - cb_bold.setToolTip(_("Bold")) - cb_italic = QCheckBox() - cb_italic.setIcon(ima.icon('italic')) - cb_italic.setToolTip(_("Italic")) - self.scedits[(clayout, cb_bold, cb_italic)] = (section, option, - default) - if without_layout: - return label, clayout, cb_bold, cb_italic - layout = QHBoxLayout() - layout.addWidget(label) - layout.addLayout(clayout) - layout.addSpacing(10) - layout.addWidget(cb_bold) - layout.addWidget(cb_italic) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - widget = QWidget(self) - widget.setLayout(layout) - return widget - - def create_combobox(self, text, choices, option, default=NoDefault, - tip=None, restart=False, section=None): - """choices: couples (name, key)""" - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - combobox = QComboBox() - if tip is not None: - combobox.setToolTip(tip) - for name, key in choices: - if not (name is None and key is None): - combobox.addItem(name, to_qvariant(key)) - # Insert separators - count = 0 - for index, item in enumerate(choices): - name, key = item - if name is None and key is None: - combobox.insertSeparator(index + count) - count += 1 - self.comboboxes[combobox] = (section, option, default) - layout = QHBoxLayout() - layout.addWidget(label) - layout.addWidget(combobox) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - widget = QWidget(self) - widget.label = label - widget.combobox = combobox - widget.setLayout(layout) - combobox.restart_required = restart - combobox.label_text = text - return widget - - def create_file_combobox(self, text, choices, option, default=NoDefault, - tip=None, restart=False, filters=None, - adjust_to_contents=False, - default_line_edit=False, section=None, - validate_callback=None): - """choices: couples (name, key)""" - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - combobox = FileComboBox(self, adjust_to_contents=adjust_to_contents, - default_line_edit=default_line_edit) - combobox.restart_required = restart - combobox.label_text = text - edit = combobox.lineEdit() - edit.label_text = text - edit.restart_required = restart - self.lineedits[edit] = (section, option, default) - - if tip is not None: - combobox.setToolTip(tip) - combobox.addItems(choices) - combobox.choices = choices - - msg = _('Invalid file path') - self.validate_data[edit] = ( - validate_callback if validate_callback else osp.isfile, - msg) - browse_btn = QPushButton(ima.icon('FileIcon'), '', self) - browse_btn.setToolTip(_("Select file")) - options = QFileDialog.DontResolveSymlinks - browse_btn.clicked.connect( - lambda: self.select_file(edit, filters, options=options)) - - layout = QGridLayout() - layout.addWidget(combobox, 0, 0, 0, 9) - layout.addWidget(browse_btn, 0, 10) - layout.setContentsMargins(0, 0, 0, 0) - widget = QWidget(self) - widget.combobox = combobox - widget.browse_btn = browse_btn - widget.setLayout(layout) - - return widget - - def create_fontgroup(self, option=None, text=None, title=None, - tip=None, fontfilters=None, without_group=False): - """Option=None -> setting plugin font""" - - if title: - fontlabel = QLabel(title) - else: - fontlabel = QLabel(_("Font")) - fontbox = QFontComboBox() - - if fontfilters is not None: - fontbox.setFontFilters(fontfilters) - - sizelabel = QLabel(" " + _("Size")) - sizebox = QSpinBox() - sizebox.setRange(7, 100) - self.fontboxes[(fontbox, sizebox)] = option - layout = QHBoxLayout() - - for subwidget in (fontlabel, fontbox, sizelabel, sizebox): - layout.addWidget(subwidget) - layout.addStretch(1) - - widget = QWidget(self) - widget.fontlabel = fontlabel - widget.sizelabel = sizelabel - widget.fontbox = fontbox - widget.sizebox = sizebox - widget.setLayout(layout) - - if not without_group: - if text is None: - text = _("Font style") - - group = QGroupBox(text) - group.setLayout(layout) - - if tip is not None: - group.setToolTip(tip) - - return group - else: - return widget - - def create_button(self, text, callback): - btn = QPushButton(text) - btn.clicked.connect(callback) - btn.clicked.connect( - lambda checked=False, opt='': self.has_been_modified( - self.CONF_SECTION, opt)) - return btn - - def create_tab(self, *widgets): - """Create simple tab widget page: widgets added in a vertical layout""" - widget = QWidget() - layout = QVBoxLayout() - for widg in widgets: - layout.addWidget(widg) - layout.addStretch(1) - widget.setLayout(layout) - return widget - - def prompt_restart_required(self): - """Prompt the user with a request to restart.""" - restart_opts = self.restart_options - changed_opts = self.changed_options - options = [restart_opts[o] for o in changed_opts if o in restart_opts] - - if len(options) == 1: - msg_start = _("Spyder needs to restart to change the following " - "setting:") - else: - msg_start = _("Spyder needs to restart to change the following " - "settings:") - msg_end = _("Do you wish to restart now?") - - msg_options = u"" - for option in options: - msg_options += u"
  • {0}
  • ".format(option) - - msg_title = _("Information") - msg = u"{0}
      {1}

    {2}".format(msg_start, msg_options, msg_end) - answer = QMessageBox.information(self, msg_title, msg, - QMessageBox.Yes | QMessageBox.No) - if answer == QMessageBox.Yes: - self.restart() - - def restart(self): - """Restart Spyder.""" - self.main.restart(close_immediately=True) - - def add_tab(self, Widget): - widget = Widget(self) - if self.tabs is None: - # In case a preference page does not have any tabs, we need to - # add a tab with the widgets that already exist and then add the - # new tab. - self.tabs = QTabWidget() - layout = self.layout() - main_widget = QWidget() - main_widget.setLayout(layout) - self.tabs.addTab(self.create_tab(main_widget), - _('General')) - self.tabs.addTab(self.create_tab(widget), - Widget.TITLE) - vlayout = QVBoxLayout() - vlayout.addWidget(self.tabs) - self.setLayout(vlayout) - else: - self.tabs.addTab(self.create_tab(widget), - Widget.TITLE) - self.load_from_conf() - - -class GeneralConfigPage(SpyderConfigPage): - """Config page that maintains reference to main Spyder window - and allows to specify page name and icon declaratively - """ - CONF_SECTION = None - - NAME = None # configuration page name, e.g. _("General") - ICON = None # name of icon resource (24x24) - - def __init__(self, parent, main): - SpyderConfigPage.__init__(self, parent) - self.main = main - - def get_name(self): - """Configuration page name""" - return self.NAME - - def get_icon(self): - """Loads page icon named by self.ICON""" - return self.ICON - - def apply_settings(self, options): - raise NotImplementedError - - -class PreferencePages: - General = 'main' +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Preferences plugin public facing API +""" + +# Standard library imports +import ast +import os.path as osp + +# Third party imports +from qtpy import API +from qtpy.compat import (getexistingdirectory, getopenfilename, from_qvariant, + to_qvariant) +from qtpy.QtCore import Qt, Signal, Slot, QRegExp +from qtpy.QtGui import QColor, QRegExpValidator, QTextOption +from qtpy.QtWidgets import (QButtonGroup, QCheckBox, QComboBox, QDoubleSpinBox, + QFileDialog, QFontComboBox, QGridLayout, QGroupBox, + QHBoxLayout, QLabel, QLineEdit, QMessageBox, + QPlainTextEdit, QPushButton, QRadioButton, + QSpinBox, QTabWidget, QVBoxLayout, QWidget) + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.config.user import NoDefault +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.widgets.colors import ColorLayout +from spyder.widgets.comboboxes import FileComboBox + + +class BaseConfigTab(QWidget): + """Stub class to declare a config tab.""" + pass + + +class ConfigAccessMixin(object): + """Namespace for methods that access config storage""" + CONF_SECTION = None + + def set_option(self, option, value, section=None, + recursive_notification=False): + section = self.CONF_SECTION if section is None else section + CONF.set(section, option, value, + recursive_notification=recursive_notification) + + def get_option(self, option, default=NoDefault, section=None): + section = self.CONF_SECTION if section is None else section + return CONF.get(section, option, default) + + def remove_option(self, option, section=None): + section = self.CONF_SECTION if section is None else section + CONF.remove_option(section, option) + + +class ConfigPage(QWidget): + """Base class for configuration page in Preferences""" + + # Signals + apply_button_enabled = Signal(bool) + show_this_page = Signal() + + def __init__(self, parent, apply_callback=None): + QWidget.__init__(self, parent) + self.apply_callback = apply_callback + self.is_modified = False + + def initialize(self): + """ + Initialize configuration page: + * setup GUI widgets + * load settings and change widgets accordingly + """ + self.setup_page() + self.load_from_conf() + + def get_name(self): + """Return configuration page name""" + raise NotImplementedError + + def get_icon(self): + """Return configuration page icon (24x24)""" + raise NotImplementedError + + def setup_page(self): + """Setup configuration page widget""" + raise NotImplementedError + + def set_modified(self, state): + self.is_modified = state + self.apply_button_enabled.emit(state) + + def is_valid(self): + """Return True if all widget contents are valid""" + raise NotImplementedError + + def apply_changes(self): + """Apply changes callback""" + if self.is_modified: + self.save_to_conf() + if self.apply_callback is not None: + self.apply_callback() + + # Since the language cannot be retrieved by CONF and the language + # is needed before loading CONF, this is an extra method needed to + # ensure that when changes are applied, they are copied to a + # specific file storing the language value. This only applies to + # the main section config. + if self.CONF_SECTION == u'main': + self._save_lang() + + for restart_option in self.restart_options: + if restart_option in self.changed_options: + self.prompt_restart_required() + break # Ensure a single popup is displayed + self.set_modified(False) + + def load_from_conf(self): + """Load settings from configuration file""" + raise NotImplementedError + + def save_to_conf(self): + """Save settings to configuration file""" + raise NotImplementedError + + +class SpyderConfigPage(ConfigPage, ConfigAccessMixin): + """Plugin configuration dialog box page widget""" + CONF_SECTION = None + + def __init__(self, parent): + ConfigPage.__init__(self, parent, + apply_callback=lambda: + self._apply_settings_tabs(self.changed_options)) + self.checkboxes = {} + self.radiobuttons = {} + self.lineedits = {} + self.textedits = {} + self.validate_data = {} + self.spinboxes = {} + self.comboboxes = {} + self.fontboxes = {} + self.coloredits = {} + self.scedits = {} + self.cross_section_options = {} + self.changed_options = set() + self.restart_options = dict() # Dict to store name and localized text + self.default_button_group = None + self.main = parent.main + self.tabs = None + + def _apply_settings_tabs(self, options): + if self.tabs is not None: + for i in range(self.tabs.count()): + tab = self.tabs.widget(i) + layout = tab.layout() + for i in range(layout.count()): + widget = layout.itemAt(i).widget() + if hasattr(widget, 'apply_settings'): + if issubclass(type(widget), BaseConfigTab): + options |= widget.apply_settings() + self.apply_settings(options) + + def apply_settings(self, options): + raise NotImplementedError + + def check_settings(self): + """This method is called to check settings after configuration + dialog has been shown""" + pass + + def set_modified(self, state): + ConfigPage.set_modified(self, state) + if not state: + self.changed_options = set() + + def is_valid(self): + """Return True if all widget contents are valid""" + status = True + for lineedit in self.lineedits: + if lineedit in self.validate_data and lineedit.isEnabled(): + validator, invalid_msg = self.validate_data[lineedit] + text = to_text_string(lineedit.text()) + if not validator(text): + QMessageBox.critical(self, self.get_name(), + f"{invalid_msg}:
    {text}", + QMessageBox.Ok) + return False + + if self.tabs is not None and status: + for i in range(self.tabs.count()): + tab = self.tabs.widget(i) + layout = tab.layout() + for i in range(layout.count()): + widget = layout.itemAt(i).widget() + if issubclass(type(widget), BaseConfigTab): + status &= widget.is_valid() + if not status: + return status + return status + + def load_from_conf(self): + """Load settings from configuration file.""" + for checkbox, (sec, option, default) in list(self.checkboxes.items()): + checkbox.setChecked(self.get_option(option, default, section=sec)) + checkbox.clicked[bool].connect(lambda _, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if checkbox.restart_required: + if sec is None: + self.restart_options[option] = checkbox.text() + else: + self.restart_options[(sec, option)] = checkbox.text() + for radiobutton, (sec, option, default) in list( + self.radiobuttons.items()): + radiobutton.setChecked(self.get_option(option, default, + section=sec)) + radiobutton.toggled.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if radiobutton.restart_required: + if sec is None: + self.restart_options[option] = radiobutton.label_text + else: + self.restart_options[(sec, option)] = radiobutton.label_text + for lineedit, (sec, option, default) in list(self.lineedits.items()): + data = self.get_option(option, default, section=sec) + if getattr(lineedit, 'content_type', None) == list: + data = ', '.join(data) + lineedit.setText(data) + lineedit.textChanged.connect(lambda _, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if lineedit.restart_required: + if sec is None: + self.restart_options[option] = lineedit.label_text + else: + self.restart_options[(sec, option)] = lineedit.label_text + for textedit, (sec, option, default) in list(self.textedits.items()): + data = self.get_option(option, default, section=sec) + if getattr(textedit, 'content_type', None) == list: + data = ', '.join(data) + elif getattr(textedit, 'content_type', None) == dict: + data = to_text_string(data) + textedit.setPlainText(data) + textedit.textChanged.connect(lambda opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if textedit.restart_required: + if sec is None: + self.restart_options[option] = textedit.label_text + else: + self.restart_options[(sec, option)] = textedit.label_text + for spinbox, (sec, option, default) in list(self.spinboxes.items()): + spinbox.setValue(self.get_option(option, default, section=sec)) + spinbox.valueChanged.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + for combobox, (sec, option, default) in list(self.comboboxes.items()): + value = self.get_option(option, default, section=sec) + for index in range(combobox.count()): + data = from_qvariant(combobox.itemData(index), to_text_string) + # For PyQt API v2, it is necessary to convert `data` to + # unicode in case the original type was not a string, like an + # integer for example (see qtpy.compat.from_qvariant): + if to_text_string(data) == to_text_string(value): + break + else: + if combobox.count() == 0: + index = None + if index: + combobox.setCurrentIndex(index) + combobox.currentIndexChanged.connect( + lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if combobox.restart_required: + if sec is None: + self.restart_options[option] = combobox.label_text + else: + self.restart_options[(sec, option)] = combobox.label_text + + for (fontbox, sizebox), option in list(self.fontboxes.items()): + rich_font = True if "rich" in option.lower() else False + font = self.get_font(rich_font) + fontbox.setCurrentFont(font) + sizebox.setValue(font.pointSize()) + if option is None: + property = 'plugin_font' + else: + property = option + fontbox.currentIndexChanged.connect(lambda _foo, opt=property: + self.has_been_modified( + self.CONF_SECTION, opt)) + sizebox.valueChanged.connect(lambda _foo, opt=property: + self.has_been_modified( + self.CONF_SECTION, opt)) + for clayout, (sec, option, default) in list(self.coloredits.items()): + property = to_qvariant(option) + edit = clayout.lineedit + btn = clayout.colorbtn + edit.setText(self.get_option(option, default, section=sec)) + # QAbstractButton works differently for PySide and PyQt + if not API == 'pyside': + btn.clicked.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + else: + btn.clicked.connect(lambda opt=option, sect=sec: + self.has_been_modified(sect, opt)) + edit.textChanged.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + for (clayout, cb_bold, cb_italic + ), (sec, option, default) in list(self.scedits.items()): + edit = clayout.lineedit + btn = clayout.colorbtn + options = self.get_option(option, default, section=sec) + if options: + color, bold, italic = options + edit.setText(color) + cb_bold.setChecked(bold) + cb_italic.setChecked(italic) + + edit.textChanged.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + btn.clicked[bool].connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + cb_bold.clicked[bool].connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + cb_italic.clicked[bool].connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + + def save_to_conf(self): + """Save settings to configuration file""" + for checkbox, (sec, option, _default) in list( + self.checkboxes.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + value = checkbox.isChecked() + self.set_option(option, value, section=sec, + recursive_notification=False) + for radiobutton, (sec, option, _default) in list( + self.radiobuttons.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + self.set_option(option, radiobutton.isChecked(), section=sec, + recursive_notification=False) + for lineedit, (sec, option, _default) in list(self.lineedits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + data = lineedit.text() + content_type = getattr(lineedit, 'content_type', None) + if content_type == list: + data = [item.strip() for item in data.split(',')] + else: + data = to_text_string(data) + self.set_option(option, data, section=sec, + recursive_notification=False) + for textedit, (sec, option, _default) in list(self.textedits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + data = textedit.toPlainText() + content_type = getattr(textedit, 'content_type', None) + if content_type == dict: + if data: + data = ast.literal_eval(data) + else: + data = textedit.content_type() + elif content_type in (tuple, list): + data = [item.strip() for item in data.split(',')] + else: + data = to_text_string(data) + self.set_option(option, data, section=sec, + recursive_notification=False) + for spinbox, (sec, option, _default) in list(self.spinboxes.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + self.set_option(option, spinbox.value(), section=sec, + recursive_notification=False) + for combobox, (sec, option, _default) in list(self.comboboxes.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + data = combobox.itemData(combobox.currentIndex()) + self.set_option(option, from_qvariant(data, to_text_string), + section=sec, recursive_notification=False) + for (fontbox, sizebox), option in list(self.fontboxes.items()): + if (self.CONF_SECTION, option) in self.changed_options: + font = fontbox.currentFont() + font.setPointSize(sizebox.value()) + self.set_font(font, option) + for clayout, (sec, option, _default) in list(self.coloredits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + self.set_option(option, + to_text_string(clayout.lineedit.text()), + section=sec, recursive_notification=False) + for (clayout, cb_bold, cb_italic), (sec, option, _default) in list( + self.scedits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + color = to_text_string(clayout.lineedit.text()) + bold = cb_bold.isChecked() + italic = cb_italic.isChecked() + self.set_option(option, (color, bold, italic), section=sec, + recursive_notification=False) + + @Slot(str) + def has_been_modified(self, section, option): + self.set_modified(True) + if section is None: + self.changed_options.add(option) + else: + self.changed_options.add((section, option)) + + def create_checkbox(self, text, option, default=NoDefault, + tip=None, msg_warning=None, msg_info=None, + msg_if_enabled=False, section=None, restart=False): + checkbox = QCheckBox(text) + self.checkboxes[checkbox] = (section, option, default) + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + if tip is not None: + checkbox.setToolTip(tip) + if msg_warning is not None or msg_info is not None: + def show_message(is_checked=False): + if is_checked or not msg_if_enabled: + if msg_warning is not None: + QMessageBox.warning(self, self.get_name(), + msg_warning, QMessageBox.Ok) + if msg_info is not None: + QMessageBox.information(self, self.get_name(), + msg_info, QMessageBox.Ok) + checkbox.clicked.connect(show_message) + checkbox.restart_required = restart + return checkbox + + def create_radiobutton(self, text, option, default=NoDefault, + tip=None, msg_warning=None, msg_info=None, + msg_if_enabled=False, button_group=None, + restart=False, section=None): + radiobutton = QRadioButton(text) + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + if button_group is None: + if self.default_button_group is None: + self.default_button_group = QButtonGroup(self) + button_group = self.default_button_group + button_group.addButton(radiobutton) + if tip is not None: + radiobutton.setToolTip(tip) + self.radiobuttons[radiobutton] = (section, option, default) + if msg_warning is not None or msg_info is not None: + def show_message(is_checked): + if is_checked or not msg_if_enabled: + if msg_warning is not None: + QMessageBox.warning(self, self.get_name(), + msg_warning, QMessageBox.Ok) + if msg_info is not None: + QMessageBox.information(self, self.get_name(), + msg_info, QMessageBox.Ok) + radiobutton.toggled.connect(show_message) + radiobutton.restart_required = restart + radiobutton.label_text = text + return radiobutton + + def create_lineedit(self, text, option, default=NoDefault, + tip=None, alignment=Qt.Vertical, regex=None, + restart=False, word_wrap=True, placeholder=None, + content_type=None, section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + label.setWordWrap(word_wrap) + edit = QLineEdit() + edit.content_type = content_type + layout = QVBoxLayout() if alignment == Qt.Vertical else QHBoxLayout() + layout.addWidget(label) + layout.addWidget(edit) + layout.setContentsMargins(0, 0, 0, 0) + if tip: + edit.setToolTip(tip) + if regex: + edit.setValidator(QRegExpValidator(QRegExp(regex))) + if placeholder: + edit.setPlaceholderText(placeholder) + self.lineedits[edit] = (section, option, default) + widget = QWidget(self) + widget.label = label + widget.textbox = edit + widget.setLayout(layout) + edit.restart_required = restart + edit.label_text = text + return widget + + def create_textedit(self, text, option, default=NoDefault, + tip=None, restart=False, content_type=None, + section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + label.setWordWrap(True) + edit = QPlainTextEdit() + edit.content_type = content_type + edit.setWordWrapMode(QTextOption.WordWrap) + layout = QVBoxLayout() + layout.addWidget(label) + layout.addWidget(edit) + layout.setContentsMargins(0, 0, 0, 0) + if tip: + edit.setToolTip(tip) + self.textedits[edit] = (section, option, default) + widget = QWidget(self) + widget.label = label + widget.textbox = edit + widget.setLayout(layout) + edit.restart_required = restart + edit.label_text = text + return widget + + def create_browsedir(self, text, option, default=NoDefault, tip=None, + section=None): + widget = self.create_lineedit(text, option, default, section=section, + alignment=Qt.Horizontal) + for edit in self.lineedits: + if widget.isAncestorOf(edit): + break + msg = _("Invalid directory path") + self.validate_data[edit] = (osp.isdir, msg) + browse_btn = QPushButton(ima.icon('DirOpenIcon'), '', self) + browse_btn.setToolTip(_("Select directory")) + browse_btn.clicked.connect(lambda: self.select_directory(edit)) + layout = QHBoxLayout() + layout.addWidget(widget) + layout.addWidget(browse_btn) + layout.setContentsMargins(0, 0, 0, 0) + browsedir = QWidget(self) + browsedir.setLayout(layout) + return browsedir + + def select_directory(self, edit): + """Select directory""" + basedir = to_text_string(edit.text()) + if not osp.isdir(basedir): + basedir = getcwd_or_home() + title = _("Select directory") + directory = getexistingdirectory(self, title, basedir) + if directory: + edit.setText(directory) + + def create_browsefile(self, text, option, default=NoDefault, tip=None, + filters=None, section=None): + widget = self.create_lineedit(text, option, default, section=section, + alignment=Qt.Horizontal) + for edit in self.lineedits: + if widget.isAncestorOf(edit): + break + msg = _('Invalid file path') + self.validate_data[edit] = (osp.isfile, msg) + browse_btn = QPushButton(ima.icon('FileIcon'), '', self) + browse_btn.setToolTip(_("Select file")) + browse_btn.clicked.connect(lambda: self.select_file(edit, filters)) + layout = QHBoxLayout() + layout.addWidget(widget) + layout.addWidget(browse_btn) + layout.setContentsMargins(0, 0, 0, 0) + browsedir = QWidget(self) + browsedir.setLayout(layout) + return browsedir + + def select_file(self, edit, filters=None, **kwargs): + """Select File""" + basedir = osp.dirname(to_text_string(edit.text())) + if not osp.isdir(basedir): + basedir = getcwd_or_home() + if filters is None: + filters = _("All files (*)") + title = _("Select file") + filename, _selfilter = getopenfilename(self, title, basedir, filters, + **kwargs) + if filename: + edit.setText(filename) + + def create_spinbox(self, prefix, suffix, option, default=NoDefault, + min_=None, max_=None, step=None, tip=None, + section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + widget = QWidget(self) + if prefix: + plabel = QLabel(prefix) + widget.plabel = plabel + else: + plabel = None + if suffix: + slabel = QLabel(suffix) + widget.slabel = slabel + else: + slabel = None + if step is not None: + if type(step) is int: + spinbox = QSpinBox() + else: + spinbox = QDoubleSpinBox() + spinbox.setDecimals(1) + spinbox.setSingleStep(step) + else: + spinbox = QSpinBox() + if min_ is not None: + spinbox.setMinimum(min_) + if max_ is not None: + spinbox.setMaximum(max_) + if tip is not None: + spinbox.setToolTip(tip) + self.spinboxes[spinbox] = (section, option, default) + layout = QHBoxLayout() + for subwidget in (plabel, spinbox, slabel): + if subwidget is not None: + layout.addWidget(subwidget) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + widget.spinbox = spinbox + widget.setLayout(layout) + return widget + + def create_coloredit(self, text, option, default=NoDefault, tip=None, + without_layout=False, section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + clayout = ColorLayout(QColor(Qt.black), self) + clayout.lineedit.setMaximumWidth(80) + if tip is not None: + clayout.setToolTip(tip) + self.coloredits[clayout] = (section, option, default) + if without_layout: + return label, clayout + layout = QHBoxLayout() + layout.addWidget(label) + layout.addLayout(clayout) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + widget = QWidget(self) + widget.setLayout(layout) + return widget + + def create_scedit(self, text, option, default=NoDefault, tip=None, + without_layout=False, section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + clayout = ColorLayout(QColor(Qt.black), self) + clayout.lineedit.setMaximumWidth(80) + if tip is not None: + clayout.setToolTip(tip) + cb_bold = QCheckBox() + cb_bold.setIcon(ima.icon('bold')) + cb_bold.setToolTip(_("Bold")) + cb_italic = QCheckBox() + cb_italic.setIcon(ima.icon('italic')) + cb_italic.setToolTip(_("Italic")) + self.scedits[(clayout, cb_bold, cb_italic)] = (section, option, + default) + if without_layout: + return label, clayout, cb_bold, cb_italic + layout = QHBoxLayout() + layout.addWidget(label) + layout.addLayout(clayout) + layout.addSpacing(10) + layout.addWidget(cb_bold) + layout.addWidget(cb_italic) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + widget = QWidget(self) + widget.setLayout(layout) + return widget + + def create_combobox(self, text, choices, option, default=NoDefault, + tip=None, restart=False, section=None): + """choices: couples (name, key)""" + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + combobox = QComboBox() + if tip is not None: + combobox.setToolTip(tip) + for name, key in choices: + if not (name is None and key is None): + combobox.addItem(name, to_qvariant(key)) + # Insert separators + count = 0 + for index, item in enumerate(choices): + name, key = item + if name is None and key is None: + combobox.insertSeparator(index + count) + count += 1 + self.comboboxes[combobox] = (section, option, default) + layout = QHBoxLayout() + layout.addWidget(label) + layout.addWidget(combobox) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + widget = QWidget(self) + widget.label = label + widget.combobox = combobox + widget.setLayout(layout) + combobox.restart_required = restart + combobox.label_text = text + return widget + + def create_file_combobox(self, text, choices, option, default=NoDefault, + tip=None, restart=False, filters=None, + adjust_to_contents=False, + default_line_edit=False, section=None, + validate_callback=None): + """choices: couples (name, key)""" + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + combobox = FileComboBox(self, adjust_to_contents=adjust_to_contents, + default_line_edit=default_line_edit) + combobox.restart_required = restart + combobox.label_text = text + edit = combobox.lineEdit() + edit.label_text = text + edit.restart_required = restart + self.lineedits[edit] = (section, option, default) + + if tip is not None: + combobox.setToolTip(tip) + combobox.addItems(choices) + combobox.choices = choices + + msg = _('Invalid file path') + self.validate_data[edit] = ( + validate_callback if validate_callback else osp.isfile, + msg) + browse_btn = QPushButton(ima.icon('FileIcon'), '', self) + browse_btn.setToolTip(_("Select file")) + options = QFileDialog.DontResolveSymlinks + browse_btn.clicked.connect( + lambda: self.select_file(edit, filters, options=options)) + + layout = QGridLayout() + layout.addWidget(combobox, 0, 0, 0, 9) + layout.addWidget(browse_btn, 0, 10) + layout.setContentsMargins(0, 0, 0, 0) + widget = QWidget(self) + widget.combobox = combobox + widget.browse_btn = browse_btn + widget.setLayout(layout) + + return widget + + def create_fontgroup(self, option=None, text=None, title=None, + tip=None, fontfilters=None, without_group=False): + """Option=None -> setting plugin font""" + + if title: + fontlabel = QLabel(title) + else: + fontlabel = QLabel(_("Font")) + fontbox = QFontComboBox() + + if fontfilters is not None: + fontbox.setFontFilters(fontfilters) + + sizelabel = QLabel(" " + _("Size")) + sizebox = QSpinBox() + sizebox.setRange(7, 100) + self.fontboxes[(fontbox, sizebox)] = option + layout = QHBoxLayout() + + for subwidget in (fontlabel, fontbox, sizelabel, sizebox): + layout.addWidget(subwidget) + layout.addStretch(1) + + widget = QWidget(self) + widget.fontlabel = fontlabel + widget.sizelabel = sizelabel + widget.fontbox = fontbox + widget.sizebox = sizebox + widget.setLayout(layout) + + if not without_group: + if text is None: + text = _("Font style") + + group = QGroupBox(text) + group.setLayout(layout) + + if tip is not None: + group.setToolTip(tip) + + return group + else: + return widget + + def create_button(self, text, callback): + btn = QPushButton(text) + btn.clicked.connect(callback) + btn.clicked.connect( + lambda checked=False, opt='': self.has_been_modified( + self.CONF_SECTION, opt)) + return btn + + def create_tab(self, *widgets): + """Create simple tab widget page: widgets added in a vertical layout""" + widget = QWidget() + layout = QVBoxLayout() + for widg in widgets: + layout.addWidget(widg) + layout.addStretch(1) + widget.setLayout(layout) + return widget + + def prompt_restart_required(self): + """Prompt the user with a request to restart.""" + restart_opts = self.restart_options + changed_opts = self.changed_options + options = [restart_opts[o] for o in changed_opts if o in restart_opts] + + if len(options) == 1: + msg_start = _("Spyder needs to restart to change the following " + "setting:") + else: + msg_start = _("Spyder needs to restart to change the following " + "settings:") + msg_end = _("Do you wish to restart now?") + + msg_options = u"" + for option in options: + msg_options += u"
  • {0}
  • ".format(option) + + msg_title = _("Information") + msg = u"{0}
      {1}

    {2}".format(msg_start, msg_options, msg_end) + answer = QMessageBox.information(self, msg_title, msg, + QMessageBox.Yes | QMessageBox.No) + if answer == QMessageBox.Yes: + self.restart() + + def restart(self): + """Restart Spyder.""" + self.main.restart(close_immediately=True) + + def add_tab(self, Widget): + widget = Widget(self) + if self.tabs is None: + # In case a preference page does not have any tabs, we need to + # add a tab with the widgets that already exist and then add the + # new tab. + self.tabs = QTabWidget() + layout = self.layout() + main_widget = QWidget() + main_widget.setLayout(layout) + self.tabs.addTab(self.create_tab(main_widget), + _('General')) + self.tabs.addTab(self.create_tab(widget), + Widget.TITLE) + vlayout = QVBoxLayout() + vlayout.addWidget(self.tabs) + self.setLayout(vlayout) + else: + self.tabs.addTab(self.create_tab(widget), + Widget.TITLE) + self.load_from_conf() + + +class GeneralConfigPage(SpyderConfigPage): + """Config page that maintains reference to main Spyder window + and allows to specify page name and icon declaratively + """ + CONF_SECTION = None + + NAME = None # configuration page name, e.g. _("General") + ICON = None # name of icon resource (24x24) + + def __init__(self, parent, main): + SpyderConfigPage.__init__(self, parent) + self.main = main + + def get_name(self): + """Configuration page name""" + return self.NAME + + def get_icon(self): + """Loads page icon named by self.ICON""" + return self.ICON + + def apply_settings(self, options): + raise NotImplementedError + + +class PreferencePages: + General = 'main' diff --git a/spyder/plugins/profiler/widgets/main_widget.py b/spyder/plugins/profiler/widgets/main_widget.py index 6c92d4ddb80..608757b0daf 100644 --- a/spyder/plugins/profiler/widgets/main_widget.py +++ b/spyder/plugins/profiler/widgets/main_widget.py @@ -1,1060 +1,1060 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# based on pylintgui.py by Pierre Raybaut -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Profiler widget. - -See the official documentation on python profiling: -https://docs.python.org/3/library/profile.html -""" - -# Standard library imports -import logging -import os -import os.path as osp -import re -import sys -import time -from itertools import islice - -# Third party imports -from qtpy import PYQT5 -from qtpy.compat import getopenfilename, getsavefilename -from qtpy.QtCore import QByteArray, QProcess, QProcessEnvironment, Qt, Signal -from qtpy.QtGui import QColor -from qtpy.QtWidgets import (QApplication, QLabel, QMessageBox, QTreeWidget, - QTreeWidgetItem, QVBoxLayout) - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import PluginMainWidget -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import get_conf_path, running_in_mac_app -from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor -from spyder.py3compat import to_text_string -from spyder.utils.misc import get_python_executable, getcwd_or_home -from spyder.utils.palette import SpyderPalette, QStylePalette -from spyder.utils.programs import shell_split -from spyder.utils.qthelpers import get_item_user_text, set_item_user_text -from spyder.widgets.comboboxes import PythonModulesComboBox - -# Localization -_ = get_translation('spyder') - -# Logging -logger = logging.getLogger(__name__) - - -# --- Constants -# ---------------------------------------------------------------------------- -MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 - - -class ProfilerWidgetActions: - # Triggers - Browse = 'browse_action' - Clear = 'clear_action' - Collapse = 'collapse_action' - Expand = 'expand_action' - LoadData = 'load_data_action' - Run = 'run_action' - SaveData = 'save_data_action' - ShowOutput = 'show_output_action' - - -class ProfilerWidgetToolbars: - Information = 'information_toolbar' - - -class ProfilerWidgetMainToolbarSections: - Main = 'main_section' - - -class ProfilerWidgetInformationToolbarSections: - Main = 'main_section' - - -class ProfilerWidgetMainToolbarItems: - FileCombo = 'file_combo' - - -class ProfilerWidgetInformationToolbarItems: - Stretcher1 = 'stretcher_1' - Stretcher2 = 'stretcher_2' - DateLabel = 'date_label' - - -# --- Utils -# ---------------------------------------------------------------------------- -def is_profiler_installed(): - from spyder.utils.programs import is_module_installed - return is_module_installed('cProfile') and is_module_installed('pstats') - - -def gettime_s(text): - """ - Parse text and return a time in seconds. - - The text is of the format 0h : 0.min:0.0s:0 ms:0us:0 ns. - Spaces are not taken into account and any of the specifiers can be ignored. - """ - pattern = r'([+-]?\d+\.?\d*) ?([mμnsinh]+)' - matches = re.findall(pattern, text) - if len(matches) == 0: - return None - time = 0. - for res in matches: - tmp = float(res[0]) - if res[1] == 'ns': - tmp *= 1e-9 - elif res[1] == u'\u03BCs': - tmp *= 1e-6 - elif res[1] == 'ms': - tmp *= 1e-3 - elif res[1] == 'min': - tmp *= 60 - elif res[1] == 'h': - tmp *= 3600 - time += tmp - return time - - -# --- Widgets -# ---------------------------------------------------------------------------- -class ProfilerWidget(PluginMainWidget): - """ - Profiler widget. - """ - ENABLE_SPINNER = True - DATAPATH = get_conf_path('profiler.results') - - # --- Signals - # ------------------------------------------------------------------------ - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - sig_redirect_stdio_requested = Signal(bool) - """ - This signal is emitted to request the main application to redirect - standard output/error when using Open/Save/Browse dialogs within widgets. - - Parameters - ---------- - redirect: bool - Start redirect (True) or stop redirect (False). - """ - - sig_started = Signal() - """This signal is emitted to inform the profiling process has started.""" - - sig_finished = Signal() - """This signal is emitted to inform the profile profiling has finished.""" - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent) - self.set_conf('text_color', MAIN_TEXT_COLOR) - - # Attributes - self._last_wdir = None - self._last_args = None - self._last_pythonpath = None - self.error_output = None - self.output = None - self.running = False - self.text_color = self.get_conf('text_color') - - # Widgets - self.process = None - self.filecombo = PythonModulesComboBox( - self, id_=ProfilerWidgetMainToolbarItems.FileCombo) - self.datatree = ProfilerDataTree(self) - self.datelabel = QLabel() - self.datelabel.ID = ProfilerWidgetInformationToolbarItems.DateLabel - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.datatree) - self.setLayout(layout) - - # Signals - self.datatree.sig_edit_goto_requested.connect( - self.sig_edit_goto_requested) - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Profiler') - - def get_focus_widget(self): - return self.datatree - - def setup(self): - self.start_action = self.create_action( - ProfilerWidgetActions.Run, - text=_("Run profiler"), - tip=_("Run profiler"), - icon=self.create_icon('run'), - triggered=self.run, - ) - browse_action = self.create_action( - ProfilerWidgetActions.Browse, - text='', - tip=_('Select Python file'), - icon=self.create_icon('fileopen'), - triggered=lambda x: self.select_file(), - ) - self.log_action = self.create_action( - ProfilerWidgetActions.ShowOutput, - text=_("Output"), - tip=_("Show program's output"), - icon=self.create_icon('log'), - triggered=self.show_log, - ) - self.collapse_action = self.create_action( - ProfilerWidgetActions.Collapse, - text=_('Collapse'), - tip=_('Collapse one level up'), - icon=self.create_icon('collapse'), - triggered=lambda x=None: self.datatree.change_view(-1), - ) - self.expand_action = self.create_action( - ProfilerWidgetActions.Expand, - text=_('Expand'), - tip=_('Expand one level down'), - icon=self.create_icon('expand'), - triggered=lambda x=None: self.datatree.change_view(1), - ) - self.save_action = self.create_action( - ProfilerWidgetActions.SaveData, - text=_("Save data"), - tip=_('Save profiling data'), - icon=self.create_icon('filesave'), - triggered=self.save_data, - ) - self.load_action = self.create_action( - ProfilerWidgetActions.LoadData, - text=_("Load data"), - tip=_('Load profiling data for comparison'), - icon=self.create_icon('fileimport'), - triggered=self.compare, - ) - self.clear_action = self.create_action( - ProfilerWidgetActions.Clear, - text=_("Clear comparison"), - tip=_("Clear comparison"), - icon=self.create_icon('editdelete'), - triggered=self.clear, - ) - self.clear_action.setEnabled(False) - - # Main Toolbar - toolbar = self.get_main_toolbar() - for item in [self.filecombo, browse_action, self.start_action]: - self.add_item_to_toolbar( - item, - toolbar=toolbar, - section=ProfilerWidgetMainToolbarSections.Main, - ) - - # Secondary Toolbar - secondary_toolbar = self.create_toolbar( - ProfilerWidgetToolbars.Information) - for item in [self.collapse_action, self.expand_action, - self.create_stretcher( - id_=ProfilerWidgetInformationToolbarItems.Stretcher1), - self.datelabel, - self.create_stretcher( - id_=ProfilerWidgetInformationToolbarItems.Stretcher2), - self.log_action, - self.save_action, self.load_action, self.clear_action]: - self.add_item_to_toolbar( - item, - toolbar=secondary_toolbar, - section=ProfilerWidgetInformationToolbarSections.Main, - ) - - # Setup - if not is_profiler_installed(): - # This should happen only on certain GNU/Linux distributions - # or when this a home-made Python build because the Python - # profilers are included in the Python standard library - for widget in (self.datatree, self.filecombo, - self.start_action): - widget.setDisabled(True) - url = 'https://docs.python.org/3/library/profile.html' - text = '%s %s' % (_('Please install'), url, - _("the Python profiler modules")) - self.datelabel.setText(text) - - def update_actions(self): - if self.running: - icon = self.create_icon('stop') - else: - icon = self.create_icon('run') - self.start_action.setIcon(icon) - - self.start_action.setEnabled(bool(self.filecombo.currentText())) - - # --- Private API - # ------------------------------------------------------------------------ - def _kill_if_running(self): - """Kill the profiling process if it is running.""" - if self.process is not None: - if self.process.state() == QProcess.Running: - self.process.close() - self.process.waitForFinished(1000) - - self.update_actions() - - def _finished(self, exit_code, exit_status): - """ - Parse results once the profiling process has ended. - - Parameters - ---------- - exit_code: int - QProcess exit code. - exit_status: str - QProcess exit status. - """ - self.running = False - self.show_errorlog() # If errors occurred, show them. - self.output = self.error_output + self.output - self.datelabel.setText('') - self.show_data(justanalyzed=True) - self.update_actions() - - def _read_output(self, error=False): - """ - Read otuput from QProcess. - - Parameters - ---------- - error: bool, optional - Process QProcess output or error channels. Default is False. - """ - if error: - self.process.setReadChannel(QProcess.StandardError) - else: - self.process.setReadChannel(QProcess.StandardOutput) - - qba = QByteArray() - while self.process.bytesAvailable(): - if error: - qba += self.process.readAllStandardError() - else: - qba += self.process.readAllStandardOutput() - - text = to_text_string(qba.data(), encoding='utf-8') - if error: - self.error_output += text - else: - self.output += text - - # --- Public API - # ------------------------------------------------------------------------ - def save_data(self): - """Save data.""" - title = _( "Save profiler result") - filename, _selfilter = getsavefilename( - self, - title, - getcwd_or_home(), - _("Profiler result") + " (*.Result)", - ) - - if filename: - self.datatree.save_data(filename) - - def compare(self): - """Compare previous saved run with last run.""" - filename, _selfilter = getopenfilename( - self, - _("Select script to compare"), - getcwd_or_home(), - _("Profiler result") + " (*.Result)", - ) - - if filename: - self.datatree.compare(filename) - self.show_data() - self.clear_action.setEnabled(True) - - def clear(self): - """Clear data in tree.""" - self.datatree.compare(None) - self.datatree.hide_diff_cols(True) - self.show_data() - self.clear_action.setEnabled(False) - - def analyze(self, filename, wdir=None, args=None, pythonpath=None): - """ - Start the profiling process. - - Parameters - ---------- - wdir: str - Working directory path string. Default is None. - args: list - Arguments to pass to the profiling process. Default is None. - pythonpath: str - Python path string. Default is None. - """ - if not is_profiler_installed(): - return - - self._kill_if_running() - - # TODO: storing data is not implemented yet - # index, _data = self.get_data(filename) - combo = self.filecombo - items = [combo.itemText(idx) for idx in range(combo.count())] - index = None - if index is None and filename not in items: - self.filecombo.addItem(filename) - self.filecombo.setCurrentIndex(self.filecombo.count() - 1) - else: - self.filecombo.setCurrentIndex(self.filecombo.findText(filename)) - - self.filecombo.selected() - if self.filecombo.is_valid(): - if wdir is None: - wdir = osp.dirname(filename) - - self.start(wdir, args, pythonpath) - - def select_file(self, filename=None): - """ - Select filename to profile. - - Parameters - ---------- - filename: str, optional - Path to filename to profile. default is None. - - Notes - ----- - If no `filename` is provided an open filename dialog will be used. - """ - if filename is None: - self.sig_redirect_stdio_requested.emit(False) - filename, _selfilter = getopenfilename( - self, - _("Select Python file"), - getcwd_or_home(), - _("Python files") + " (*.py ; *.pyw)" - ) - self.sig_redirect_stdio_requested.emit(True) - - if filename: - self.analyze(filename) - - def show_log(self): - """Show process output log.""" - if self.output: - output_dialog = TextEditor( - self.output, - title=_("Profiler output"), - readonly=True, - parent=self, - ) - output_dialog.resize(700, 500) - output_dialog.exec_() - - def show_errorlog(self): - """Show process error log.""" - if self.error_output: - output_dialog = TextEditor( - self.error_output, - title=_("Profiler output"), - readonly=True, - parent=self, - ) - output_dialog.resize(700, 500) - output_dialog.exec_() - - def start(self, wdir=None, args=None, pythonpath=None): - """ - Start the profiling process. - - Parameters - ---------- - wdir: str - Working directory path string. Default is None. - args: list - Arguments to pass to the profiling process. Default is None. - pythonpath: str - Python path string. Default is None. - """ - filename = to_text_string(self.filecombo.currentText()) - if wdir is None: - wdir = self._last_wdir - if wdir is None: - wdir = osp.basename(filename) - - if args is None: - args = self._last_args - if args is None: - args = [] - - if pythonpath is None: - pythonpath = self._last_pythonpath - - self._last_wdir = wdir - self._last_args = args - self._last_pythonpath = pythonpath - - self.datelabel.setText(_('Profiling, please wait...')) - - self.process = QProcess(self) - self.process.setProcessChannelMode(QProcess.SeparateChannels) - self.process.setWorkingDirectory(wdir) - self.process.readyReadStandardOutput.connect(self._read_output) - self.process.readyReadStandardError.connect( - lambda: self._read_output(error=True)) - self.process.finished.connect( - lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) - self.process.finished.connect(self.stop_spinner) - - # Start with system environment - proc_env = QProcessEnvironment() - for k, v in os.environ.items(): - proc_env.insert(k, v) - proc_env.insert("PYTHONIOENCODING", "utf8") - proc_env.remove('PYTHONPATH') - if pythonpath is not None: - proc_env.insert('PYTHONPATH', os.pathsep.join(pythonpath)) - self.process.setProcessEnvironment(proc_env) - - executable = self.get_conf('executable', section='main_interpreter') - - if not running_in_mac_app(executable): - env = self.process.processEnvironment() - env.remove('PYTHONHOME') - self.process.setProcessEnvironment(env) - - self.output = '' - self.error_output = '' - self.running = True - self.start_spinner() - - p_args = ['-m', 'cProfile', '-o', self.DATAPATH] - if os.name == 'nt': - # On Windows, one has to replace backslashes by slashes to avoid - # confusion with escape characters (otherwise, for example, '\t' - # will be interpreted as a tabulation): - p_args.append(osp.normpath(filename).replace(os.sep, '/')) - else: - p_args.append(filename) - - if args: - p_args.extend(shell_split(args)) - - self.process.start(executable, p_args) - running = self.process.waitForStarted() - if not running: - QMessageBox.critical( - self, - _("Error"), - _("Process failed to start"), - ) - self.update_actions() - - def stop(self): - """Stop the running process.""" - self.running = False - self.process.close() - self.process.waitForFinished(1000) - self.stop_spinner() - self.update_actions() - - def run(self): - """Toggle starting or running the profiling process.""" - if self.running: - self.stop() - else: - self.start() - - def show_data(self, justanalyzed=False): - """ - Show analyzed data on results tree. - - Parameters - ---------- - justanalyzed: bool, optional - Default is False. - """ - if not justanalyzed: - self.output = None - - self.log_action.setEnabled(self.output is not None - and len(self.output) > 0) - self._kill_if_running() - filename = to_text_string(self.filecombo.currentText()) - if not filename: - return - - self.datelabel.setText(_('Sorting data, please wait...')) - QApplication.processEvents() - - self.datatree.load_data(self.DATAPATH) - self.datatree.show_tree() - - text_style = "%s " - date_text = text_style % (self.text_color, - time.strftime("%Y-%m-%d %H:%M:%S", - time.localtime())) - self.datelabel.setText(date_text) - - -class TreeWidgetItem(QTreeWidgetItem): - def __init__(self, parent=None): - QTreeWidgetItem.__init__(self, parent) - - def __lt__(self, otherItem): - column = self.treeWidget().sortColumn() - try: - if column == 1 or column == 3: # TODO: Hardcoded Column - t0 = gettime_s(self.text(column)) - t1 = gettime_s(otherItem.text(column)) - if t0 is not None and t1 is not None: - return t0 > t1 - - return float(self.text(column)) > float(otherItem.text(column)) - except ValueError: - return self.text(column) > otherItem.text(column) - - -class ProfilerDataTree(QTreeWidget, SpyderWidgetMixin): - """ - Convenience tree widget (with built-in model) - to store and view profiler data. - - The quantities calculated by the profiler are as follows - (from profile.Profile): - [0] = The number of times this function was called, not counting direct - or indirect recursion, - [1] = Number of times this function appears on the stack, minus one - [2] = Total time spent internal to this function - [3] = Cumulative time that this function was present on the stack. In - non-recursive functions, this is the total execution time from start - to finish of each invocation of a function, including time spent in - all subfunctions. - [4] = A dictionary indicating for each function name, the number of times - it was called by us. - """ - SEP = r"<[=]>" # separator between filename and linenumber - # (must be improbable as a filename to avoid splitting the filename itself) - - # Signals - sig_edit_goto_requested = Signal(str, int, str) - - def __init__(self, parent=None): - if PYQT5: - super().__init__(parent, class_parent=parent) - else: - QTreeWidget.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - self.header_list = [_('Function/Module'), _('Total Time'), _('Diff'), - _('Local Time'), _('Diff'), _('Calls'), _('Diff'), - _('File:line')] - self.icon_list = { - 'module': self.create_icon('python'), - 'function': self.create_icon('function'), - 'builtin': self.create_icon('python'), - 'constructor': self.create_icon('class') - } - self.profdata = None # To be filled by self.load_data() - self.stats = None # To be filled by self.load_data() - self.item_depth = None - self.item_list = None - self.items_to_be_shown = None - self.current_view_depth = None - self.compare_file = None - self.setColumnCount(len(self.header_list)) - self.setHeaderLabels(self.header_list) - self.initialize_view() - self.itemActivated.connect(self.item_activated) - self.itemExpanded.connect(self.item_expanded) - - def set_item_data(self, item, filename, line_number): - """Set tree item user data: filename (string) and line_number (int)""" - set_item_user_text(item, '%s%s%d' % (filename, self.SEP, line_number)) - - def get_item_data(self, item): - """Get tree item user data: (filename, line_number)""" - filename, line_number_str = get_item_user_text(item).split(self.SEP) - return filename, int(line_number_str) - - def initialize_view(self): - """Clean the tree and view parameters""" - self.clear() - self.item_depth = 0 # To be use for collapsing/expanding one level - self.item_list = [] # To be use for collapsing/expanding one level - self.items_to_be_shown = {} - self.current_view_depth = 0 - - def load_data(self, profdatafile): - """Load profiler data saved by profile/cProfile module""" - import pstats - # Fixes spyder-ide/spyder#6220. - try: - stats_indi = [pstats.Stats(profdatafile), ] - except (OSError, IOError): - self.profdata = None - return - self.profdata = stats_indi[0] - - if self.compare_file is not None: - # Fixes spyder-ide/spyder#5587. - try: - stats_indi.append(pstats.Stats(self.compare_file)) - except (OSError, IOError) as e: - QMessageBox.critical( - self, _("Error"), - _("Error when trying to load profiler results. " - "The error was

    " - "{0}").format(e)) - self.compare_file = None - map(lambda x: x.calc_callees(), stats_indi) - self.profdata.calc_callees() - self.stats1 = stats_indi - self.stats = stats_indi[0].stats - - def compare(self, filename): - self.hide_diff_cols(False) - self.compare_file = filename - - def hide_diff_cols(self, hide): - for i in (2, 4, 6): - self.setColumnHidden(i, hide) - - def save_data(self, filename): - """Save profiler data.""" - self.stats1[0].dump_stats(filename) - - def find_root(self): - """Find a function without a caller""" - # Fixes spyder-ide/spyder#8336. - if self.profdata is not None: - self.profdata.sort_stats("cumulative") - else: - return - for func in self.profdata.fcn_list: - if ('~', 0) != func[0:2] and not func[2].startswith( - ''): - # This skips the profiler function at the top of the list - # it does only occur in Python 3 - return func - - def find_callees(self, parent): - """Find all functions called by (parent) function.""" - # FIXME: This implementation is very inneficient, because it - # traverses all the data to find children nodes (callees) - return self.profdata.all_callees[parent] - - def show_tree(self): - """Populate the tree with profiler data and display it.""" - self.initialize_view() # Clear before re-populating - self.setItemsExpandable(True) - self.setSortingEnabled(False) - rootkey = self.find_root() # This root contains profiler overhead - if rootkey is not None: - self.populate_tree(self, self.find_callees(rootkey)) - self.resizeColumnToContents(0) - self.setSortingEnabled(True) - self.sortItems(1, Qt.AscendingOrder) # FIXME: hardcoded index - self.change_view(1) - - def function_info(self, functionKey): - """Returns processed information about the function's name and file.""" - node_type = 'function' - filename, line_number, function_name = functionKey - if function_name == '': - modulePath, moduleName = osp.split(filename) - node_type = 'module' - if moduleName == '__init__.py': - modulePath, moduleName = osp.split(modulePath) - function_name = '<' + moduleName + '>' - if not filename or filename == '~': - file_and_line = '(built-in)' - node_type = 'builtin' - else: - if function_name == '__init__': - node_type = 'constructor' - file_and_line = '%s : %d' % (filename, line_number) - return filename, line_number, function_name, file_and_line, node_type - - @staticmethod - def format_measure(measure): - """Get format and units for data coming from profiler task.""" - # Convert to a positive value. - measure = abs(measure) - - # For number of calls - if isinstance(measure, int): - return to_text_string(measure) - - # For time measurements - if 1.e-9 < measure <= 1.e-6: - measure = u"{0:.2f} ns".format(measure / 1.e-9) - elif 1.e-6 < measure <= 1.e-3: - measure = u"{0:.2f} \u03BCs".format(measure / 1.e-6) - elif 1.e-3 < measure <= 1: - measure = u"{0:.2f} ms".format(measure / 1.e-3) - elif 1 < measure <= 60: - measure = u"{0:.2f} s".format(measure) - elif 60 < measure <= 3600: - m, s = divmod(measure, 3600) - if s > 60: - m, s = divmod(measure, 60) - s = to_text_string(s).split(".")[-1] - measure = u"{0:.0f}.{1:.2s} min".format(m, s) - else: - h, m = divmod(measure, 3600) - if m > 60: - m /= 60 - measure = u"{0:.0f}h:{1:.0f}min".format(h, m) - return measure - - def color_string(self, x): - """Return a string formatted delta for the values in x. - - Args: - x: 2-item list of integers (representing number of calls) or - 2-item list of floats (representing seconds of runtime). - - Returns: - A list with [formatted x[0], [color, formatted delta]], where - color reflects whether x[1] is lower, greater, or the same as - x[0]. - """ - diff_str = "" - color = "black" - - if len(x) == 2 and self.compare_file is not None: - difference = x[0] - x[1] - if difference: - color, sign = ((SpyderPalette.COLOR_SUCCESS_1, '-') - if difference < 0 - else (SpyderPalette.COLOR_ERROR_1, '+')) - diff_str = '{}{}'.format(sign, self.format_measure(difference)) - return [self.format_measure(x[0]), [diff_str, color]] - - def format_output(self, child_key): - """ Formats the data. - - self.stats1 contains a list of one or two pstat.Stats() instances, with - the first being the current run and the second, the saved run, if it - exists. Each Stats instance is a dictionary mapping a function to - 5 data points - cumulative calls, number of calls, total time, - cumulative time, and callers. - - format_output() converts the number of calls, total time, and - cumulative time to a string format for the child_key parameter. - """ - data = [x.stats.get(child_key, [0, 0, 0, 0, {}]) for x in self.stats1] - return (map(self.color_string, islice(zip(*data), 1, 4))) - - def populate_tree(self, parentItem, children_list): - """ - Recursive method to create each item (and associated data) - in the tree. - """ - for child_key in children_list: - self.item_depth += 1 - (filename, line_number, function_name, file_and_line, node_type - ) = self.function_info(child_key) - - ((total_calls, total_calls_dif), (loc_time, loc_time_dif), - (cum_time, cum_time_dif)) = self.format_output(child_key) - - child_item = TreeWidgetItem(parentItem) - self.item_list.append(child_item) - self.set_item_data(child_item, filename, line_number) - - # FIXME: indexes to data should be defined by a dictionary on init - child_item.setToolTip(0, _('Function or module name')) - child_item.setData(0, Qt.DisplayRole, function_name) - child_item.setIcon(0, self.icon_list[node_type]) - - child_item.setToolTip(1, _('Time in function ' - '(including sub-functions)')) - child_item.setData(1, Qt.DisplayRole, cum_time) - child_item.setTextAlignment(1, Qt.AlignRight) - - child_item.setData(2, Qt.DisplayRole, cum_time_dif[0]) - child_item.setForeground(2, QColor(cum_time_dif[1])) - child_item.setTextAlignment(2, Qt.AlignLeft) - - child_item.setToolTip(3, _('Local time in function ' - '(not in sub-functions)')) - - child_item.setData(3, Qt.DisplayRole, loc_time) - child_item.setTextAlignment(3, Qt.AlignRight) - - child_item.setData(4, Qt.DisplayRole, loc_time_dif[0]) - child_item.setForeground(4, QColor(loc_time_dif[1])) - child_item.setTextAlignment(4, Qt.AlignLeft) - - child_item.setToolTip(5, _('Total number of calls ' - '(including recursion)')) - - child_item.setData(5, Qt.DisplayRole, total_calls) - child_item.setTextAlignment(5, Qt.AlignRight) - - child_item.setData(6, Qt.DisplayRole, total_calls_dif[0]) - child_item.setForeground(6, QColor(total_calls_dif[1])) - child_item.setTextAlignment(6, Qt.AlignLeft) - - child_item.setToolTip(7, _('File:line ' - 'where function is defined')) - child_item.setData(7, Qt.DisplayRole, file_and_line) - #child_item.setExpanded(True) - if self.is_recursive(child_item): - child_item.setData(7, Qt.DisplayRole, '(%s)' % _('recursion')) - child_item.setDisabled(True) - else: - callees = self.find_callees(child_key) - if self.item_depth < 3: - self.populate_tree(child_item, callees) - elif callees: - child_item.setChildIndicatorPolicy(child_item.ShowIndicator) - self.items_to_be_shown[id(child_item)] = callees - self.item_depth -= 1 - - def item_activated(self, item): - filename, line_number = self.get_item_data(item) - self.sig_edit_goto_requested.emit(filename, line_number, '') - - def item_expanded(self, item): - if item.childCount() == 0 and id(item) in self.items_to_be_shown: - callees = self.items_to_be_shown[id(item)] - self.populate_tree(item, callees) - - def is_recursive(self, child_item): - """Returns True is a function is a descendant of itself.""" - ancestor = child_item.parent() - # FIXME: indexes to data should be defined by a dictionary on init - while ancestor: - if (child_item.data(0, Qt.DisplayRole - ) == ancestor.data(0, Qt.DisplayRole) and - child_item.data(7, Qt.DisplayRole - ) == ancestor.data(7, Qt.DisplayRole)): - return True - else: - ancestor = ancestor.parent() - return False - - def get_top_level_items(self): - """Iterate over top level items""" - return [self.topLevelItem(_i) - for _i in range(self.topLevelItemCount())] - - def get_items(self, maxlevel): - """Return all items with a level <= `maxlevel`""" - itemlist = [] - - def add_to_itemlist(item, maxlevel, level=1): - level += 1 - for index in range(item.childCount()): - citem = item.child(index) - itemlist.append(citem) - if level <= maxlevel: - add_to_itemlist(citem, maxlevel, level) - - for tlitem in self.get_top_level_items(): - itemlist.append(tlitem) - if maxlevel > 0: - add_to_itemlist(tlitem, maxlevel=maxlevel) - return itemlist - - def change_view(self, change_in_depth): - """Change view depth by expanding or collapsing all same-level nodes""" - self.current_view_depth += change_in_depth - if self.current_view_depth < 0: - self.current_view_depth = 0 - self.collapseAll() - if self.current_view_depth > 0: - for item in self.get_items(maxlevel=self.current_view_depth - 1): - item.setExpanded(True) - - -# ============================================================================= -# Tests -# ============================================================================= -def primes(n): - """ - Simple test function - Taken from http://www.huyng.com/posts/python-performance-analysis/ - """ - if n == 2: - return [2] - elif n < 2: - return [] - s = list(range(3, n + 1, 2)) - mroot = n ** 0.5 - half = (n + 1) // 2 - 1 - i = 0 - m = 3 - while m <= mroot: - if s[i]: - j = (m * m - 3) // 2 - s[j] = 0 - while j < half: - s[j] = 0 - j += m - i = i + 1 - m = 2 * i + 3 - return [2] + [x for x in s if x] - - -def test(): - """Run widget test""" - from spyder.utils.qthelpers import qapplication - import inspect - import tempfile - from unittest.mock import MagicMock - - primes_sc = inspect.getsource(primes) - fd, script = tempfile.mkstemp(suffix='.py') - with os.fdopen(fd, 'w') as f: - f.write("# -*- coding: utf-8 -*-" + "\n\n") - f.write(primes_sc + "\n\n") - f.write("primes(100000)") - - plugin_mock = MagicMock() - plugin_mock.CONF_SECTION = 'profiler' - - app = qapplication(test_time=5) - widget = ProfilerWidget('test', plugin=plugin_mock) - widget._setup() - widget.setup() - widget.get_conf('executable', get_python_executable(), - section='main_interpreter') - widget.resize(800, 600) - widget.show() - widget.analyze(script) - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# based on pylintgui.py by Pierre Raybaut +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Profiler widget. + +See the official documentation on python profiling: +https://docs.python.org/3/library/profile.html +""" + +# Standard library imports +import logging +import os +import os.path as osp +import re +import sys +import time +from itertools import islice + +# Third party imports +from qtpy import PYQT5 +from qtpy.compat import getopenfilename, getsavefilename +from qtpy.QtCore import QByteArray, QProcess, QProcessEnvironment, Qt, Signal +from qtpy.QtGui import QColor +from qtpy.QtWidgets import (QApplication, QLabel, QMessageBox, QTreeWidget, + QTreeWidgetItem, QVBoxLayout) + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import PluginMainWidget +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import get_conf_path, running_in_mac_app +from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor +from spyder.py3compat import to_text_string +from spyder.utils.misc import get_python_executable, getcwd_or_home +from spyder.utils.palette import SpyderPalette, QStylePalette +from spyder.utils.programs import shell_split +from spyder.utils.qthelpers import get_item_user_text, set_item_user_text +from spyder.widgets.comboboxes import PythonModulesComboBox + +# Localization +_ = get_translation('spyder') + +# Logging +logger = logging.getLogger(__name__) + + +# --- Constants +# ---------------------------------------------------------------------------- +MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 + + +class ProfilerWidgetActions: + # Triggers + Browse = 'browse_action' + Clear = 'clear_action' + Collapse = 'collapse_action' + Expand = 'expand_action' + LoadData = 'load_data_action' + Run = 'run_action' + SaveData = 'save_data_action' + ShowOutput = 'show_output_action' + + +class ProfilerWidgetToolbars: + Information = 'information_toolbar' + + +class ProfilerWidgetMainToolbarSections: + Main = 'main_section' + + +class ProfilerWidgetInformationToolbarSections: + Main = 'main_section' + + +class ProfilerWidgetMainToolbarItems: + FileCombo = 'file_combo' + + +class ProfilerWidgetInformationToolbarItems: + Stretcher1 = 'stretcher_1' + Stretcher2 = 'stretcher_2' + DateLabel = 'date_label' + + +# --- Utils +# ---------------------------------------------------------------------------- +def is_profiler_installed(): + from spyder.utils.programs import is_module_installed + return is_module_installed('cProfile') and is_module_installed('pstats') + + +def gettime_s(text): + """ + Parse text and return a time in seconds. + + The text is of the format 0h : 0.min:0.0s:0 ms:0us:0 ns. + Spaces are not taken into account and any of the specifiers can be ignored. + """ + pattern = r'([+-]?\d+\.?\d*) ?([mμnsinh]+)' + matches = re.findall(pattern, text) + if len(matches) == 0: + return None + time = 0. + for res in matches: + tmp = float(res[0]) + if res[1] == 'ns': + tmp *= 1e-9 + elif res[1] == u'\u03BCs': + tmp *= 1e-6 + elif res[1] == 'ms': + tmp *= 1e-3 + elif res[1] == 'min': + tmp *= 60 + elif res[1] == 'h': + tmp *= 3600 + time += tmp + return time + + +# --- Widgets +# ---------------------------------------------------------------------------- +class ProfilerWidget(PluginMainWidget): + """ + Profiler widget. + """ + ENABLE_SPINNER = True + DATAPATH = get_conf_path('profiler.results') + + # --- Signals + # ------------------------------------------------------------------------ + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + sig_redirect_stdio_requested = Signal(bool) + """ + This signal is emitted to request the main application to redirect + standard output/error when using Open/Save/Browse dialogs within widgets. + + Parameters + ---------- + redirect: bool + Start redirect (True) or stop redirect (False). + """ + + sig_started = Signal() + """This signal is emitted to inform the profiling process has started.""" + + sig_finished = Signal() + """This signal is emitted to inform the profile profiling has finished.""" + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent) + self.set_conf('text_color', MAIN_TEXT_COLOR) + + # Attributes + self._last_wdir = None + self._last_args = None + self._last_pythonpath = None + self.error_output = None + self.output = None + self.running = False + self.text_color = self.get_conf('text_color') + + # Widgets + self.process = None + self.filecombo = PythonModulesComboBox( + self, id_=ProfilerWidgetMainToolbarItems.FileCombo) + self.datatree = ProfilerDataTree(self) + self.datelabel = QLabel() + self.datelabel.ID = ProfilerWidgetInformationToolbarItems.DateLabel + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.datatree) + self.setLayout(layout) + + # Signals + self.datatree.sig_edit_goto_requested.connect( + self.sig_edit_goto_requested) + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Profiler') + + def get_focus_widget(self): + return self.datatree + + def setup(self): + self.start_action = self.create_action( + ProfilerWidgetActions.Run, + text=_("Run profiler"), + tip=_("Run profiler"), + icon=self.create_icon('run'), + triggered=self.run, + ) + browse_action = self.create_action( + ProfilerWidgetActions.Browse, + text='', + tip=_('Select Python file'), + icon=self.create_icon('fileopen'), + triggered=lambda x: self.select_file(), + ) + self.log_action = self.create_action( + ProfilerWidgetActions.ShowOutput, + text=_("Output"), + tip=_("Show program's output"), + icon=self.create_icon('log'), + triggered=self.show_log, + ) + self.collapse_action = self.create_action( + ProfilerWidgetActions.Collapse, + text=_('Collapse'), + tip=_('Collapse one level up'), + icon=self.create_icon('collapse'), + triggered=lambda x=None: self.datatree.change_view(-1), + ) + self.expand_action = self.create_action( + ProfilerWidgetActions.Expand, + text=_('Expand'), + tip=_('Expand one level down'), + icon=self.create_icon('expand'), + triggered=lambda x=None: self.datatree.change_view(1), + ) + self.save_action = self.create_action( + ProfilerWidgetActions.SaveData, + text=_("Save data"), + tip=_('Save profiling data'), + icon=self.create_icon('filesave'), + triggered=self.save_data, + ) + self.load_action = self.create_action( + ProfilerWidgetActions.LoadData, + text=_("Load data"), + tip=_('Load profiling data for comparison'), + icon=self.create_icon('fileimport'), + triggered=self.compare, + ) + self.clear_action = self.create_action( + ProfilerWidgetActions.Clear, + text=_("Clear comparison"), + tip=_("Clear comparison"), + icon=self.create_icon('editdelete'), + triggered=self.clear, + ) + self.clear_action.setEnabled(False) + + # Main Toolbar + toolbar = self.get_main_toolbar() + for item in [self.filecombo, browse_action, self.start_action]: + self.add_item_to_toolbar( + item, + toolbar=toolbar, + section=ProfilerWidgetMainToolbarSections.Main, + ) + + # Secondary Toolbar + secondary_toolbar = self.create_toolbar( + ProfilerWidgetToolbars.Information) + for item in [self.collapse_action, self.expand_action, + self.create_stretcher( + id_=ProfilerWidgetInformationToolbarItems.Stretcher1), + self.datelabel, + self.create_stretcher( + id_=ProfilerWidgetInformationToolbarItems.Stretcher2), + self.log_action, + self.save_action, self.load_action, self.clear_action]: + self.add_item_to_toolbar( + item, + toolbar=secondary_toolbar, + section=ProfilerWidgetInformationToolbarSections.Main, + ) + + # Setup + if not is_profiler_installed(): + # This should happen only on certain GNU/Linux distributions + # or when this a home-made Python build because the Python + # profilers are included in the Python standard library + for widget in (self.datatree, self.filecombo, + self.start_action): + widget.setDisabled(True) + url = 'https://docs.python.org/3/library/profile.html' + text = '%s %s' % (_('Please install'), url, + _("the Python profiler modules")) + self.datelabel.setText(text) + + def update_actions(self): + if self.running: + icon = self.create_icon('stop') + else: + icon = self.create_icon('run') + self.start_action.setIcon(icon) + + self.start_action.setEnabled(bool(self.filecombo.currentText())) + + # --- Private API + # ------------------------------------------------------------------------ + def _kill_if_running(self): + """Kill the profiling process if it is running.""" + if self.process is not None: + if self.process.state() == QProcess.Running: + self.process.close() + self.process.waitForFinished(1000) + + self.update_actions() + + def _finished(self, exit_code, exit_status): + """ + Parse results once the profiling process has ended. + + Parameters + ---------- + exit_code: int + QProcess exit code. + exit_status: str + QProcess exit status. + """ + self.running = False + self.show_errorlog() # If errors occurred, show them. + self.output = self.error_output + self.output + self.datelabel.setText('') + self.show_data(justanalyzed=True) + self.update_actions() + + def _read_output(self, error=False): + """ + Read otuput from QProcess. + + Parameters + ---------- + error: bool, optional + Process QProcess output or error channels. Default is False. + """ + if error: + self.process.setReadChannel(QProcess.StandardError) + else: + self.process.setReadChannel(QProcess.StandardOutput) + + qba = QByteArray() + while self.process.bytesAvailable(): + if error: + qba += self.process.readAllStandardError() + else: + qba += self.process.readAllStandardOutput() + + text = to_text_string(qba.data(), encoding='utf-8') + if error: + self.error_output += text + else: + self.output += text + + # --- Public API + # ------------------------------------------------------------------------ + def save_data(self): + """Save data.""" + title = _( "Save profiler result") + filename, _selfilter = getsavefilename( + self, + title, + getcwd_or_home(), + _("Profiler result") + " (*.Result)", + ) + + if filename: + self.datatree.save_data(filename) + + def compare(self): + """Compare previous saved run with last run.""" + filename, _selfilter = getopenfilename( + self, + _("Select script to compare"), + getcwd_or_home(), + _("Profiler result") + " (*.Result)", + ) + + if filename: + self.datatree.compare(filename) + self.show_data() + self.clear_action.setEnabled(True) + + def clear(self): + """Clear data in tree.""" + self.datatree.compare(None) + self.datatree.hide_diff_cols(True) + self.show_data() + self.clear_action.setEnabled(False) + + def analyze(self, filename, wdir=None, args=None, pythonpath=None): + """ + Start the profiling process. + + Parameters + ---------- + wdir: str + Working directory path string. Default is None. + args: list + Arguments to pass to the profiling process. Default is None. + pythonpath: str + Python path string. Default is None. + """ + if not is_profiler_installed(): + return + + self._kill_if_running() + + # TODO: storing data is not implemented yet + # index, _data = self.get_data(filename) + combo = self.filecombo + items = [combo.itemText(idx) for idx in range(combo.count())] + index = None + if index is None and filename not in items: + self.filecombo.addItem(filename) + self.filecombo.setCurrentIndex(self.filecombo.count() - 1) + else: + self.filecombo.setCurrentIndex(self.filecombo.findText(filename)) + + self.filecombo.selected() + if self.filecombo.is_valid(): + if wdir is None: + wdir = osp.dirname(filename) + + self.start(wdir, args, pythonpath) + + def select_file(self, filename=None): + """ + Select filename to profile. + + Parameters + ---------- + filename: str, optional + Path to filename to profile. default is None. + + Notes + ----- + If no `filename` is provided an open filename dialog will be used. + """ + if filename is None: + self.sig_redirect_stdio_requested.emit(False) + filename, _selfilter = getopenfilename( + self, + _("Select Python file"), + getcwd_or_home(), + _("Python files") + " (*.py ; *.pyw)" + ) + self.sig_redirect_stdio_requested.emit(True) + + if filename: + self.analyze(filename) + + def show_log(self): + """Show process output log.""" + if self.output: + output_dialog = TextEditor( + self.output, + title=_("Profiler output"), + readonly=True, + parent=self, + ) + output_dialog.resize(700, 500) + output_dialog.exec_() + + def show_errorlog(self): + """Show process error log.""" + if self.error_output: + output_dialog = TextEditor( + self.error_output, + title=_("Profiler output"), + readonly=True, + parent=self, + ) + output_dialog.resize(700, 500) + output_dialog.exec_() + + def start(self, wdir=None, args=None, pythonpath=None): + """ + Start the profiling process. + + Parameters + ---------- + wdir: str + Working directory path string. Default is None. + args: list + Arguments to pass to the profiling process. Default is None. + pythonpath: str + Python path string. Default is None. + """ + filename = to_text_string(self.filecombo.currentText()) + if wdir is None: + wdir = self._last_wdir + if wdir is None: + wdir = osp.basename(filename) + + if args is None: + args = self._last_args + if args is None: + args = [] + + if pythonpath is None: + pythonpath = self._last_pythonpath + + self._last_wdir = wdir + self._last_args = args + self._last_pythonpath = pythonpath + + self.datelabel.setText(_('Profiling, please wait...')) + + self.process = QProcess(self) + self.process.setProcessChannelMode(QProcess.SeparateChannels) + self.process.setWorkingDirectory(wdir) + self.process.readyReadStandardOutput.connect(self._read_output) + self.process.readyReadStandardError.connect( + lambda: self._read_output(error=True)) + self.process.finished.connect( + lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) + self.process.finished.connect(self.stop_spinner) + + # Start with system environment + proc_env = QProcessEnvironment() + for k, v in os.environ.items(): + proc_env.insert(k, v) + proc_env.insert("PYTHONIOENCODING", "utf8") + proc_env.remove('PYTHONPATH') + if pythonpath is not None: + proc_env.insert('PYTHONPATH', os.pathsep.join(pythonpath)) + self.process.setProcessEnvironment(proc_env) + + executable = self.get_conf('executable', section='main_interpreter') + + if not running_in_mac_app(executable): + env = self.process.processEnvironment() + env.remove('PYTHONHOME') + self.process.setProcessEnvironment(env) + + self.output = '' + self.error_output = '' + self.running = True + self.start_spinner() + + p_args = ['-m', 'cProfile', '-o', self.DATAPATH] + if os.name == 'nt': + # On Windows, one has to replace backslashes by slashes to avoid + # confusion with escape characters (otherwise, for example, '\t' + # will be interpreted as a tabulation): + p_args.append(osp.normpath(filename).replace(os.sep, '/')) + else: + p_args.append(filename) + + if args: + p_args.extend(shell_split(args)) + + self.process.start(executable, p_args) + running = self.process.waitForStarted() + if not running: + QMessageBox.critical( + self, + _("Error"), + _("Process failed to start"), + ) + self.update_actions() + + def stop(self): + """Stop the running process.""" + self.running = False + self.process.close() + self.process.waitForFinished(1000) + self.stop_spinner() + self.update_actions() + + def run(self): + """Toggle starting or running the profiling process.""" + if self.running: + self.stop() + else: + self.start() + + def show_data(self, justanalyzed=False): + """ + Show analyzed data on results tree. + + Parameters + ---------- + justanalyzed: bool, optional + Default is False. + """ + if not justanalyzed: + self.output = None + + self.log_action.setEnabled(self.output is not None + and len(self.output) > 0) + self._kill_if_running() + filename = to_text_string(self.filecombo.currentText()) + if not filename: + return + + self.datelabel.setText(_('Sorting data, please wait...')) + QApplication.processEvents() + + self.datatree.load_data(self.DATAPATH) + self.datatree.show_tree() + + text_style = "%s " + date_text = text_style % (self.text_color, + time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime())) + self.datelabel.setText(date_text) + + +class TreeWidgetItem(QTreeWidgetItem): + def __init__(self, parent=None): + QTreeWidgetItem.__init__(self, parent) + + def __lt__(self, otherItem): + column = self.treeWidget().sortColumn() + try: + if column == 1 or column == 3: # TODO: Hardcoded Column + t0 = gettime_s(self.text(column)) + t1 = gettime_s(otherItem.text(column)) + if t0 is not None and t1 is not None: + return t0 > t1 + + return float(self.text(column)) > float(otherItem.text(column)) + except ValueError: + return self.text(column) > otherItem.text(column) + + +class ProfilerDataTree(QTreeWidget, SpyderWidgetMixin): + """ + Convenience tree widget (with built-in model) + to store and view profiler data. + + The quantities calculated by the profiler are as follows + (from profile.Profile): + [0] = The number of times this function was called, not counting direct + or indirect recursion, + [1] = Number of times this function appears on the stack, minus one + [2] = Total time spent internal to this function + [3] = Cumulative time that this function was present on the stack. In + non-recursive functions, this is the total execution time from start + to finish of each invocation of a function, including time spent in + all subfunctions. + [4] = A dictionary indicating for each function name, the number of times + it was called by us. + """ + SEP = r"<[=]>" # separator between filename and linenumber + # (must be improbable as a filename to avoid splitting the filename itself) + + # Signals + sig_edit_goto_requested = Signal(str, int, str) + + def __init__(self, parent=None): + if PYQT5: + super().__init__(parent, class_parent=parent) + else: + QTreeWidget.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + self.header_list = [_('Function/Module'), _('Total Time'), _('Diff'), + _('Local Time'), _('Diff'), _('Calls'), _('Diff'), + _('File:line')] + self.icon_list = { + 'module': self.create_icon('python'), + 'function': self.create_icon('function'), + 'builtin': self.create_icon('python'), + 'constructor': self.create_icon('class') + } + self.profdata = None # To be filled by self.load_data() + self.stats = None # To be filled by self.load_data() + self.item_depth = None + self.item_list = None + self.items_to_be_shown = None + self.current_view_depth = None + self.compare_file = None + self.setColumnCount(len(self.header_list)) + self.setHeaderLabels(self.header_list) + self.initialize_view() + self.itemActivated.connect(self.item_activated) + self.itemExpanded.connect(self.item_expanded) + + def set_item_data(self, item, filename, line_number): + """Set tree item user data: filename (string) and line_number (int)""" + set_item_user_text(item, '%s%s%d' % (filename, self.SEP, line_number)) + + def get_item_data(self, item): + """Get tree item user data: (filename, line_number)""" + filename, line_number_str = get_item_user_text(item).split(self.SEP) + return filename, int(line_number_str) + + def initialize_view(self): + """Clean the tree and view parameters""" + self.clear() + self.item_depth = 0 # To be use for collapsing/expanding one level + self.item_list = [] # To be use for collapsing/expanding one level + self.items_to_be_shown = {} + self.current_view_depth = 0 + + def load_data(self, profdatafile): + """Load profiler data saved by profile/cProfile module""" + import pstats + # Fixes spyder-ide/spyder#6220. + try: + stats_indi = [pstats.Stats(profdatafile), ] + except (OSError, IOError): + self.profdata = None + return + self.profdata = stats_indi[0] + + if self.compare_file is not None: + # Fixes spyder-ide/spyder#5587. + try: + stats_indi.append(pstats.Stats(self.compare_file)) + except (OSError, IOError) as e: + QMessageBox.critical( + self, _("Error"), + _("Error when trying to load profiler results. " + "The error was

    " + "{0}").format(e)) + self.compare_file = None + map(lambda x: x.calc_callees(), stats_indi) + self.profdata.calc_callees() + self.stats1 = stats_indi + self.stats = stats_indi[0].stats + + def compare(self, filename): + self.hide_diff_cols(False) + self.compare_file = filename + + def hide_diff_cols(self, hide): + for i in (2, 4, 6): + self.setColumnHidden(i, hide) + + def save_data(self, filename): + """Save profiler data.""" + self.stats1[0].dump_stats(filename) + + def find_root(self): + """Find a function without a caller""" + # Fixes spyder-ide/spyder#8336. + if self.profdata is not None: + self.profdata.sort_stats("cumulative") + else: + return + for func in self.profdata.fcn_list: + if ('~', 0) != func[0:2] and not func[2].startswith( + ''): + # This skips the profiler function at the top of the list + # it does only occur in Python 3 + return func + + def find_callees(self, parent): + """Find all functions called by (parent) function.""" + # FIXME: This implementation is very inneficient, because it + # traverses all the data to find children nodes (callees) + return self.profdata.all_callees[parent] + + def show_tree(self): + """Populate the tree with profiler data and display it.""" + self.initialize_view() # Clear before re-populating + self.setItemsExpandable(True) + self.setSortingEnabled(False) + rootkey = self.find_root() # This root contains profiler overhead + if rootkey is not None: + self.populate_tree(self, self.find_callees(rootkey)) + self.resizeColumnToContents(0) + self.setSortingEnabled(True) + self.sortItems(1, Qt.AscendingOrder) # FIXME: hardcoded index + self.change_view(1) + + def function_info(self, functionKey): + """Returns processed information about the function's name and file.""" + node_type = 'function' + filename, line_number, function_name = functionKey + if function_name == '': + modulePath, moduleName = osp.split(filename) + node_type = 'module' + if moduleName == '__init__.py': + modulePath, moduleName = osp.split(modulePath) + function_name = '<' + moduleName + '>' + if not filename or filename == '~': + file_and_line = '(built-in)' + node_type = 'builtin' + else: + if function_name == '__init__': + node_type = 'constructor' + file_and_line = '%s : %d' % (filename, line_number) + return filename, line_number, function_name, file_and_line, node_type + + @staticmethod + def format_measure(measure): + """Get format and units for data coming from profiler task.""" + # Convert to a positive value. + measure = abs(measure) + + # For number of calls + if isinstance(measure, int): + return to_text_string(measure) + + # For time measurements + if 1.e-9 < measure <= 1.e-6: + measure = u"{0:.2f} ns".format(measure / 1.e-9) + elif 1.e-6 < measure <= 1.e-3: + measure = u"{0:.2f} \u03BCs".format(measure / 1.e-6) + elif 1.e-3 < measure <= 1: + measure = u"{0:.2f} ms".format(measure / 1.e-3) + elif 1 < measure <= 60: + measure = u"{0:.2f} s".format(measure) + elif 60 < measure <= 3600: + m, s = divmod(measure, 3600) + if s > 60: + m, s = divmod(measure, 60) + s = to_text_string(s).split(".")[-1] + measure = u"{0:.0f}.{1:.2s} min".format(m, s) + else: + h, m = divmod(measure, 3600) + if m > 60: + m /= 60 + measure = u"{0:.0f}h:{1:.0f}min".format(h, m) + return measure + + def color_string(self, x): + """Return a string formatted delta for the values in x. + + Args: + x: 2-item list of integers (representing number of calls) or + 2-item list of floats (representing seconds of runtime). + + Returns: + A list with [formatted x[0], [color, formatted delta]], where + color reflects whether x[1] is lower, greater, or the same as + x[0]. + """ + diff_str = "" + color = "black" + + if len(x) == 2 and self.compare_file is not None: + difference = x[0] - x[1] + if difference: + color, sign = ((SpyderPalette.COLOR_SUCCESS_1, '-') + if difference < 0 + else (SpyderPalette.COLOR_ERROR_1, '+')) + diff_str = '{}{}'.format(sign, self.format_measure(difference)) + return [self.format_measure(x[0]), [diff_str, color]] + + def format_output(self, child_key): + """ Formats the data. + + self.stats1 contains a list of one or two pstat.Stats() instances, with + the first being the current run and the second, the saved run, if it + exists. Each Stats instance is a dictionary mapping a function to + 5 data points - cumulative calls, number of calls, total time, + cumulative time, and callers. + + format_output() converts the number of calls, total time, and + cumulative time to a string format for the child_key parameter. + """ + data = [x.stats.get(child_key, [0, 0, 0, 0, {}]) for x in self.stats1] + return (map(self.color_string, islice(zip(*data), 1, 4))) + + def populate_tree(self, parentItem, children_list): + """ + Recursive method to create each item (and associated data) + in the tree. + """ + for child_key in children_list: + self.item_depth += 1 + (filename, line_number, function_name, file_and_line, node_type + ) = self.function_info(child_key) + + ((total_calls, total_calls_dif), (loc_time, loc_time_dif), + (cum_time, cum_time_dif)) = self.format_output(child_key) + + child_item = TreeWidgetItem(parentItem) + self.item_list.append(child_item) + self.set_item_data(child_item, filename, line_number) + + # FIXME: indexes to data should be defined by a dictionary on init + child_item.setToolTip(0, _('Function or module name')) + child_item.setData(0, Qt.DisplayRole, function_name) + child_item.setIcon(0, self.icon_list[node_type]) + + child_item.setToolTip(1, _('Time in function ' + '(including sub-functions)')) + child_item.setData(1, Qt.DisplayRole, cum_time) + child_item.setTextAlignment(1, Qt.AlignRight) + + child_item.setData(2, Qt.DisplayRole, cum_time_dif[0]) + child_item.setForeground(2, QColor(cum_time_dif[1])) + child_item.setTextAlignment(2, Qt.AlignLeft) + + child_item.setToolTip(3, _('Local time in function ' + '(not in sub-functions)')) + + child_item.setData(3, Qt.DisplayRole, loc_time) + child_item.setTextAlignment(3, Qt.AlignRight) + + child_item.setData(4, Qt.DisplayRole, loc_time_dif[0]) + child_item.setForeground(4, QColor(loc_time_dif[1])) + child_item.setTextAlignment(4, Qt.AlignLeft) + + child_item.setToolTip(5, _('Total number of calls ' + '(including recursion)')) + + child_item.setData(5, Qt.DisplayRole, total_calls) + child_item.setTextAlignment(5, Qt.AlignRight) + + child_item.setData(6, Qt.DisplayRole, total_calls_dif[0]) + child_item.setForeground(6, QColor(total_calls_dif[1])) + child_item.setTextAlignment(6, Qt.AlignLeft) + + child_item.setToolTip(7, _('File:line ' + 'where function is defined')) + child_item.setData(7, Qt.DisplayRole, file_and_line) + #child_item.setExpanded(True) + if self.is_recursive(child_item): + child_item.setData(7, Qt.DisplayRole, '(%s)' % _('recursion')) + child_item.setDisabled(True) + else: + callees = self.find_callees(child_key) + if self.item_depth < 3: + self.populate_tree(child_item, callees) + elif callees: + child_item.setChildIndicatorPolicy(child_item.ShowIndicator) + self.items_to_be_shown[id(child_item)] = callees + self.item_depth -= 1 + + def item_activated(self, item): + filename, line_number = self.get_item_data(item) + self.sig_edit_goto_requested.emit(filename, line_number, '') + + def item_expanded(self, item): + if item.childCount() == 0 and id(item) in self.items_to_be_shown: + callees = self.items_to_be_shown[id(item)] + self.populate_tree(item, callees) + + def is_recursive(self, child_item): + """Returns True is a function is a descendant of itself.""" + ancestor = child_item.parent() + # FIXME: indexes to data should be defined by a dictionary on init + while ancestor: + if (child_item.data(0, Qt.DisplayRole + ) == ancestor.data(0, Qt.DisplayRole) and + child_item.data(7, Qt.DisplayRole + ) == ancestor.data(7, Qt.DisplayRole)): + return True + else: + ancestor = ancestor.parent() + return False + + def get_top_level_items(self): + """Iterate over top level items""" + return [self.topLevelItem(_i) + for _i in range(self.topLevelItemCount())] + + def get_items(self, maxlevel): + """Return all items with a level <= `maxlevel`""" + itemlist = [] + + def add_to_itemlist(item, maxlevel, level=1): + level += 1 + for index in range(item.childCount()): + citem = item.child(index) + itemlist.append(citem) + if level <= maxlevel: + add_to_itemlist(citem, maxlevel, level) + + for tlitem in self.get_top_level_items(): + itemlist.append(tlitem) + if maxlevel > 0: + add_to_itemlist(tlitem, maxlevel=maxlevel) + return itemlist + + def change_view(self, change_in_depth): + """Change view depth by expanding or collapsing all same-level nodes""" + self.current_view_depth += change_in_depth + if self.current_view_depth < 0: + self.current_view_depth = 0 + self.collapseAll() + if self.current_view_depth > 0: + for item in self.get_items(maxlevel=self.current_view_depth - 1): + item.setExpanded(True) + + +# ============================================================================= +# Tests +# ============================================================================= +def primes(n): + """ + Simple test function + Taken from http://www.huyng.com/posts/python-performance-analysis/ + """ + if n == 2: + return [2] + elif n < 2: + return [] + s = list(range(3, n + 1, 2)) + mroot = n ** 0.5 + half = (n + 1) // 2 - 1 + i = 0 + m = 3 + while m <= mroot: + if s[i]: + j = (m * m - 3) // 2 + s[j] = 0 + while j < half: + s[j] = 0 + j += m + i = i + 1 + m = 2 * i + 3 + return [2] + [x for x in s if x] + + +def test(): + """Run widget test""" + from spyder.utils.qthelpers import qapplication + import inspect + import tempfile + from unittest.mock import MagicMock + + primes_sc = inspect.getsource(primes) + fd, script = tempfile.mkstemp(suffix='.py') + with os.fdopen(fd, 'w') as f: + f.write("# -*- coding: utf-8 -*-" + "\n\n") + f.write(primes_sc + "\n\n") + f.write("primes(100000)") + + plugin_mock = MagicMock() + plugin_mock.CONF_SECTION = 'profiler' + + app = qapplication(test_time=5) + widget = ProfilerWidget('test', plugin=plugin_mock) + widget._setup() + widget.setup() + widget.get_conf('executable', get_python_executable(), + section='main_interpreter') + widget.resize(800, 600) + widget.show() + widget.analyze(script) + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/projects/api.py b/spyder/plugins/projects/api.py index 6c9df2cc59c..e5eae435502 100644 --- a/spyder/plugins/projects/api.py +++ b/spyder/plugins/projects/api.py @@ -1,180 +1,180 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Projects Plugin API. -""" - -# Standard library imports -import os.path as osp -from collections import OrderedDict - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.base import get_project_config_folder -from spyder.plugins.projects.utils.config import (ProjectMultiConfig, - PROJECT_NAME_MAP, - PROJECT_DEFAULTS, - PROJECT_CONF_VERSION, - WORKSPACE) - -# Localization -_ = get_translation("spyder") - - -class BaseProjectType: - """ - Spyder base project. - - This base class must not be used directly, but inherited from. It does not - assume that python is specific to this project. - """ - ID = None - - def __init__(self, root_path, parent_plugin=None): - self.plugin = parent_plugin - self.root_path = root_path - self.open_project_files = [] - self.open_non_project_files = [] - path = osp.join(root_path, get_project_config_folder(), 'config') - self.config = ProjectMultiConfig( - PROJECT_NAME_MAP, - path=path, - defaults=PROJECT_DEFAULTS, - load=True, - version=PROJECT_CONF_VERSION, - backup=True, - raw_mode=True, - remove_obsolete=False, - ) - act_name = self.get_option("project_type") - if not act_name: - self.set_option("project_type", self.ID) - - # --- Helpers - # ------------------------------------------------------------------------- - def get_option(self, option, section=WORKSPACE, default=None): - """Get project configuration option.""" - return self.config.get(section=section, option=option, default=default) - - def set_option(self, option, value, section=WORKSPACE): - """Set project configuration option.""" - self.config.set(section=section, option=option, value=value) - - def set_recent_files(self, recent_files): - """Set a list of files opened by the project.""" - processed_recent_files = [] - for recent_file in recent_files: - if osp.isfile(recent_file): - try: - relative_recent_file = osp.relpath( - recent_file, self.root_path) - processed_recent_files.append(relative_recent_file) - except ValueError: - processed_recent_files.append(recent_file) - - files = list(OrderedDict.fromkeys(processed_recent_files)) - self.set_option("recent_files", files) - - def get_recent_files(self): - """Return a list of files opened by the project.""" - - # Check if recent_files in [main] (Spyder 4) - recent_files = self.get_option("recent_files", 'main', []) - if recent_files: - # Move to [workspace] (Spyder 5) - self.config.remove_option('main', 'recent_files') - self.set_recent_files(recent_files) - else: - recent_files = self.get_option("recent_files", default=[]) - - recent_files = [recent_file if osp.isabs(recent_file) - else osp.join(self.root_path, recent_file) - for recent_file in recent_files] - for recent_file in recent_files[:]: - if not osp.isfile(recent_file): - recent_files.remove(recent_file) - - return list(OrderedDict.fromkeys(recent_files)) - - # --- API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - """ - Provide a human readable version of NAME. - """ - raise NotImplementedError("Must implement a `get_name` method!") - - @staticmethod - def validate_name(path, name): - """ - Validate the project's name. - - Returns - ------- - tuple - The first item (bool) indicates if the name was validated - successfully, and the second item (str) indicates the error - message, if any. - """ - return True, "" - - def create_project(self): - """ - Create a project and do any additional setup for this project type. - - Returns - ------- - tuple - The first item (bool) indicates if the project was created - successfully, and the second item (str) indicates the error - message, if any. - """ - return False, "A ProjectType must define a `create_project` method!" - - def open_project(self): - """ - Open a project and do any additional setup for this project type. - - Returns - ------- - tuple - The first item (bool) indicates if the project was opened - successfully, and the second item (str) indicates the error - message, if any. - """ - return False, "A ProjectType must define an `open_project` method!" - - def close_project(self): - """ - Close a project and do any additional setup for this project type. - - Returns - ------- - tuple - The first item (bool) indicates if the project was closed - successfully, and the second item (str) indicates the error - message, if any. - """ - return False, "A ProjectType must define a `close_project` method!" - - -class EmptyProject(BaseProjectType): - ID = 'empty-project-type' - - @staticmethod - def get_name(): - return _("Empty project") - - def create_project(self): - return True, "" - - def open_project(self): - return True, "" - - def close_project(self): - return True, "" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Projects Plugin API. +""" + +# Standard library imports +import os.path as osp +from collections import OrderedDict + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.base import get_project_config_folder +from spyder.plugins.projects.utils.config import (ProjectMultiConfig, + PROJECT_NAME_MAP, + PROJECT_DEFAULTS, + PROJECT_CONF_VERSION, + WORKSPACE) + +# Localization +_ = get_translation("spyder") + + +class BaseProjectType: + """ + Spyder base project. + + This base class must not be used directly, but inherited from. It does not + assume that python is specific to this project. + """ + ID = None + + def __init__(self, root_path, parent_plugin=None): + self.plugin = parent_plugin + self.root_path = root_path + self.open_project_files = [] + self.open_non_project_files = [] + path = osp.join(root_path, get_project_config_folder(), 'config') + self.config = ProjectMultiConfig( + PROJECT_NAME_MAP, + path=path, + defaults=PROJECT_DEFAULTS, + load=True, + version=PROJECT_CONF_VERSION, + backup=True, + raw_mode=True, + remove_obsolete=False, + ) + act_name = self.get_option("project_type") + if not act_name: + self.set_option("project_type", self.ID) + + # --- Helpers + # ------------------------------------------------------------------------- + def get_option(self, option, section=WORKSPACE, default=None): + """Get project configuration option.""" + return self.config.get(section=section, option=option, default=default) + + def set_option(self, option, value, section=WORKSPACE): + """Set project configuration option.""" + self.config.set(section=section, option=option, value=value) + + def set_recent_files(self, recent_files): + """Set a list of files opened by the project.""" + processed_recent_files = [] + for recent_file in recent_files: + if osp.isfile(recent_file): + try: + relative_recent_file = osp.relpath( + recent_file, self.root_path) + processed_recent_files.append(relative_recent_file) + except ValueError: + processed_recent_files.append(recent_file) + + files = list(OrderedDict.fromkeys(processed_recent_files)) + self.set_option("recent_files", files) + + def get_recent_files(self): + """Return a list of files opened by the project.""" + + # Check if recent_files in [main] (Spyder 4) + recent_files = self.get_option("recent_files", 'main', []) + if recent_files: + # Move to [workspace] (Spyder 5) + self.config.remove_option('main', 'recent_files') + self.set_recent_files(recent_files) + else: + recent_files = self.get_option("recent_files", default=[]) + + recent_files = [recent_file if osp.isabs(recent_file) + else osp.join(self.root_path, recent_file) + for recent_file in recent_files] + for recent_file in recent_files[:]: + if not osp.isfile(recent_file): + recent_files.remove(recent_file) + + return list(OrderedDict.fromkeys(recent_files)) + + # --- API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + """ + Provide a human readable version of NAME. + """ + raise NotImplementedError("Must implement a `get_name` method!") + + @staticmethod + def validate_name(path, name): + """ + Validate the project's name. + + Returns + ------- + tuple + The first item (bool) indicates if the name was validated + successfully, and the second item (str) indicates the error + message, if any. + """ + return True, "" + + def create_project(self): + """ + Create a project and do any additional setup for this project type. + + Returns + ------- + tuple + The first item (bool) indicates if the project was created + successfully, and the second item (str) indicates the error + message, if any. + """ + return False, "A ProjectType must define a `create_project` method!" + + def open_project(self): + """ + Open a project and do any additional setup for this project type. + + Returns + ------- + tuple + The first item (bool) indicates if the project was opened + successfully, and the second item (str) indicates the error + message, if any. + """ + return False, "A ProjectType must define an `open_project` method!" + + def close_project(self): + """ + Close a project and do any additional setup for this project type. + + Returns + ------- + tuple + The first item (bool) indicates if the project was closed + successfully, and the second item (str) indicates the error + message, if any. + """ + return False, "A ProjectType must define a `close_project` method!" + + +class EmptyProject(BaseProjectType): + ID = 'empty-project-type' + + @staticmethod + def get_name(): + return _("Empty project") + + def create_project(self): + return True, "" + + def open_project(self): + return True, "" + + def close_project(self): + return True, "" diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 14fd1a1b3de..90fac1ef201 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -1,1022 +1,1022 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Projects Plugin - -It handles closing, opening and switching among projetcs and also -updating the file tree explorer associated with a project -""" - -# Standard library imports -import configparser -import logging -import os -import os.path as osp -import shutil -from collections import OrderedDict - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import Signal, Slot -from qtpy.QtWidgets import QInputDialog, QMessageBox - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.config.base import (get_home_dir, get_project_config_folder, - running_in_mac_app, running_under_pytest) -from spyder.py3compat import is_text_string, to_text_string -from spyder.utils import encoding -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.plugins.mainmenu.api import ApplicationMenus, ProjectsMenuSections -from spyder.plugins.projects.api import (BaseProjectType, EmptyProject, - WORKSPACE) -from spyder.plugins.projects.utils.watcher import WorkspaceWatcher -from spyder.plugins.projects.widgets.main_widget import ProjectExplorerWidget -from spyder.plugins.projects.widgets.projectdialog import ProjectDialog -from spyder.plugins.completion.api import ( - CompletionRequestTypes, FileChangeType, WorkspaceUpdateKind) -from spyder.plugins.completion.decorators import ( - request, handles, class_register) - -# Localization and logging -_ = get_translation("spyder") -logger = logging.getLogger(__name__) - - -class ProjectsMenuSubmenus: - RecentProjects = 'recent_projects' - - -class ProjectsActions: - NewProject = 'new_project_action' - OpenProject = 'open_project_action' - CloseProject = 'close_project_action' - DeleteProject = 'delete_project_action' - ClearRecentProjects = 'clear_recent_projects_action' - MaxRecent = 'max_recent_action' - - -class RecentProjectsMenuSections: - Recent = 'recent_section' - Extras = 'extras_section' - - -@class_register -class Projects(SpyderDockablePlugin): - """Projects plugin.""" - NAME = 'project_explorer' - CONF_SECTION = NAME - CONF_FILE = False - REQUIRES = [] - OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Editor, - Plugins.MainMenu] - WIDGET_CLASS = ProjectExplorerWidget - - # Signals - sig_project_created = Signal(str, str, object) - """ - This signal is emitted to request the Projects plugin the creation of a - project. - - Parameters - ---------- - project_path: str - Location of project. - project_type: str - Type of project as defined by project types. - project_packages: object - Package to install. Currently not in use. - """ - - sig_project_loaded = Signal(object) - """ - This signal is emitted when a project is loaded. - - Parameters - ---------- - project_path: object - Loaded project path. - """ - - sig_project_closed = Signal((object,), (bool,)) - """ - This signal is emitted when a project is closed. - - Parameters - ---------- - project_path: object - Closed project path (signature 1). - close_project: bool - This is emitted only when closing a project but not when switching - between projects (signature 2). - """ - - sig_pythonpath_changed = Signal() - """ - This signal is emitted when the Python path has changed. - """ - - def __init__(self, parent=None, configuration=None): - """Initialization.""" - super().__init__(parent, configuration) - self.recent_projects = self.get_conf('recent_projects', []) - self.current_active_project = None - self.latest_project = None - self.watcher = WorkspaceWatcher(self) - self.completions_available = False - self.get_widget().setup_project(self.get_active_project_path()) - self.watcher.connect_signals(self) - self._project_types = OrderedDict() - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Projects") - - def get_description(self): - return _("Create Spyder projects and manage their files.") - - def get_icon(self): - return self.create_icon('project') - - def on_initialize(self): - """Register plugin in Spyder's main window""" - widget = self.get_widget() - treewidget = widget.treewidget - - self.ipyconsole = None - self.editor = None - self.completions = None - - treewidget.sig_delete_project.connect(self.delete_project) - treewidget.sig_redirect_stdio_requested.connect( - self.sig_redirect_stdio_requested) - self.sig_switch_to_plugin_requested.connect( - lambda plugin, check: self.show_explorer()) - self.sig_project_loaded.connect(self.update_explorer) - - if self.main: - widget.sig_open_file_requested.connect(self.main.open_file) - self.main.project_path = self.get_pythonpath(at_start=True) - self.sig_project_loaded.connect( - lambda v: self.main.set_window_title()) - self.sig_project_closed.connect( - lambda v: self.main.set_window_title()) - self.main.restore_scrollbar_position.connect( - self.restore_scrollbar_position) - self.sig_pythonpath_changed.connect(self.main.pythonpath_changed) - - self.register_project_type(self, EmptyProject) - self.setup() - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - self.editor = self.get_plugin(Plugins.Editor) - widget = self.get_widget() - treewidget = widget.treewidget - - treewidget.sig_open_file_requested.connect(self.editor.load) - treewidget.sig_removed.connect(self.editor.removed) - treewidget.sig_tree_removed.connect(self.editor.removed_tree) - treewidget.sig_renamed.connect(self.editor.renamed) - treewidget.sig_tree_renamed.connect(self.editor.renamed_tree) - treewidget.sig_module_created.connect(self.editor.new) - treewidget.sig_file_created.connect(self._new_editor) - - self.sig_project_loaded.connect(self._setup_editor_files) - self.sig_project_closed[bool].connect(self._setup_editor_files) - - self.editor.set_projects(self) - self.sig_project_loaded.connect(self._set_path_in_editor) - self.sig_project_closed.connect(self._unset_path_in_editor) - - @on_plugin_available(plugin=Plugins.Completions) - def on_completions_available(self): - self.completions = self.get_plugin(Plugins.Completions) - - # TODO: This is not necessary anymore due to us starting workspace - # services in the editor. However, we could restore it in the future. - # completions.sig_language_completions_available.connect( - # lambda settings, language: - # self.start_workspace_services()) - self.completions.sig_stop_completions.connect( - self.stop_workspace_services) - self.sig_project_loaded.connect(self._add_path_to_completions) - self.sig_project_closed.connect(self._remove_path_from_completions) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipython_console_available(self): - self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) - widget = self.get_widget() - treewidget = widget.treewidget - treewidget.sig_open_interpreter_requested.connect( - self.ipyconsole.create_client_from_path) - treewidget.sig_run_requested.connect(self._run_file_in_ipyconsole) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - main_menu = self.get_plugin(Plugins.MainMenu) - new_project_action = self.get_action(ProjectsActions.NewProject) - open_project_action = self.get_action(ProjectsActions.OpenProject) - - projects_menu = main_menu.get_application_menu( - ApplicationMenus.Projects) - projects_menu.aboutToShow.connect(self.is_invalid_active_project) - - main_menu.add_item_to_application_menu( - new_project_action, - menu_id=ApplicationMenus.Projects, - section=ProjectsMenuSections.New) - - for item in [open_project_action, self.close_project_action, - self.delete_project_action]: - main_menu.add_item_to_application_menu( - item, - menu_id=ApplicationMenus.Projects, - section=ProjectsMenuSections.Open) - - main_menu.add_item_to_application_menu( - self.recent_project_menu, - menu_id=ApplicationMenus.Projects, - section=ProjectsMenuSections.Extras) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - self.editor = self.get_plugin(Plugins.Editor) - widget = self.get_widget() - treewidget = widget.treewidget - - treewidget.sig_open_file_requested.disconnect(self.editor.load) - treewidget.sig_removed.disconnect(self.editor.removed) - treewidget.sig_tree_removed.disconnect(self.editor.removed_tree) - treewidget.sig_renamed.disconnect(self.editor.renamed) - treewidget.sig_tree_renamed.disconnect(self.editor.renamed_tree) - treewidget.sig_module_created.disconnect(self.editor.new) - treewidget.sig_file_created.disconnect(self._new_editor) - - self.sig_project_loaded.disconnect(self._setup_editor_files) - self.sig_project_closed[bool].disconnect(self._setup_editor_files) - self.editor.set_projects(None) - self.sig_project_loaded.disconnect(self._set_path_in_editor) - self.sig_project_closed.disconnect(self._unset_path_in_editor) - - self.editor = None - - @on_plugin_teardown(plugin=Plugins.Completions) - def on_completions_teardown(self): - self.completions = self.get_plugin(Plugins.Completions) - - self.completions.sig_stop_completions.disconnect( - self.stop_workspace_services) - - self.sig_project_loaded.disconnect(self._add_path_to_completions) - self.sig_project_closed.disconnect(self._remove_path_from_completions) - - self.completions = None - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipython_console_teardown(self): - self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) - widget = self.get_widget() - treewidget = widget.treewidget - - treewidget.sig_open_interpreter_requested.disconnect( - self.ipyconsole.create_client_from_path) - treewidget.sig_run_requested.disconnect(self._run_file_in_ipyconsole) - - self._ipython_run_script = None - self.ipyconsole = None - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - main_menu = self.get_plugin(Plugins.MainMenu) - main_menu.remove_application_menu(ApplicationMenus.Projects) - - def setup(self): - """Setup the plugin actions.""" - self.create_action( - ProjectsActions.NewProject, - text=_("New Project..."), - triggered=self.create_new_project) - - self.create_action( - ProjectsActions.OpenProject, - text=_("Open Project..."), - triggered=lambda v: self.open_project()) - - self.close_project_action = self.create_action( - ProjectsActions.CloseProject, - text=_("Close Project"), - triggered=self.close_project) - - self.delete_project_action = self.create_action( - ProjectsActions.DeleteProject, - text=_("Delete Project"), - triggered=self.delete_project) - - self.clear_recent_projects_action = self.create_action( - ProjectsActions.ClearRecentProjects, - text=_("Clear this list"), - triggered=self.clear_recent_projects) - - self.max_recent_action = self.create_action( - ProjectsActions.MaxRecent, - text=_("Maximum number of recent projects..."), - triggered=self.change_max_recent_projects) - - self.recent_project_menu = self.get_widget().create_menu( - ProjectsMenuSubmenus.RecentProjects, - _("Recent Projects") - ) - self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions) - self.setup_menu_actions() - - def setup_menu_actions(self): - """Setup and update the menu actions.""" - if self.recent_projects: - for project in self.recent_projects: - if self.is_valid_project(project): - if os.name == 'nt': - name = project - else: - name = project.replace(get_home_dir(), '~') - try: - action = self.get_action(name) - except KeyError: - action = self.create_action( - name, - text=name, - icon=ima.icon('project'), - triggered=self.build_opener(project), - ) - self.get_widget().add_item_to_menu( - action, - menu=self.recent_project_menu, - section=RecentProjectsMenuSections.Recent) - - for item in [self.clear_recent_projects_action, - self.max_recent_action]: - self.get_widget().add_item_to_menu( - item, - menu=self.recent_project_menu, - section=RecentProjectsMenuSections.Extras) - self.update_project_actions() - - def update_project_actions(self): - """Update actions of the Projects menu""" - if self.recent_projects: - self.clear_recent_projects_action.setEnabled(True) - else: - self.clear_recent_projects_action.setEnabled(False) - - active = bool(self.get_active_project_path()) - self.close_project_action.setEnabled(active) - self.delete_project_action.setEnabled(active) - - def on_close(self, cancelable=False): - """Perform actions before parent main window is closed""" - self.save_config() - self.watcher.stop() - return True - - def unmaximize(self): - """Unmaximize the currently maximized plugin, if not self.""" - if self.main: - if (self.main.last_plugin is not None and - self.main.last_plugin._ismaximized and - self.main.last_plugin is not self): - self.main.maximize_dockwidget() - - def build_opener(self, project): - """Build function opening passed project""" - def opener(*args, **kwargs): - self.open_project(path=project) - return opener - - def on_mainwindow_visible(self): - # Open project passed on the command line or reopen last one. - cli_options = self.get_command_line_options() - initial_cwd = self._main.get_initial_working_directory() - - if cli_options.project is not None: - # This doesn't work for our Mac app - if not running_in_mac_app(): - logger.debug('Opening project from the command line') - project = osp.normpath( - osp.join(initial_cwd, cli_options.project) - ) - self.open_project( - project, - workdir=cli_options.working_directory - ) - else: - logger.debug('Reopening project from last session') - self.reopen_last_project() - - # ------ Public API ------------------------------------------------------- - @Slot() - def create_new_project(self): - """Create new project.""" - self.unmaximize() - dlg = ProjectDialog(self.get_widget(), - project_types=self.get_project_types()) - result = dlg.exec_() - data = dlg.project_data - root_path = data.get("root_path", None) - project_type = data.get("project_type", EmptyProject.ID) - - if result: - self._create_project(root_path, project_type_id=project_type) - dlg.close() - - def _create_project(self, root_path, project_type_id=EmptyProject.ID, - packages=None): - """Create a new project.""" - project_types = self.get_project_types() - if project_type_id in project_types: - project_type_class = project_types[project_type_id] - project = project_type_class( - root_path=root_path, - parent_plugin=project_type_class._PARENT_PLUGIN, - ) - - created_succesfully, message = project.create_project() - if not created_succesfully: - QMessageBox.warning( - self.get_widget(), "Project creation", message) - shutil.rmtree(root_path, ignore_errors=True) - return - - # TODO: In a subsequent PR return a value and emit based on that - self.sig_project_created.emit(root_path, project_type_id, packages) - self.open_project(path=root_path, project=project) - else: - if not running_under_pytest(): - QMessageBox.critical( - self.get_widget(), - _('Error'), - _("{} is not a registered Spyder project " - "type!").format(project_type_id) - ) - - def open_project(self, path=None, project=None, restart_consoles=True, - save_previous_files=True, workdir=None): - """Open the project located in `path`.""" - self.unmaximize() - if path is None: - basedir = get_home_dir() - path = getexistingdirectory(parent=self.get_widget(), - caption=_("Open project"), - basedir=basedir) - path = encoding.to_unicode_from_fs(path) - if not self.is_valid_project(path): - if path: - QMessageBox.critical( - self.get_widget(), - _('Error'), - _("%s is not a Spyder project!") % path, - ) - return - else: - path = encoding.to_unicode_from_fs(path) - - logger.debug(f'Opening project located at {path}') - - if project is None: - project_type_class = self._load_project_type_class(path) - project = project_type_class( - root_path=path, - parent_plugin=project_type_class._PARENT_PLUGIN, - ) - - # A project was not open before - if self.current_active_project is None: - if save_previous_files and self.editor is not None: - self.editor.save_open_files() - - if self.editor is not None: - self.set_conf('last_working_dir', getcwd_or_home(), - section='editor') - - if self.get_conf('visible_if_project_open'): - self.show_explorer() - else: - # We are switching projects - if self.editor is not None: - self.set_project_filenames(self.editor.get_open_filenames()) - - # TODO: Don't emit sig_project_closed when we support - # multiple workspaces. - self.sig_project_closed.emit( - self.current_active_project.root_path) - self.watcher.stop() - - self.current_active_project = project - self.latest_project = project - self.add_to_recent(path) - - self.set_conf('current_project_path', self.get_active_project_path()) - - self.setup_menu_actions() - if workdir and osp.isdir(workdir): - self.sig_project_loaded.emit(workdir) - else: - self.sig_project_loaded.emit(path) - self.sig_pythonpath_changed.emit() - self.watcher.start(path) - - if restart_consoles: - self.restart_consoles() - - open_successfully, message = project.open_project() - if not open_successfully: - QMessageBox.warning(self.get_widget(), "Project open", message) - - def close_project(self): - """ - Close current project and return to a window without an active - project - """ - if self.current_active_project: - self.unmaximize() - if self.editor is not None: - self.set_project_filenames( - self.editor.get_open_filenames()) - path = self.current_active_project.root_path - closed_sucessfully, message = ( - self.current_active_project.close_project()) - if not closed_sucessfully: - QMessageBox.warning( - self.get_widget(), "Project close", message) - - self.current_active_project = None - self.set_conf('current_project_path', None) - self.setup_menu_actions() - - self.sig_project_closed.emit(path) - self.sig_project_closed[bool].emit(True) - self.sig_pythonpath_changed.emit() - - # Hide pane. - self.set_conf('visible_if_project_open', - self.get_widget().isVisible()) - self.toggle_view(False) - - self.get_widget().clear() - self.restart_consoles() - self.watcher.stop() - - def delete_project(self): - """ - Delete the current project without deleting the files in the directory. - """ - if self.current_active_project: - self.unmaximize() - path = self.current_active_project.root_path - buttons = QMessageBox.Yes | QMessageBox.No - answer = QMessageBox.warning( - self.get_widget(), - _("Delete"), - _("Do you really want to delete {filename}?

    " - "Note: This action will only delete the project. " - "Its files are going to be preserved on disk." - ).format(filename=osp.basename(path)), - buttons) - if answer == QMessageBox.Yes: - try: - self.close_project() - shutil.rmtree(osp.join(path, '.spyproject')) - except EnvironmentError as error: - QMessageBox.critical( - self.get_widget(), - _("Project Explorer"), - _("Unable to delete {varpath}" - "

    The error message was:
    {error}" - ).format(varpath=path, error=to_text_string(error))) - - def clear_recent_projects(self): - """Clear the list of recent projects""" - self.recent_projects = [] - self.set_conf('recent_projects', self.recent_projects) - self.setup_menu_actions() - - def change_max_recent_projects(self): - """Change max recent projects entries.""" - - mrf, valid = QInputDialog.getInt( - self.get_widget(), - _('Projects'), - _('Maximum number of recent projects'), - self.get_conf('max_recent_projects'), - 1, - 35) - - if valid: - self.set_conf('max_recent_projects', mrf) - - def get_active_project(self): - """Get the active project""" - return self.current_active_project - - def reopen_last_project(self): - """ - Reopen the active project when Spyder was closed last time, if any - """ - current_project_path = self.get_conf('current_project_path', - default=None) - - # Needs a safer test of project existence! - if ( - current_project_path and - self.is_valid_project(current_project_path) - ): - cli_options = self.get_command_line_options() - self.open_project( - path=current_project_path, - restart_consoles=True, - save_previous_files=False, - workdir=cli_options.working_directory - ) - self.load_config() - - def get_project_filenames(self): - """Get the list of recent filenames of a project""" - recent_files = [] - if self.current_active_project: - recent_files = self.current_active_project.get_recent_files() - elif self.latest_project: - recent_files = self.latest_project.get_recent_files() - return recent_files - - def set_project_filenames(self, recent_files): - """Set the list of open file names in a project""" - if (self.current_active_project - and self.is_valid_project( - self.current_active_project.root_path)): - self.current_active_project.set_recent_files(recent_files) - - def get_active_project_path(self): - """Get path of the active project""" - active_project_path = None - if self.current_active_project: - active_project_path = self.current_active_project.root_path - return active_project_path - - def get_pythonpath(self, at_start=False): - """Get project path as a list to be added to PYTHONPATH""" - if at_start: - current_path = self.get_conf('current_project_path', - default=None) - else: - current_path = self.get_active_project_path() - if current_path is None: - return [] - else: - return [current_path] - - def get_last_working_dir(self): - """Get the path of the last working directory""" - return self.get_conf( - 'last_working_dir', section='editor', default=getcwd_or_home()) - - def save_config(self): - """ - Save configuration: opened projects & tree widget state. - - Also save whether dock widget is visible if a project is open. - """ - self.set_conf('recent_projects', self.recent_projects) - self.set_conf('expanded_state', - self.get_widget().treewidget.get_expanded_state()) - self.set_conf('scrollbar_position', - self.get_widget().treewidget.get_scrollbar_position()) - if self.current_active_project: - self.set_conf('visible_if_project_open', - self.get_widget().isVisible()) - - def load_config(self): - """Load configuration: opened projects & tree widget state""" - expanded_state = self.get_conf('expanded_state', None) - # Sometimes the expanded state option may be truncated in .ini file - # (for an unknown reason), in this case it would be converted to a - # string by 'userconfig': - if is_text_string(expanded_state): - expanded_state = None - if expanded_state is not None: - self.get_widget().treewidget.set_expanded_state(expanded_state) - - def restore_scrollbar_position(self): - """Restoring scrollbar position after main window is visible""" - scrollbar_pos = self.get_conf('scrollbar_position', None) - if scrollbar_pos is not None: - self.get_widget().treewidget.set_scrollbar_position(scrollbar_pos) - - def update_explorer(self): - """Update explorer tree""" - self.get_widget().setup_project(self.get_active_project_path()) - - def show_explorer(self): - """Show the explorer""" - if self.get_widget() is not None: - self.toggle_view(True) - self.get_widget().setVisible(True) - self.get_widget().raise_() - self.get_widget().update() - - def restart_consoles(self): - """Restart consoles when closing, opening and switching projects""" - if self.ipyconsole is not None: - self.ipyconsole.restart() - - def is_valid_project(self, path): - """Check if a directory is a valid Spyder project""" - spy_project_dir = osp.join(path, '.spyproject') - return osp.isdir(path) and osp.isdir(spy_project_dir) - - def is_invalid_active_project(self): - """Handle an invalid active project.""" - try: - path = self.get_active_project_path() - except AttributeError: - return - - if bool(path): - if not self.is_valid_project(path): - if path: - QMessageBox.critical( - self.get_widget(), - _('Error'), - _("{} is no longer a valid Spyder project! " - "Since it is the current active project, it will " - "be closed automatically.").format(path) - ) - self.close_project() - - def add_to_recent(self, project): - """ - Add an entry to recent projetcs - - We only maintain the list of the 10 most recent projects - """ - if project not in self.recent_projects: - self.recent_projects.insert(0, project) - if len(self.recent_projects) > self.get_conf('max_recent_projects'): - self.recent_projects.pop(-1) - - def start_workspace_services(self): - """Enable LSP workspace functionality.""" - self.completions_available = True - if self.current_active_project: - path = self.get_active_project_path() - self.notify_project_open(path) - - def stop_workspace_services(self, _language): - """Disable LSP workspace functionality.""" - self.completions_available = False - - def emit_request(self, method, params, requires_response): - """Send request/notification/response to all LSP servers.""" - params['requires_response'] = requires_response - params['response_instance'] = self - if self.completions: - self.completions.broadcast_notification(method, params) - - @Slot(str, dict) - def handle_response(self, method, params): - """Method dispatcher for LSP requests.""" - if method in self.handler_registry: - handler_name = self.handler_registry[method] - handler = getattr(self, handler_name) - handler(params) - - @Slot(str, str, bool) - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - def file_moved(self, src_file, dest_file, is_dir): - """Notify LSP server about a file that is moved.""" - # LSP specification only considers file updates - if is_dir: - return - - deletion_entry = { - 'file': src_file, - 'kind': FileChangeType.DELETED - } - - addition_entry = { - 'file': dest_file, - 'kind': FileChangeType.CREATED - } - - entries = [addition_entry, deletion_entry] - params = { - 'params': entries - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - @Slot(str, bool) - def file_created(self, src_file, is_dir): - """Notify LSP server about file creation.""" - if is_dir: - return - - params = { - 'params': [{ - 'file': src_file, - 'kind': FileChangeType.CREATED - }] - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - @Slot(str, bool) - def file_deleted(self, src_file, is_dir): - """Notify LSP server about file deletion.""" - if is_dir: - return - - params = { - 'params': [{ - 'file': src_file, - 'kind': FileChangeType.DELETED - }] - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, - requires_response=False) - @Slot(str, bool) - def file_modified(self, src_file, is_dir): - """Notify LSP server about file modification.""" - if is_dir: - return - - params = { - 'params': [{ - 'file': src_file, - 'kind': FileChangeType.CHANGED - }] - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, - requires_response=False) - def notify_project_open(self, path): - """Notify LSP server about project path availability.""" - params = { - 'folder': path, - 'instance': self, - 'kind': 'addition' - } - return params - - @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, - requires_response=False) - def notify_project_close(self, path): - """Notify LSP server to unregister project path.""" - params = { - 'folder': path, - 'instance': self, - 'kind': 'deletion' - } - return params - - @handles(CompletionRequestTypes.WORKSPACE_APPLY_EDIT) - @request(method=CompletionRequestTypes.WORKSPACE_APPLY_EDIT, - requires_response=False) - def handle_workspace_edit(self, params): - """Apply edits to multiple files and notify server about success.""" - edits = params['params'] - response = { - 'applied': False, - 'error': 'Not implemented', - 'language': edits['language'] - } - return response - - # --- New API: - # ------------------------------------------------------------------------ - def _load_project_type_class(self, path): - """ - Load a project type class from the config project folder directly. - - Notes - ----- - This is done directly, since using the EmptyProject would rewrite the - value in the constructor. If the project found has not been registered - as a valid project type, the EmptyProject type will be returned. - - Returns - ------- - spyder.plugins.projects.api.BaseProjectType - Loaded project type class. - """ - fpath = osp.join( - path, get_project_config_folder(), 'config', WORKSPACE + ".ini") - - project_type_id = EmptyProject.ID - if osp.isfile(fpath): - config = configparser.ConfigParser() - - # Catch any possible error when reading the workspace config file. - # Fixes spyder-ide/spyder#17621 - try: - config.read(fpath, encoding='utf-8') - except Exception: - pass - - # This is necessary to catch an error for projects created in - # Spyder 4 or older versions. - # Fixes spyder-ide/spyder17097 - try: - project_type_id = config[WORKSPACE].get( - "project_type", EmptyProject.ID) - except KeyError: - pass - - EmptyProject._PARENT_PLUGIN = self - project_types = self.get_project_types() - project_type_class = project_types.get(project_type_id, EmptyProject) - return project_type_class - - def register_project_type(self, parent_plugin, project_type): - """ - Register a new project type. - - Parameters - ---------- - parent_plugin: spyder.plugins.api.plugins.SpyderPluginV2 - The parent plugin instance making the project type registration. - project_type: spyder.plugins.projects.api.BaseProjectType - Project type to register. - """ - if not issubclass(project_type, BaseProjectType): - raise SpyderAPIError("A project type must subclass " - "BaseProjectType!") - - project_id = project_type.ID - if project_id in self._project_types: - raise SpyderAPIError("A project type id '{}' has already been " - "registered!".format(project_id)) - - project_type._PARENT_PLUGIN = parent_plugin - self._project_types[project_id] = project_type - - def get_project_types(self): - """ - Return available registered project types. - - Returns - ------- - dict - Project types dictionary. Keys are project type IDs and values - are project type classes. - """ - return self._project_types - - # --- Private API - # ------------------------------------------------------------------------- - def _new_editor(self, text): - self.editor.new(text=text) - - def _setup_editor_files(self, __unused): - self.editor.setup_open_files() - - def _set_path_in_editor(self, path): - self.editor.set_current_project_path(path) - - def _unset_path_in_editor(self, __unused): - self.editor.set_current_project_path() - - def _add_path_to_completions(self, path): - self.completions.project_path_update( - path, - update_kind=WorkspaceUpdateKind.ADDITION, - instance=self - ) - - def _remove_path_from_completions(self, path): - self.completions.project_path_update( - path, - update_kind=WorkspaceUpdateKind.DELETION, - instance=self - ) - - def _run_file_in_ipyconsole(self, fname): - self.ipyconsole.run_script( - fname, osp.dirname(fname), '', False, False, False, True, - False - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Projects Plugin + +It handles closing, opening and switching among projetcs and also +updating the file tree explorer associated with a project +""" + +# Standard library imports +import configparser +import logging +import os +import os.path as osp +import shutil +from collections import OrderedDict + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import Signal, Slot +from qtpy.QtWidgets import QInputDialog, QMessageBox + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.config.base import (get_home_dir, get_project_config_folder, + running_in_mac_app, running_under_pytest) +from spyder.py3compat import is_text_string, to_text_string +from spyder.utils import encoding +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.plugins.mainmenu.api import ApplicationMenus, ProjectsMenuSections +from spyder.plugins.projects.api import (BaseProjectType, EmptyProject, + WORKSPACE) +from spyder.plugins.projects.utils.watcher import WorkspaceWatcher +from spyder.plugins.projects.widgets.main_widget import ProjectExplorerWidget +from spyder.plugins.projects.widgets.projectdialog import ProjectDialog +from spyder.plugins.completion.api import ( + CompletionRequestTypes, FileChangeType, WorkspaceUpdateKind) +from spyder.plugins.completion.decorators import ( + request, handles, class_register) + +# Localization and logging +_ = get_translation("spyder") +logger = logging.getLogger(__name__) + + +class ProjectsMenuSubmenus: + RecentProjects = 'recent_projects' + + +class ProjectsActions: + NewProject = 'new_project_action' + OpenProject = 'open_project_action' + CloseProject = 'close_project_action' + DeleteProject = 'delete_project_action' + ClearRecentProjects = 'clear_recent_projects_action' + MaxRecent = 'max_recent_action' + + +class RecentProjectsMenuSections: + Recent = 'recent_section' + Extras = 'extras_section' + + +@class_register +class Projects(SpyderDockablePlugin): + """Projects plugin.""" + NAME = 'project_explorer' + CONF_SECTION = NAME + CONF_FILE = False + REQUIRES = [] + OPTIONAL = [Plugins.Completions, Plugins.IPythonConsole, Plugins.Editor, + Plugins.MainMenu] + WIDGET_CLASS = ProjectExplorerWidget + + # Signals + sig_project_created = Signal(str, str, object) + """ + This signal is emitted to request the Projects plugin the creation of a + project. + + Parameters + ---------- + project_path: str + Location of project. + project_type: str + Type of project as defined by project types. + project_packages: object + Package to install. Currently not in use. + """ + + sig_project_loaded = Signal(object) + """ + This signal is emitted when a project is loaded. + + Parameters + ---------- + project_path: object + Loaded project path. + """ + + sig_project_closed = Signal((object,), (bool,)) + """ + This signal is emitted when a project is closed. + + Parameters + ---------- + project_path: object + Closed project path (signature 1). + close_project: bool + This is emitted only when closing a project but not when switching + between projects (signature 2). + """ + + sig_pythonpath_changed = Signal() + """ + This signal is emitted when the Python path has changed. + """ + + def __init__(self, parent=None, configuration=None): + """Initialization.""" + super().__init__(parent, configuration) + self.recent_projects = self.get_conf('recent_projects', []) + self.current_active_project = None + self.latest_project = None + self.watcher = WorkspaceWatcher(self) + self.completions_available = False + self.get_widget().setup_project(self.get_active_project_path()) + self.watcher.connect_signals(self) + self._project_types = OrderedDict() + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Projects") + + def get_description(self): + return _("Create Spyder projects and manage their files.") + + def get_icon(self): + return self.create_icon('project') + + def on_initialize(self): + """Register plugin in Spyder's main window""" + widget = self.get_widget() + treewidget = widget.treewidget + + self.ipyconsole = None + self.editor = None + self.completions = None + + treewidget.sig_delete_project.connect(self.delete_project) + treewidget.sig_redirect_stdio_requested.connect( + self.sig_redirect_stdio_requested) + self.sig_switch_to_plugin_requested.connect( + lambda plugin, check: self.show_explorer()) + self.sig_project_loaded.connect(self.update_explorer) + + if self.main: + widget.sig_open_file_requested.connect(self.main.open_file) + self.main.project_path = self.get_pythonpath(at_start=True) + self.sig_project_loaded.connect( + lambda v: self.main.set_window_title()) + self.sig_project_closed.connect( + lambda v: self.main.set_window_title()) + self.main.restore_scrollbar_position.connect( + self.restore_scrollbar_position) + self.sig_pythonpath_changed.connect(self.main.pythonpath_changed) + + self.register_project_type(self, EmptyProject) + self.setup() + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + self.editor = self.get_plugin(Plugins.Editor) + widget = self.get_widget() + treewidget = widget.treewidget + + treewidget.sig_open_file_requested.connect(self.editor.load) + treewidget.sig_removed.connect(self.editor.removed) + treewidget.sig_tree_removed.connect(self.editor.removed_tree) + treewidget.sig_renamed.connect(self.editor.renamed) + treewidget.sig_tree_renamed.connect(self.editor.renamed_tree) + treewidget.sig_module_created.connect(self.editor.new) + treewidget.sig_file_created.connect(self._new_editor) + + self.sig_project_loaded.connect(self._setup_editor_files) + self.sig_project_closed[bool].connect(self._setup_editor_files) + + self.editor.set_projects(self) + self.sig_project_loaded.connect(self._set_path_in_editor) + self.sig_project_closed.connect(self._unset_path_in_editor) + + @on_plugin_available(plugin=Plugins.Completions) + def on_completions_available(self): + self.completions = self.get_plugin(Plugins.Completions) + + # TODO: This is not necessary anymore due to us starting workspace + # services in the editor. However, we could restore it in the future. + # completions.sig_language_completions_available.connect( + # lambda settings, language: + # self.start_workspace_services()) + self.completions.sig_stop_completions.connect( + self.stop_workspace_services) + self.sig_project_loaded.connect(self._add_path_to_completions) + self.sig_project_closed.connect(self._remove_path_from_completions) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipython_console_available(self): + self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) + widget = self.get_widget() + treewidget = widget.treewidget + treewidget.sig_open_interpreter_requested.connect( + self.ipyconsole.create_client_from_path) + treewidget.sig_run_requested.connect(self._run_file_in_ipyconsole) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + main_menu = self.get_plugin(Plugins.MainMenu) + new_project_action = self.get_action(ProjectsActions.NewProject) + open_project_action = self.get_action(ProjectsActions.OpenProject) + + projects_menu = main_menu.get_application_menu( + ApplicationMenus.Projects) + projects_menu.aboutToShow.connect(self.is_invalid_active_project) + + main_menu.add_item_to_application_menu( + new_project_action, + menu_id=ApplicationMenus.Projects, + section=ProjectsMenuSections.New) + + for item in [open_project_action, self.close_project_action, + self.delete_project_action]: + main_menu.add_item_to_application_menu( + item, + menu_id=ApplicationMenus.Projects, + section=ProjectsMenuSections.Open) + + main_menu.add_item_to_application_menu( + self.recent_project_menu, + menu_id=ApplicationMenus.Projects, + section=ProjectsMenuSections.Extras) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + self.editor = self.get_plugin(Plugins.Editor) + widget = self.get_widget() + treewidget = widget.treewidget + + treewidget.sig_open_file_requested.disconnect(self.editor.load) + treewidget.sig_removed.disconnect(self.editor.removed) + treewidget.sig_tree_removed.disconnect(self.editor.removed_tree) + treewidget.sig_renamed.disconnect(self.editor.renamed) + treewidget.sig_tree_renamed.disconnect(self.editor.renamed_tree) + treewidget.sig_module_created.disconnect(self.editor.new) + treewidget.sig_file_created.disconnect(self._new_editor) + + self.sig_project_loaded.disconnect(self._setup_editor_files) + self.sig_project_closed[bool].disconnect(self._setup_editor_files) + self.editor.set_projects(None) + self.sig_project_loaded.disconnect(self._set_path_in_editor) + self.sig_project_closed.disconnect(self._unset_path_in_editor) + + self.editor = None + + @on_plugin_teardown(plugin=Plugins.Completions) + def on_completions_teardown(self): + self.completions = self.get_plugin(Plugins.Completions) + + self.completions.sig_stop_completions.disconnect( + self.stop_workspace_services) + + self.sig_project_loaded.disconnect(self._add_path_to_completions) + self.sig_project_closed.disconnect(self._remove_path_from_completions) + + self.completions = None + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipython_console_teardown(self): + self.ipyconsole = self.get_plugin(Plugins.IPythonConsole) + widget = self.get_widget() + treewidget = widget.treewidget + + treewidget.sig_open_interpreter_requested.disconnect( + self.ipyconsole.create_client_from_path) + treewidget.sig_run_requested.disconnect(self._run_file_in_ipyconsole) + + self._ipython_run_script = None + self.ipyconsole = None + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + main_menu = self.get_plugin(Plugins.MainMenu) + main_menu.remove_application_menu(ApplicationMenus.Projects) + + def setup(self): + """Setup the plugin actions.""" + self.create_action( + ProjectsActions.NewProject, + text=_("New Project..."), + triggered=self.create_new_project) + + self.create_action( + ProjectsActions.OpenProject, + text=_("Open Project..."), + triggered=lambda v: self.open_project()) + + self.close_project_action = self.create_action( + ProjectsActions.CloseProject, + text=_("Close Project"), + triggered=self.close_project) + + self.delete_project_action = self.create_action( + ProjectsActions.DeleteProject, + text=_("Delete Project"), + triggered=self.delete_project) + + self.clear_recent_projects_action = self.create_action( + ProjectsActions.ClearRecentProjects, + text=_("Clear this list"), + triggered=self.clear_recent_projects) + + self.max_recent_action = self.create_action( + ProjectsActions.MaxRecent, + text=_("Maximum number of recent projects..."), + triggered=self.change_max_recent_projects) + + self.recent_project_menu = self.get_widget().create_menu( + ProjectsMenuSubmenus.RecentProjects, + _("Recent Projects") + ) + self.recent_project_menu.aboutToShow.connect(self.setup_menu_actions) + self.setup_menu_actions() + + def setup_menu_actions(self): + """Setup and update the menu actions.""" + if self.recent_projects: + for project in self.recent_projects: + if self.is_valid_project(project): + if os.name == 'nt': + name = project + else: + name = project.replace(get_home_dir(), '~') + try: + action = self.get_action(name) + except KeyError: + action = self.create_action( + name, + text=name, + icon=ima.icon('project'), + triggered=self.build_opener(project), + ) + self.get_widget().add_item_to_menu( + action, + menu=self.recent_project_menu, + section=RecentProjectsMenuSections.Recent) + + for item in [self.clear_recent_projects_action, + self.max_recent_action]: + self.get_widget().add_item_to_menu( + item, + menu=self.recent_project_menu, + section=RecentProjectsMenuSections.Extras) + self.update_project_actions() + + def update_project_actions(self): + """Update actions of the Projects menu""" + if self.recent_projects: + self.clear_recent_projects_action.setEnabled(True) + else: + self.clear_recent_projects_action.setEnabled(False) + + active = bool(self.get_active_project_path()) + self.close_project_action.setEnabled(active) + self.delete_project_action.setEnabled(active) + + def on_close(self, cancelable=False): + """Perform actions before parent main window is closed""" + self.save_config() + self.watcher.stop() + return True + + def unmaximize(self): + """Unmaximize the currently maximized plugin, if not self.""" + if self.main: + if (self.main.last_plugin is not None and + self.main.last_plugin._ismaximized and + self.main.last_plugin is not self): + self.main.maximize_dockwidget() + + def build_opener(self, project): + """Build function opening passed project""" + def opener(*args, **kwargs): + self.open_project(path=project) + return opener + + def on_mainwindow_visible(self): + # Open project passed on the command line or reopen last one. + cli_options = self.get_command_line_options() + initial_cwd = self._main.get_initial_working_directory() + + if cli_options.project is not None: + # This doesn't work for our Mac app + if not running_in_mac_app(): + logger.debug('Opening project from the command line') + project = osp.normpath( + osp.join(initial_cwd, cli_options.project) + ) + self.open_project( + project, + workdir=cli_options.working_directory + ) + else: + logger.debug('Reopening project from last session') + self.reopen_last_project() + + # ------ Public API ------------------------------------------------------- + @Slot() + def create_new_project(self): + """Create new project.""" + self.unmaximize() + dlg = ProjectDialog(self.get_widget(), + project_types=self.get_project_types()) + result = dlg.exec_() + data = dlg.project_data + root_path = data.get("root_path", None) + project_type = data.get("project_type", EmptyProject.ID) + + if result: + self._create_project(root_path, project_type_id=project_type) + dlg.close() + + def _create_project(self, root_path, project_type_id=EmptyProject.ID, + packages=None): + """Create a new project.""" + project_types = self.get_project_types() + if project_type_id in project_types: + project_type_class = project_types[project_type_id] + project = project_type_class( + root_path=root_path, + parent_plugin=project_type_class._PARENT_PLUGIN, + ) + + created_succesfully, message = project.create_project() + if not created_succesfully: + QMessageBox.warning( + self.get_widget(), "Project creation", message) + shutil.rmtree(root_path, ignore_errors=True) + return + + # TODO: In a subsequent PR return a value and emit based on that + self.sig_project_created.emit(root_path, project_type_id, packages) + self.open_project(path=root_path, project=project) + else: + if not running_under_pytest(): + QMessageBox.critical( + self.get_widget(), + _('Error'), + _("{} is not a registered Spyder project " + "type!").format(project_type_id) + ) + + def open_project(self, path=None, project=None, restart_consoles=True, + save_previous_files=True, workdir=None): + """Open the project located in `path`.""" + self.unmaximize() + if path is None: + basedir = get_home_dir() + path = getexistingdirectory(parent=self.get_widget(), + caption=_("Open project"), + basedir=basedir) + path = encoding.to_unicode_from_fs(path) + if not self.is_valid_project(path): + if path: + QMessageBox.critical( + self.get_widget(), + _('Error'), + _("%s is not a Spyder project!") % path, + ) + return + else: + path = encoding.to_unicode_from_fs(path) + + logger.debug(f'Opening project located at {path}') + + if project is None: + project_type_class = self._load_project_type_class(path) + project = project_type_class( + root_path=path, + parent_plugin=project_type_class._PARENT_PLUGIN, + ) + + # A project was not open before + if self.current_active_project is None: + if save_previous_files and self.editor is not None: + self.editor.save_open_files() + + if self.editor is not None: + self.set_conf('last_working_dir', getcwd_or_home(), + section='editor') + + if self.get_conf('visible_if_project_open'): + self.show_explorer() + else: + # We are switching projects + if self.editor is not None: + self.set_project_filenames(self.editor.get_open_filenames()) + + # TODO: Don't emit sig_project_closed when we support + # multiple workspaces. + self.sig_project_closed.emit( + self.current_active_project.root_path) + self.watcher.stop() + + self.current_active_project = project + self.latest_project = project + self.add_to_recent(path) + + self.set_conf('current_project_path', self.get_active_project_path()) + + self.setup_menu_actions() + if workdir and osp.isdir(workdir): + self.sig_project_loaded.emit(workdir) + else: + self.sig_project_loaded.emit(path) + self.sig_pythonpath_changed.emit() + self.watcher.start(path) + + if restart_consoles: + self.restart_consoles() + + open_successfully, message = project.open_project() + if not open_successfully: + QMessageBox.warning(self.get_widget(), "Project open", message) + + def close_project(self): + """ + Close current project and return to a window without an active + project + """ + if self.current_active_project: + self.unmaximize() + if self.editor is not None: + self.set_project_filenames( + self.editor.get_open_filenames()) + path = self.current_active_project.root_path + closed_sucessfully, message = ( + self.current_active_project.close_project()) + if not closed_sucessfully: + QMessageBox.warning( + self.get_widget(), "Project close", message) + + self.current_active_project = None + self.set_conf('current_project_path', None) + self.setup_menu_actions() + + self.sig_project_closed.emit(path) + self.sig_project_closed[bool].emit(True) + self.sig_pythonpath_changed.emit() + + # Hide pane. + self.set_conf('visible_if_project_open', + self.get_widget().isVisible()) + self.toggle_view(False) + + self.get_widget().clear() + self.restart_consoles() + self.watcher.stop() + + def delete_project(self): + """ + Delete the current project without deleting the files in the directory. + """ + if self.current_active_project: + self.unmaximize() + path = self.current_active_project.root_path + buttons = QMessageBox.Yes | QMessageBox.No + answer = QMessageBox.warning( + self.get_widget(), + _("Delete"), + _("Do you really want to delete {filename}?

    " + "Note: This action will only delete the project. " + "Its files are going to be preserved on disk." + ).format(filename=osp.basename(path)), + buttons) + if answer == QMessageBox.Yes: + try: + self.close_project() + shutil.rmtree(osp.join(path, '.spyproject')) + except EnvironmentError as error: + QMessageBox.critical( + self.get_widget(), + _("Project Explorer"), + _("Unable to delete {varpath}" + "

    The error message was:
    {error}" + ).format(varpath=path, error=to_text_string(error))) + + def clear_recent_projects(self): + """Clear the list of recent projects""" + self.recent_projects = [] + self.set_conf('recent_projects', self.recent_projects) + self.setup_menu_actions() + + def change_max_recent_projects(self): + """Change max recent projects entries.""" + + mrf, valid = QInputDialog.getInt( + self.get_widget(), + _('Projects'), + _('Maximum number of recent projects'), + self.get_conf('max_recent_projects'), + 1, + 35) + + if valid: + self.set_conf('max_recent_projects', mrf) + + def get_active_project(self): + """Get the active project""" + return self.current_active_project + + def reopen_last_project(self): + """ + Reopen the active project when Spyder was closed last time, if any + """ + current_project_path = self.get_conf('current_project_path', + default=None) + + # Needs a safer test of project existence! + if ( + current_project_path and + self.is_valid_project(current_project_path) + ): + cli_options = self.get_command_line_options() + self.open_project( + path=current_project_path, + restart_consoles=True, + save_previous_files=False, + workdir=cli_options.working_directory + ) + self.load_config() + + def get_project_filenames(self): + """Get the list of recent filenames of a project""" + recent_files = [] + if self.current_active_project: + recent_files = self.current_active_project.get_recent_files() + elif self.latest_project: + recent_files = self.latest_project.get_recent_files() + return recent_files + + def set_project_filenames(self, recent_files): + """Set the list of open file names in a project""" + if (self.current_active_project + and self.is_valid_project( + self.current_active_project.root_path)): + self.current_active_project.set_recent_files(recent_files) + + def get_active_project_path(self): + """Get path of the active project""" + active_project_path = None + if self.current_active_project: + active_project_path = self.current_active_project.root_path + return active_project_path + + def get_pythonpath(self, at_start=False): + """Get project path as a list to be added to PYTHONPATH""" + if at_start: + current_path = self.get_conf('current_project_path', + default=None) + else: + current_path = self.get_active_project_path() + if current_path is None: + return [] + else: + return [current_path] + + def get_last_working_dir(self): + """Get the path of the last working directory""" + return self.get_conf( + 'last_working_dir', section='editor', default=getcwd_or_home()) + + def save_config(self): + """ + Save configuration: opened projects & tree widget state. + + Also save whether dock widget is visible if a project is open. + """ + self.set_conf('recent_projects', self.recent_projects) + self.set_conf('expanded_state', + self.get_widget().treewidget.get_expanded_state()) + self.set_conf('scrollbar_position', + self.get_widget().treewidget.get_scrollbar_position()) + if self.current_active_project: + self.set_conf('visible_if_project_open', + self.get_widget().isVisible()) + + def load_config(self): + """Load configuration: opened projects & tree widget state""" + expanded_state = self.get_conf('expanded_state', None) + # Sometimes the expanded state option may be truncated in .ini file + # (for an unknown reason), in this case it would be converted to a + # string by 'userconfig': + if is_text_string(expanded_state): + expanded_state = None + if expanded_state is not None: + self.get_widget().treewidget.set_expanded_state(expanded_state) + + def restore_scrollbar_position(self): + """Restoring scrollbar position after main window is visible""" + scrollbar_pos = self.get_conf('scrollbar_position', None) + if scrollbar_pos is not None: + self.get_widget().treewidget.set_scrollbar_position(scrollbar_pos) + + def update_explorer(self): + """Update explorer tree""" + self.get_widget().setup_project(self.get_active_project_path()) + + def show_explorer(self): + """Show the explorer""" + if self.get_widget() is not None: + self.toggle_view(True) + self.get_widget().setVisible(True) + self.get_widget().raise_() + self.get_widget().update() + + def restart_consoles(self): + """Restart consoles when closing, opening and switching projects""" + if self.ipyconsole is not None: + self.ipyconsole.restart() + + def is_valid_project(self, path): + """Check if a directory is a valid Spyder project""" + spy_project_dir = osp.join(path, '.spyproject') + return osp.isdir(path) and osp.isdir(spy_project_dir) + + def is_invalid_active_project(self): + """Handle an invalid active project.""" + try: + path = self.get_active_project_path() + except AttributeError: + return + + if bool(path): + if not self.is_valid_project(path): + if path: + QMessageBox.critical( + self.get_widget(), + _('Error'), + _("{} is no longer a valid Spyder project! " + "Since it is the current active project, it will " + "be closed automatically.").format(path) + ) + self.close_project() + + def add_to_recent(self, project): + """ + Add an entry to recent projetcs + + We only maintain the list of the 10 most recent projects + """ + if project not in self.recent_projects: + self.recent_projects.insert(0, project) + if len(self.recent_projects) > self.get_conf('max_recent_projects'): + self.recent_projects.pop(-1) + + def start_workspace_services(self): + """Enable LSP workspace functionality.""" + self.completions_available = True + if self.current_active_project: + path = self.get_active_project_path() + self.notify_project_open(path) + + def stop_workspace_services(self, _language): + """Disable LSP workspace functionality.""" + self.completions_available = False + + def emit_request(self, method, params, requires_response): + """Send request/notification/response to all LSP servers.""" + params['requires_response'] = requires_response + params['response_instance'] = self + if self.completions: + self.completions.broadcast_notification(method, params) + + @Slot(str, dict) + def handle_response(self, method, params): + """Method dispatcher for LSP requests.""" + if method in self.handler_registry: + handler_name = self.handler_registry[method] + handler = getattr(self, handler_name) + handler(params) + + @Slot(str, str, bool) + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + def file_moved(self, src_file, dest_file, is_dir): + """Notify LSP server about a file that is moved.""" + # LSP specification only considers file updates + if is_dir: + return + + deletion_entry = { + 'file': src_file, + 'kind': FileChangeType.DELETED + } + + addition_entry = { + 'file': dest_file, + 'kind': FileChangeType.CREATED + } + + entries = [addition_entry, deletion_entry] + params = { + 'params': entries + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + @Slot(str, bool) + def file_created(self, src_file, is_dir): + """Notify LSP server about file creation.""" + if is_dir: + return + + params = { + 'params': [{ + 'file': src_file, + 'kind': FileChangeType.CREATED + }] + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + @Slot(str, bool) + def file_deleted(self, src_file, is_dir): + """Notify LSP server about file deletion.""" + if is_dir: + return + + params = { + 'params': [{ + 'file': src_file, + 'kind': FileChangeType.DELETED + }] + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_WATCHED_FILES_UPDATE, + requires_response=False) + @Slot(str, bool) + def file_modified(self, src_file, is_dir): + """Notify LSP server about file modification.""" + if is_dir: + return + + params = { + 'params': [{ + 'file': src_file, + 'kind': FileChangeType.CHANGED + }] + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, + requires_response=False) + def notify_project_open(self, path): + """Notify LSP server about project path availability.""" + params = { + 'folder': path, + 'instance': self, + 'kind': 'addition' + } + return params + + @request(method=CompletionRequestTypes.WORKSPACE_FOLDERS_CHANGE, + requires_response=False) + def notify_project_close(self, path): + """Notify LSP server to unregister project path.""" + params = { + 'folder': path, + 'instance': self, + 'kind': 'deletion' + } + return params + + @handles(CompletionRequestTypes.WORKSPACE_APPLY_EDIT) + @request(method=CompletionRequestTypes.WORKSPACE_APPLY_EDIT, + requires_response=False) + def handle_workspace_edit(self, params): + """Apply edits to multiple files and notify server about success.""" + edits = params['params'] + response = { + 'applied': False, + 'error': 'Not implemented', + 'language': edits['language'] + } + return response + + # --- New API: + # ------------------------------------------------------------------------ + def _load_project_type_class(self, path): + """ + Load a project type class from the config project folder directly. + + Notes + ----- + This is done directly, since using the EmptyProject would rewrite the + value in the constructor. If the project found has not been registered + as a valid project type, the EmptyProject type will be returned. + + Returns + ------- + spyder.plugins.projects.api.BaseProjectType + Loaded project type class. + """ + fpath = osp.join( + path, get_project_config_folder(), 'config', WORKSPACE + ".ini") + + project_type_id = EmptyProject.ID + if osp.isfile(fpath): + config = configparser.ConfigParser() + + # Catch any possible error when reading the workspace config file. + # Fixes spyder-ide/spyder#17621 + try: + config.read(fpath, encoding='utf-8') + except Exception: + pass + + # This is necessary to catch an error for projects created in + # Spyder 4 or older versions. + # Fixes spyder-ide/spyder17097 + try: + project_type_id = config[WORKSPACE].get( + "project_type", EmptyProject.ID) + except KeyError: + pass + + EmptyProject._PARENT_PLUGIN = self + project_types = self.get_project_types() + project_type_class = project_types.get(project_type_id, EmptyProject) + return project_type_class + + def register_project_type(self, parent_plugin, project_type): + """ + Register a new project type. + + Parameters + ---------- + parent_plugin: spyder.plugins.api.plugins.SpyderPluginV2 + The parent plugin instance making the project type registration. + project_type: spyder.plugins.projects.api.BaseProjectType + Project type to register. + """ + if not issubclass(project_type, BaseProjectType): + raise SpyderAPIError("A project type must subclass " + "BaseProjectType!") + + project_id = project_type.ID + if project_id in self._project_types: + raise SpyderAPIError("A project type id '{}' has already been " + "registered!".format(project_id)) + + project_type._PARENT_PLUGIN = parent_plugin + self._project_types[project_id] = project_type + + def get_project_types(self): + """ + Return available registered project types. + + Returns + ------- + dict + Project types dictionary. Keys are project type IDs and values + are project type classes. + """ + return self._project_types + + # --- Private API + # ------------------------------------------------------------------------- + def _new_editor(self, text): + self.editor.new(text=text) + + def _setup_editor_files(self, __unused): + self.editor.setup_open_files() + + def _set_path_in_editor(self, path): + self.editor.set_current_project_path(path) + + def _unset_path_in_editor(self, __unused): + self.editor.set_current_project_path() + + def _add_path_to_completions(self, path): + self.completions.project_path_update( + path, + update_kind=WorkspaceUpdateKind.ADDITION, + instance=self + ) + + def _remove_path_from_completions(self, path): + self.completions.project_path_update( + path, + update_kind=WorkspaceUpdateKind.DELETION, + instance=self + ) + + def _run_file_in_ipyconsole(self, fname): + self.ipyconsole.run_script( + fname, osp.dirname(fname), '', False, False, False, True, + False + ) diff --git a/spyder/plugins/projects/utils/config.py b/spyder/plugins/projects/utils/config.py index cd207ba146e..963d82e9a75 100644 --- a/spyder/plugins/projects/utils/config.py +++ b/spyder/plugins/projects/utils/config.py @@ -1,111 +1,111 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- -"""Configuration options for projects.""" - -# Local imports -from spyder.config.user import MultiUserConfig, UserConfig - - -# Constants -PROJECT_FILENAME = '.spyproj' -WORKSPACE = 'workspace' -CODESTYLE = 'codestyle' -ENCODING = 'encoding' -VCS = 'vcs' - - -# Project configuration defaults -PROJECT_DEFAULTS = [ - (WORKSPACE, - {'restore_data_on_startup': True, - 'save_data_on_exit': True, - 'save_history': True, - 'save_non_project_files': False, - } - ), - (CODESTYLE, - {'indentation': True, - 'edge_line': True, - 'edge_line_columns': '79', - } - ), - (VCS, - {'use_version_control': False, - 'version_control_system': '', - } - ), - (ENCODING, - {'text_encoding': 'utf-8', - } - ) -] - - -PROJECT_NAME_MAP = { - # Empty container object means use the rest of defaults - WORKSPACE: [], - # Splitting these files makes sense for projects, we might as well - # apply the same split for the app global config - # These options change on spyder startup or are tied to a specific OS, - # not good for version control - WORKSPACE: [ - (WORKSPACE, [ - 'restore_data_on_startup', - 'save_data_on_exit', - 'save_history', - 'save_non_project_files', - ], - ), - ], - CODESTYLE: [ - (CODESTYLE, [ - 'indentation', - 'edge_line', - 'edge_line_columns', - ], - ), - ], - VCS: [ - (VCS, [ - 'use_version_control', - 'version_control_system', - ], - ), - ], - ENCODING: [ - (ENCODING, [ - 'text_encoding', - ] - ), - ], -} - - -# ============================================================================= -# Config instance -# ============================================================================= -# IMPORTANT NOTES: -# 1. If you want to *change* the default value of a current option, you need to -# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 -# 2. If you want to *remove* options that are no longer needed in our codebase, -# or if you want to *rename* options, then you need to do a MAJOR update in -# version, e.g. from 3.0.0 to 4.0.0 -# 3. You don't need to touch this value if you're just adding a new option -PROJECT_CONF_VERSION = '0.2.0' - - -class ProjectConfig(UserConfig): - """Plugin configuration handler.""" - - -class ProjectMultiConfig(MultiUserConfig): - """Plugin configuration handler with multifile support.""" - DEFAULT_FILE_NAME = WORKSPACE - - def get_config_class(self): - return ProjectConfig +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Configuration options for projects.""" + +# Local imports +from spyder.config.user import MultiUserConfig, UserConfig + + +# Constants +PROJECT_FILENAME = '.spyproj' +WORKSPACE = 'workspace' +CODESTYLE = 'codestyle' +ENCODING = 'encoding' +VCS = 'vcs' + + +# Project configuration defaults +PROJECT_DEFAULTS = [ + (WORKSPACE, + {'restore_data_on_startup': True, + 'save_data_on_exit': True, + 'save_history': True, + 'save_non_project_files': False, + } + ), + (CODESTYLE, + {'indentation': True, + 'edge_line': True, + 'edge_line_columns': '79', + } + ), + (VCS, + {'use_version_control': False, + 'version_control_system': '', + } + ), + (ENCODING, + {'text_encoding': 'utf-8', + } + ) +] + + +PROJECT_NAME_MAP = { + # Empty container object means use the rest of defaults + WORKSPACE: [], + # Splitting these files makes sense for projects, we might as well + # apply the same split for the app global config + # These options change on spyder startup or are tied to a specific OS, + # not good for version control + WORKSPACE: [ + (WORKSPACE, [ + 'restore_data_on_startup', + 'save_data_on_exit', + 'save_history', + 'save_non_project_files', + ], + ), + ], + CODESTYLE: [ + (CODESTYLE, [ + 'indentation', + 'edge_line', + 'edge_line_columns', + ], + ), + ], + VCS: [ + (VCS, [ + 'use_version_control', + 'version_control_system', + ], + ), + ], + ENCODING: [ + (ENCODING, [ + 'text_encoding', + ] + ), + ], +} + + +# ============================================================================= +# Config instance +# ============================================================================= +# IMPORTANT NOTES: +# 1. If you want to *change* the default value of a current option, you need to +# do a MINOR update in config version, e.g. from 3.0.0 to 3.1.0 +# 2. If you want to *remove* options that are no longer needed in our codebase, +# or if you want to *rename* options, then you need to do a MAJOR update in +# version, e.g. from 3.0.0 to 4.0.0 +# 3. You don't need to touch this value if you're just adding a new option +PROJECT_CONF_VERSION = '0.2.0' + + +class ProjectConfig(UserConfig): + """Plugin configuration handler.""" + + +class ProjectMultiConfig(MultiUserConfig): + """Plugin configuration handler with multifile support.""" + DEFAULT_FILE_NAME = WORKSPACE + + def get_config_class(self): + return ProjectConfig diff --git a/spyder/plugins/projects/widgets/__init__.py b/spyder/plugins/projects/widgets/__init__.py index cd58dcb7743..9f1b5513de0 100644 --- a/spyder/plugins/projects/widgets/__init__.py +++ b/spyder/plugins/projects/widgets/__init__.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- diff --git a/spyder/plugins/projects/widgets/projectdialog.py b/spyder/plugins/projects/widgets/projectdialog.py index 3b6db05c680..56d18b7b376 100644 --- a/spyder/plugins/projects/widgets/projectdialog.py +++ b/spyder/plugins/projects/widgets/projectdialog.py @@ -1,269 +1,269 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- -"""Project creation dialog.""" - -# Standard library imports -import errno -import os.path as osp -import sys -import tempfile - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import Qt, Signal -from qtpy.QtWidgets import (QComboBox, QDialog, QDialogButtonBox, QGridLayout, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, - QPushButton, QRadioButton, QVBoxLayout) - -# Local imports -from spyder.config.base import _, get_home_dir -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import create_toolbutton - - -def is_writable(path): - """Check if path has write access""" - try: - testfile = tempfile.TemporaryFile(dir=path) - testfile.close() - except OSError as e: - if e.errno == errno.EACCES: # 13 - return False - return True - - -class ProjectDialog(QDialog): - """Project creation dialog.""" - - sig_project_creation_requested = Signal(str, str, object) - """ - This signal is emitted to request the Projects plugin the creation of a - project. - - Parameters - ---------- - project_path: str - Location of project. - project_type: str - Type of project as defined by project types. - project_packages: object - Package to install. Currently not in use. - """ - - def __init__(self, parent, project_types): - """Project creation dialog.""" - super(ProjectDialog, self).__init__(parent=parent) - self.plugin = parent - self._project_types = project_types - self.project_data = {} - - self.setWindowFlags( - self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - - self.project_name = None - self.location = get_home_dir() - - # Widgets - projects_url = "http://docs.spyder-ide.org/current/panes/projects.html" - self.description_label = QLabel( - _("Select a new or existing directory to create a new Spyder " - "project in it. To learn more about projects, take a look at " - "our documentation.").format(projects_url) - ) - self.description_label.setOpenExternalLinks(True) - self.description_label.setWordWrap(True) - - self.groupbox = QGroupBox() - self.radio_new_dir = QRadioButton(_("New directory")) - self.radio_from_dir = QRadioButton(_("Existing directory")) - - self.label_project_name = QLabel(_('Project name')) - self.label_location = QLabel(_('Location')) - self.label_project_type = QLabel(_('Project type')) - - self.text_project_name = QLineEdit() - self.text_location = QLineEdit(get_home_dir()) - self.combo_project_type = QComboBox() - - self.label_information = QLabel("") - self.label_information.hide() - - self.button_select_location = create_toolbutton( - self, - triggered=self.select_location, - icon=ima.icon('DirOpenIcon'), - tip=_("Select directory") - ) - self.button_cancel = QPushButton(_('Cancel')) - self.button_create = QPushButton(_('Create')) - - self.bbox = QDialogButtonBox(Qt.Horizontal) - self.bbox.addButton(self.button_cancel, QDialogButtonBox.ActionRole) - self.bbox.addButton(self.button_create, QDialogButtonBox.ActionRole) - - # Widget setup - self.radio_new_dir.setChecked(True) - self.text_location.setEnabled(True) - self.text_location.setReadOnly(True) - self.button_cancel.setDefault(True) - self.button_cancel.setAutoDefault(True) - self.button_create.setEnabled(False) - for (id_, name) in [(pt_id, pt.get_name()) for pt_id, pt - in project_types.items()]: - self.combo_project_type.addItem(name, id_) - - self.setWindowTitle(_('Create new project')) - - # Layouts - layout_top = QHBoxLayout() - layout_top.addWidget(self.radio_new_dir) - layout_top.addSpacing(15) - layout_top.addWidget(self.radio_from_dir) - layout_top.addSpacing(200) - self.groupbox.setLayout(layout_top) - - layout_grid = QGridLayout() - layout_grid.addWidget(self.label_project_name, 0, 0) - layout_grid.addWidget(self.text_project_name, 0, 1, 1, 2) - layout_grid.addWidget(self.label_location, 1, 0) - layout_grid.addWidget(self.text_location, 1, 1) - layout_grid.addWidget(self.button_select_location, 1, 2) - layout_grid.addWidget(self.label_project_type, 2, 0) - layout_grid.addWidget(self.combo_project_type, 2, 1, 1, 2) - layout_grid.addWidget(self.label_information, 3, 0, 1, 3) - - layout = QVBoxLayout() - layout.addWidget(self.description_label) - layout.addSpacing(3) - layout.addWidget(self.groupbox) - layout.addSpacing(8) - layout.addLayout(layout_grid) - layout.addSpacing(8) - layout.addWidget(self.bbox) - layout.setSizeConstraint(layout.SetFixedSize) - - self.setLayout(layout) - - # Signals and slots - self.button_create.clicked.connect(self.create_project) - self.button_cancel.clicked.connect(self.close) - self.radio_from_dir.clicked.connect(self.update_location) - self.radio_new_dir.clicked.connect(self.update_location) - self.text_project_name.textChanged.connect(self.update_location) - - def select_location(self): - """Select directory.""" - location = osp.normpath( - getexistingdirectory( - self, - _("Select directory"), - self.location, - ) - ) - - if location and location != '.': - if is_writable(location): - self.location = location - self.text_project_name.setText(osp.basename(location)) - self.update_location() - - def update_location(self, text=''): - """Update text of location and validate it.""" - msg = '' - path_validation = False - path = self.location - name = self.text_project_name.text().strip() - - # Setup - self.text_project_name.setEnabled(self.radio_new_dir.isChecked()) - self.label_information.setText('') - self.label_information.hide() - - if name and self.radio_new_dir.isChecked(): - # Allow to create projects only on new directories. - path = osp.join(self.location, name) - path_validation = not osp.isdir(path) - if not path_validation: - msg = _("This directory already exists!") - elif self.radio_from_dir.isChecked(): - # Allow to create projects in current directories that are not - # Spyder projects. - path = self.location - path_validation = not osp.isdir(osp.join(path, '.spyproject')) - if not path_validation: - msg = _("This directory is already a Spyder project!") - - # Set path in text_location - self.text_location.setText(path) - - # Validate project name with the method from the currently selected - # project. - project_type_id = self.combo_project_type.currentData() - validate_func = self._project_types[project_type_id].validate_name - project_name_validation, project_msg = validate_func(path, name) - if not project_name_validation: - if msg: - msg = msg + '\n\n' + project_msg - else: - msg = project_msg - - # Set message - if msg: - self.label_information.show() - self.label_information.setText('\n' + msg) - - # Allow to create project if validation was successful - validated = path_validation and project_name_validation - self.button_create.setEnabled(validated) - - # Set default state of buttons according to validation - # Fixes spyder-ide/spyder#16745 - if validated: - self.button_create.setDefault(True) - self.button_create.setAutoDefault(True) - else: - self.button_cancel.setDefault(True) - self.button_cancel.setAutoDefault(True) - - def create_project(self): - """Create project.""" - self.project_data = { - "root_path": self.text_location.text(), - "project_type": self.combo_project_type.currentData(), - } - self.sig_project_creation_requested.emit( - self.text_location.text(), - self.combo_project_type.currentData(), - [], - ) - self.accept() - - -def test(): - """Local test.""" - from spyder.utils.qthelpers import qapplication - from spyder.plugins.projects.api import BaseProjectType - - class MockProjectType(BaseProjectType): - - @staticmethod - def get_name(): - return "Boo" - - @staticmethod - def validate_name(path, name): - return False, "BOOM!" - - app = qapplication() - dlg = ProjectDialog(None, {"empty": MockProjectType}) - dlg.show() - sys.exit(app.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- +"""Project creation dialog.""" + +# Standard library imports +import errno +import os.path as osp +import sys +import tempfile + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import (QComboBox, QDialog, QDialogButtonBox, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QRadioButton, QVBoxLayout) + +# Local imports +from spyder.config.base import _, get_home_dir +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import create_toolbutton + + +def is_writable(path): + """Check if path has write access""" + try: + testfile = tempfile.TemporaryFile(dir=path) + testfile.close() + except OSError as e: + if e.errno == errno.EACCES: # 13 + return False + return True + + +class ProjectDialog(QDialog): + """Project creation dialog.""" + + sig_project_creation_requested = Signal(str, str, object) + """ + This signal is emitted to request the Projects plugin the creation of a + project. + + Parameters + ---------- + project_path: str + Location of project. + project_type: str + Type of project as defined by project types. + project_packages: object + Package to install. Currently not in use. + """ + + def __init__(self, parent, project_types): + """Project creation dialog.""" + super(ProjectDialog, self).__init__(parent=parent) + self.plugin = parent + self._project_types = project_types + self.project_data = {} + + self.setWindowFlags( + self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + self.project_name = None + self.location = get_home_dir() + + # Widgets + projects_url = "http://docs.spyder-ide.org/current/panes/projects.html" + self.description_label = QLabel( + _("Select a new or existing directory to create a new Spyder " + "project in it. To learn more about projects, take a look at " + "our documentation.").format(projects_url) + ) + self.description_label.setOpenExternalLinks(True) + self.description_label.setWordWrap(True) + + self.groupbox = QGroupBox() + self.radio_new_dir = QRadioButton(_("New directory")) + self.radio_from_dir = QRadioButton(_("Existing directory")) + + self.label_project_name = QLabel(_('Project name')) + self.label_location = QLabel(_('Location')) + self.label_project_type = QLabel(_('Project type')) + + self.text_project_name = QLineEdit() + self.text_location = QLineEdit(get_home_dir()) + self.combo_project_type = QComboBox() + + self.label_information = QLabel("") + self.label_information.hide() + + self.button_select_location = create_toolbutton( + self, + triggered=self.select_location, + icon=ima.icon('DirOpenIcon'), + tip=_("Select directory") + ) + self.button_cancel = QPushButton(_('Cancel')) + self.button_create = QPushButton(_('Create')) + + self.bbox = QDialogButtonBox(Qt.Horizontal) + self.bbox.addButton(self.button_cancel, QDialogButtonBox.ActionRole) + self.bbox.addButton(self.button_create, QDialogButtonBox.ActionRole) + + # Widget setup + self.radio_new_dir.setChecked(True) + self.text_location.setEnabled(True) + self.text_location.setReadOnly(True) + self.button_cancel.setDefault(True) + self.button_cancel.setAutoDefault(True) + self.button_create.setEnabled(False) + for (id_, name) in [(pt_id, pt.get_name()) for pt_id, pt + in project_types.items()]: + self.combo_project_type.addItem(name, id_) + + self.setWindowTitle(_('Create new project')) + + # Layouts + layout_top = QHBoxLayout() + layout_top.addWidget(self.radio_new_dir) + layout_top.addSpacing(15) + layout_top.addWidget(self.radio_from_dir) + layout_top.addSpacing(200) + self.groupbox.setLayout(layout_top) + + layout_grid = QGridLayout() + layout_grid.addWidget(self.label_project_name, 0, 0) + layout_grid.addWidget(self.text_project_name, 0, 1, 1, 2) + layout_grid.addWidget(self.label_location, 1, 0) + layout_grid.addWidget(self.text_location, 1, 1) + layout_grid.addWidget(self.button_select_location, 1, 2) + layout_grid.addWidget(self.label_project_type, 2, 0) + layout_grid.addWidget(self.combo_project_type, 2, 1, 1, 2) + layout_grid.addWidget(self.label_information, 3, 0, 1, 3) + + layout = QVBoxLayout() + layout.addWidget(self.description_label) + layout.addSpacing(3) + layout.addWidget(self.groupbox) + layout.addSpacing(8) + layout.addLayout(layout_grid) + layout.addSpacing(8) + layout.addWidget(self.bbox) + layout.setSizeConstraint(layout.SetFixedSize) + + self.setLayout(layout) + + # Signals and slots + self.button_create.clicked.connect(self.create_project) + self.button_cancel.clicked.connect(self.close) + self.radio_from_dir.clicked.connect(self.update_location) + self.radio_new_dir.clicked.connect(self.update_location) + self.text_project_name.textChanged.connect(self.update_location) + + def select_location(self): + """Select directory.""" + location = osp.normpath( + getexistingdirectory( + self, + _("Select directory"), + self.location, + ) + ) + + if location and location != '.': + if is_writable(location): + self.location = location + self.text_project_name.setText(osp.basename(location)) + self.update_location() + + def update_location(self, text=''): + """Update text of location and validate it.""" + msg = '' + path_validation = False + path = self.location + name = self.text_project_name.text().strip() + + # Setup + self.text_project_name.setEnabled(self.radio_new_dir.isChecked()) + self.label_information.setText('') + self.label_information.hide() + + if name and self.radio_new_dir.isChecked(): + # Allow to create projects only on new directories. + path = osp.join(self.location, name) + path_validation = not osp.isdir(path) + if not path_validation: + msg = _("This directory already exists!") + elif self.radio_from_dir.isChecked(): + # Allow to create projects in current directories that are not + # Spyder projects. + path = self.location + path_validation = not osp.isdir(osp.join(path, '.spyproject')) + if not path_validation: + msg = _("This directory is already a Spyder project!") + + # Set path in text_location + self.text_location.setText(path) + + # Validate project name with the method from the currently selected + # project. + project_type_id = self.combo_project_type.currentData() + validate_func = self._project_types[project_type_id].validate_name + project_name_validation, project_msg = validate_func(path, name) + if not project_name_validation: + if msg: + msg = msg + '\n\n' + project_msg + else: + msg = project_msg + + # Set message + if msg: + self.label_information.show() + self.label_information.setText('\n' + msg) + + # Allow to create project if validation was successful + validated = path_validation and project_name_validation + self.button_create.setEnabled(validated) + + # Set default state of buttons according to validation + # Fixes spyder-ide/spyder#16745 + if validated: + self.button_create.setDefault(True) + self.button_create.setAutoDefault(True) + else: + self.button_cancel.setDefault(True) + self.button_cancel.setAutoDefault(True) + + def create_project(self): + """Create project.""" + self.project_data = { + "root_path": self.text_location.text(), + "project_type": self.combo_project_type.currentData(), + } + self.sig_project_creation_requested.emit( + self.text_location.text(), + self.combo_project_type.currentData(), + [], + ) + self.accept() + + +def test(): + """Local test.""" + from spyder.utils.qthelpers import qapplication + from spyder.plugins.projects.api import BaseProjectType + + class MockProjectType(BaseProjectType): + + @staticmethod + def get_name(): + return "Boo" + + @staticmethod + def validate_name(path, name): + return False, "BOOM!" + + app = qapplication() + dlg = ProjectDialog(None, {"empty": MockProjectType}) + dlg.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/projects/widgets/projectexplorer.py b/spyder/plugins/projects/widgets/projectexplorer.py index 6988f1fdc45..c7cbb4383ec 100644 --- a/spyder/plugins/projects/widgets/projectexplorer.py +++ b/spyder/plugins/projects/widgets/projectexplorer.py @@ -1,349 +1,349 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Project Explorer""" - -# pylint: disable=C0103 - -# Standard library imports -from __future__ import print_function - -import os -import os.path as osp -import shutil - -# Third party imports -from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal, Slot -from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox - -# Local imports -from spyder.api.translations import get_translation -from spyder.py3compat import to_text_string -from spyder.utils import misc -from spyder.plugins.explorer.widgets.explorer import DirView - -_ = get_translation('spyder') - - -class ProxyModel(QSortFilterProxyModel): - """Proxy model to filter tree view.""" - - PATHS_TO_HIDE = [ - # Useful paths - '.spyproject', - '__pycache__', - '.ipynb_checkpoints', - # VCS paths - '.git', - '.hg', - '.svn', - # Others - '.pytest_cache', - '.DS_Store', - 'Thumbs.db', - '.directory' - ] - - PATHS_TO_SHOW = [ - '.github' - ] - - def __init__(self, parent): - """Initialize the proxy model.""" - super(ProxyModel, self).__init__(parent) - self.root_path = None - self.path_list = [] - self.setDynamicSortFilter(True) - - def setup_filter(self, root_path, path_list): - """ - Setup proxy model filter parameters. - - Parameters - ---------- - root_path: str - Root path of the proxy model. - path_list: list - List with all the paths. - """ - self.root_path = osp.normpath(str(root_path)) - self.path_list = [osp.normpath(str(p)) for p in path_list] - self.invalidateFilter() - - def sort(self, column, order=Qt.AscendingOrder): - """Reimplement Qt method.""" - self.sourceModel().sort(column, order) - - def filterAcceptsRow(self, row, parent_index): - """Reimplement Qt method.""" - if self.root_path is None: - return True - index = self.sourceModel().index(row, 0, parent_index) - path = osp.normcase(osp.normpath( - str(self.sourceModel().filePath(index)))) - - if osp.normcase(self.root_path).startswith(path): - # This is necessary because parent folders need to be scanned - return True - else: - for p in [osp.normcase(p) for p in self.path_list]: - if path == p or path.startswith(p + os.sep): - if not any([path.endswith(os.sep + d) - for d in self.PATHS_TO_SHOW]): - if any([path.endswith(os.sep + d) - for d in self.PATHS_TO_HIDE]): - return False - else: - return True - else: - return True - else: - return False - - def data(self, index, role): - """Show tooltip with full path only for the root directory.""" - if role == Qt.ToolTipRole: - root_dir = self.path_list[0].split(osp.sep)[-1] - if index.data() == root_dir: - return osp.join(self.root_path, root_dir) - return QSortFilterProxyModel.data(self, index, role) - - def type(self, index): - """ - Returns the type of file for the given index. - - Parameters - ---------- - index: int - Given index to search its type. - """ - return self.sourceModel().type(self.mapToSource(index)) - - -class FilteredDirView(DirView): - """Filtered file/directory tree view.""" - def __init__(self, parent=None): - """Initialize the filtered dir view.""" - super().__init__(parent) - self.proxymodel = None - self.setup_proxy_model() - self.root_path = None - - # ---- Model - def setup_proxy_model(self): - """Setup proxy model.""" - self.proxymodel = ProxyModel(self) - self.proxymodel.setSourceModel(self.fsmodel) - - def install_model(self): - """Install proxy model.""" - if self.root_path is not None: - self.setModel(self.proxymodel) - - def set_root_path(self, root_path): - """ - Set root path. - - Parameters - ---------- - root_path: str - New path directory. - """ - self.root_path = root_path - self.install_model() - index = self.fsmodel.setRootPath(root_path) - self.proxymodel.setup_filter(self.root_path, []) - self.setRootIndex(self.proxymodel.mapFromSource(index)) - - def get_index(self, filename): - """ - Return index associated with filename. - - Parameters - ---------- - filename: str - String with the filename. - """ - index = self.fsmodel.index(filename) - if index.isValid() and index.model() is self.fsmodel: - return self.proxymodel.mapFromSource(index) - - def set_folder_names(self, folder_names): - """ - Set folder names - - Parameters - ---------- - folder_names: list - List with the folder names. - """ - assert self.root_path is not None - path_list = [osp.join(self.root_path, dirname) - for dirname in folder_names] - self.proxymodel.setup_filter(self.root_path, path_list) - - def get_filename(self, index): - """ - Return filename from index - - Parameters - ---------- - index: int - Index of the list of filenames - """ - if index: - path = self.fsmodel.filePath(self.proxymodel.mapToSource(index)) - return osp.normpath(str(path)) - - def setup_project_view(self): - """Setup view for projects.""" - for i in [1, 2, 3]: - self.hideColumn(i) - self.setHeaderHidden(True) - - # ---- Events - def directory_clicked(self, dirname, index): - if index and index.isValid(): - if self.get_conf('single_click_to_open'): - state = not self.isExpanded(index) - else: - state = self.isExpanded(index) - self.setExpanded(index, state) - - -class ProjectExplorerTreeWidget(FilteredDirView): - """Explorer tree widget""" - - sig_delete_project = Signal() - - def __init__(self, parent, show_hscrollbar=True): - FilteredDirView.__init__(self, parent) - self.last_folder = None - self.setSelectionMode(FilteredDirView.ExtendedSelection) - self.show_hscrollbar = show_hscrollbar - - # Enable drag & drop events - self.setDragEnabled(True) - self.setDragDropMode(FilteredDirView.DragDrop) - - # ------Public API--------------------------------------------------------- - @Slot(bool) - def toggle_hscrollbar(self, checked): - """Toggle horizontal scrollbar""" - self.set_conf('show_hscrollbar', checked) - self.show_hscrollbar = checked - self.header().setStretchLastSection(not checked) - self.header().setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) - self.header().setSectionResizeMode(QHeaderView.ResizeToContents) - - # ---- Internal drag & drop - def dragMoveEvent(self, event): - """Reimplement Qt method""" - index = self.indexAt(event.pos()) - if index: - dst = self.get_filename(index) - if osp.isdir(dst): - event.acceptProposedAction() - else: - event.ignore() - else: - event.ignore() - - def dropEvent(self, event): - """Reimplement Qt method""" - event.ignore() - action = event.dropAction() - if action not in (Qt.MoveAction, Qt.CopyAction): - return - - # QTreeView must not remove the source items even in MoveAction mode: - # event.setDropAction(Qt.CopyAction) - - dst = self.get_filename(self.indexAt(event.pos())) - yes_to_all, no_to_all = None, None - src_list = [to_text_string(url.toString()) - for url in event.mimeData().urls()] - if len(src_list) > 1: - buttons = (QMessageBox.Yes | QMessageBox.YesToAll | - QMessageBox.No | QMessageBox.NoToAll | - QMessageBox.Cancel) - else: - buttons = QMessageBox.Yes | QMessageBox.No - for src in src_list: - if src == dst: - continue - dst_fname = osp.join(dst, osp.basename(src)) - if osp.exists(dst_fname): - if yes_to_all is not None or no_to_all is not None: - if no_to_all: - continue - elif osp.isfile(dst_fname): - answer = QMessageBox.warning( - self, - _('Project explorer'), - _('File %s already exists.
    ' - 'Do you want to overwrite it?') % dst_fname, - buttons - ) - - if answer == QMessageBox.No: - continue - elif answer == QMessageBox.Cancel: - break - elif answer == QMessageBox.YesToAll: - yes_to_all = True - elif answer == QMessageBox.NoToAll: - no_to_all = True - continue - else: - QMessageBox.critical( - self, - _('Project explorer'), - _('Folder %s already exists.') % dst_fname, - QMessageBox.Ok - ) - event.setDropAction(Qt.CopyAction) - return - try: - if action == Qt.CopyAction: - if osp.isfile(src): - shutil.copy(src, dst) - else: - shutil.copytree(src, dst) - else: - if osp.isfile(src): - misc.move_file(src, dst) - else: - shutil.move(src, dst) - self.parent_widget.removed.emit(src) - except EnvironmentError as error: - if action == Qt.CopyAction: - action_str = _('copy') - else: - action_str = _('move') - QMessageBox.critical( - self, - _("Project Explorer"), - _("Unable to %s %s" - "

    Error message:
    %s") % (action_str, src, - str(error)) - ) - - @Slot() - def delete(self, fnames=None): - """Delete files""" - if fnames is None: - fnames = self.get_selected_filenames() - multiple = len(fnames) > 1 - yes_to_all = None - for fname in fnames: - if fname == self.proxymodel.path_list[0]: - self.sig_delete_project.emit() - else: - yes_to_all = self.delete_file(fname, multiple, yes_to_all) - if yes_to_all is not None and not yes_to_all: - # Canceled - break +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Project Explorer""" + +# pylint: disable=C0103 + +# Standard library imports +from __future__ import print_function + +import os +import os.path as osp +import shutil + +# Third party imports +from qtpy.QtCore import QSortFilterProxyModel, Qt, Signal, Slot +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox + +# Local imports +from spyder.api.translations import get_translation +from spyder.py3compat import to_text_string +from spyder.utils import misc +from spyder.plugins.explorer.widgets.explorer import DirView + +_ = get_translation('spyder') + + +class ProxyModel(QSortFilterProxyModel): + """Proxy model to filter tree view.""" + + PATHS_TO_HIDE = [ + # Useful paths + '.spyproject', + '__pycache__', + '.ipynb_checkpoints', + # VCS paths + '.git', + '.hg', + '.svn', + # Others + '.pytest_cache', + '.DS_Store', + 'Thumbs.db', + '.directory' + ] + + PATHS_TO_SHOW = [ + '.github' + ] + + def __init__(self, parent): + """Initialize the proxy model.""" + super(ProxyModel, self).__init__(parent) + self.root_path = None + self.path_list = [] + self.setDynamicSortFilter(True) + + def setup_filter(self, root_path, path_list): + """ + Setup proxy model filter parameters. + + Parameters + ---------- + root_path: str + Root path of the proxy model. + path_list: list + List with all the paths. + """ + self.root_path = osp.normpath(str(root_path)) + self.path_list = [osp.normpath(str(p)) for p in path_list] + self.invalidateFilter() + + def sort(self, column, order=Qt.AscendingOrder): + """Reimplement Qt method.""" + self.sourceModel().sort(column, order) + + def filterAcceptsRow(self, row, parent_index): + """Reimplement Qt method.""" + if self.root_path is None: + return True + index = self.sourceModel().index(row, 0, parent_index) + path = osp.normcase(osp.normpath( + str(self.sourceModel().filePath(index)))) + + if osp.normcase(self.root_path).startswith(path): + # This is necessary because parent folders need to be scanned + return True + else: + for p in [osp.normcase(p) for p in self.path_list]: + if path == p or path.startswith(p + os.sep): + if not any([path.endswith(os.sep + d) + for d in self.PATHS_TO_SHOW]): + if any([path.endswith(os.sep + d) + for d in self.PATHS_TO_HIDE]): + return False + else: + return True + else: + return True + else: + return False + + def data(self, index, role): + """Show tooltip with full path only for the root directory.""" + if role == Qt.ToolTipRole: + root_dir = self.path_list[0].split(osp.sep)[-1] + if index.data() == root_dir: + return osp.join(self.root_path, root_dir) + return QSortFilterProxyModel.data(self, index, role) + + def type(self, index): + """ + Returns the type of file for the given index. + + Parameters + ---------- + index: int + Given index to search its type. + """ + return self.sourceModel().type(self.mapToSource(index)) + + +class FilteredDirView(DirView): + """Filtered file/directory tree view.""" + def __init__(self, parent=None): + """Initialize the filtered dir view.""" + super().__init__(parent) + self.proxymodel = None + self.setup_proxy_model() + self.root_path = None + + # ---- Model + def setup_proxy_model(self): + """Setup proxy model.""" + self.proxymodel = ProxyModel(self) + self.proxymodel.setSourceModel(self.fsmodel) + + def install_model(self): + """Install proxy model.""" + if self.root_path is not None: + self.setModel(self.proxymodel) + + def set_root_path(self, root_path): + """ + Set root path. + + Parameters + ---------- + root_path: str + New path directory. + """ + self.root_path = root_path + self.install_model() + index = self.fsmodel.setRootPath(root_path) + self.proxymodel.setup_filter(self.root_path, []) + self.setRootIndex(self.proxymodel.mapFromSource(index)) + + def get_index(self, filename): + """ + Return index associated with filename. + + Parameters + ---------- + filename: str + String with the filename. + """ + index = self.fsmodel.index(filename) + if index.isValid() and index.model() is self.fsmodel: + return self.proxymodel.mapFromSource(index) + + def set_folder_names(self, folder_names): + """ + Set folder names + + Parameters + ---------- + folder_names: list + List with the folder names. + """ + assert self.root_path is not None + path_list = [osp.join(self.root_path, dirname) + for dirname in folder_names] + self.proxymodel.setup_filter(self.root_path, path_list) + + def get_filename(self, index): + """ + Return filename from index + + Parameters + ---------- + index: int + Index of the list of filenames + """ + if index: + path = self.fsmodel.filePath(self.proxymodel.mapToSource(index)) + return osp.normpath(str(path)) + + def setup_project_view(self): + """Setup view for projects.""" + for i in [1, 2, 3]: + self.hideColumn(i) + self.setHeaderHidden(True) + + # ---- Events + def directory_clicked(self, dirname, index): + if index and index.isValid(): + if self.get_conf('single_click_to_open'): + state = not self.isExpanded(index) + else: + state = self.isExpanded(index) + self.setExpanded(index, state) + + +class ProjectExplorerTreeWidget(FilteredDirView): + """Explorer tree widget""" + + sig_delete_project = Signal() + + def __init__(self, parent, show_hscrollbar=True): + FilteredDirView.__init__(self, parent) + self.last_folder = None + self.setSelectionMode(FilteredDirView.ExtendedSelection) + self.show_hscrollbar = show_hscrollbar + + # Enable drag & drop events + self.setDragEnabled(True) + self.setDragDropMode(FilteredDirView.DragDrop) + + # ------Public API--------------------------------------------------------- + @Slot(bool) + def toggle_hscrollbar(self, checked): + """Toggle horizontal scrollbar""" + self.set_conf('show_hscrollbar', checked) + self.show_hscrollbar = checked + self.header().setStretchLastSection(not checked) + self.header().setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.header().setSectionResizeMode(QHeaderView.ResizeToContents) + + # ---- Internal drag & drop + def dragMoveEvent(self, event): + """Reimplement Qt method""" + index = self.indexAt(event.pos()) + if index: + dst = self.get_filename(index) + if osp.isdir(dst): + event.acceptProposedAction() + else: + event.ignore() + else: + event.ignore() + + def dropEvent(self, event): + """Reimplement Qt method""" + event.ignore() + action = event.dropAction() + if action not in (Qt.MoveAction, Qt.CopyAction): + return + + # QTreeView must not remove the source items even in MoveAction mode: + # event.setDropAction(Qt.CopyAction) + + dst = self.get_filename(self.indexAt(event.pos())) + yes_to_all, no_to_all = None, None + src_list = [to_text_string(url.toString()) + for url in event.mimeData().urls()] + if len(src_list) > 1: + buttons = (QMessageBox.Yes | QMessageBox.YesToAll | + QMessageBox.No | QMessageBox.NoToAll | + QMessageBox.Cancel) + else: + buttons = QMessageBox.Yes | QMessageBox.No + for src in src_list: + if src == dst: + continue + dst_fname = osp.join(dst, osp.basename(src)) + if osp.exists(dst_fname): + if yes_to_all is not None or no_to_all is not None: + if no_to_all: + continue + elif osp.isfile(dst_fname): + answer = QMessageBox.warning( + self, + _('Project explorer'), + _('File %s already exists.
    ' + 'Do you want to overwrite it?') % dst_fname, + buttons + ) + + if answer == QMessageBox.No: + continue + elif answer == QMessageBox.Cancel: + break + elif answer == QMessageBox.YesToAll: + yes_to_all = True + elif answer == QMessageBox.NoToAll: + no_to_all = True + continue + else: + QMessageBox.critical( + self, + _('Project explorer'), + _('Folder %s already exists.') % dst_fname, + QMessageBox.Ok + ) + event.setDropAction(Qt.CopyAction) + return + try: + if action == Qt.CopyAction: + if osp.isfile(src): + shutil.copy(src, dst) + else: + shutil.copytree(src, dst) + else: + if osp.isfile(src): + misc.move_file(src, dst) + else: + shutil.move(src, dst) + self.parent_widget.removed.emit(src) + except EnvironmentError as error: + if action == Qt.CopyAction: + action_str = _('copy') + else: + action_str = _('move') + QMessageBox.critical( + self, + _("Project Explorer"), + _("Unable to %s %s" + "

    Error message:
    %s") % (action_str, src, + str(error)) + ) + + @Slot() + def delete(self, fnames=None): + """Delete files""" + if fnames is None: + fnames = self.get_selected_filenames() + multiple = len(fnames) > 1 + yes_to_all = None + for fname in fnames: + if fname == self.proxymodel.path_list[0]: + self.sig_delete_project.emit() + else: + yes_to_all = self.delete_file(fname, multiple, yes_to_all) + if yes_to_all is not None and not yes_to_all: + # Canceled + break diff --git a/spyder/plugins/pylint/main_widget.py b/spyder/plugins/pylint/main_widget.py index 5f5cb9df794..6a2293f8f94 100644 --- a/spyder/plugins/pylint/main_widget.py +++ b/spyder/plugins/pylint/main_widget.py @@ -1,985 +1,985 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Pylint widget.""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os -import os.path as osp -import pickle -import re -import sys -import time - -# Third party imports -import pylint -from qtpy.compat import getopenfilename -from qtpy.QtCore import (QByteArray, QProcess, QProcessEnvironment, Signal, - Slot) -from qtpy.QtWidgets import (QInputDialog, QLabel, QMessageBox, QTreeWidgetItem, - QVBoxLayout) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.widgets.main_widget import PluginMainWidget -from spyder.config.base import get_conf_path, running_in_mac_app -from spyder.plugins.pylint.utils import get_pylintrc_path -from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home, get_home_dir -from spyder.utils.palette import QStylePalette, SpyderPalette -from spyder.widgets.comboboxes import (PythonModulesComboBox, - is_module_or_package) -from spyder.widgets.onecolumntree import OneColumnTree, OneColumnTreeActions - -# Localization -_ = get_translation("spyder") - - -# --- Constants -# ---------------------------------------------------------------------------- -PYLINT_VER = pylint.__version__ -MIN_HISTORY_ENTRIES = 5 -MAX_HISTORY_ENTRIES = 100 -DANGER_COLOR = SpyderPalette.COLOR_ERROR_1 -WARNING_COLOR = SpyderPalette.COLOR_WARN_1 -SUCCESS_COLOR = SpyderPalette.COLOR_SUCCESS_1 - - -# TODO: There should be some palette from the appearance plugin so this -# is easier to use -MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 -MAIN_PREVRATE_COLOR = QStylePalette.COLOR_TEXT_1 - - - -class PylintWidgetActions: - ChangeHistory = "change_history_depth_action" - RunCodeAnalysis = "run_analysis_action" - BrowseFile = "browse_action" - ShowLog = "log_action" - - -class PylintWidgetOptionsMenuSections: - Global = "global_section" - Section = "section_section" - History = "history_section" - - -class PylintWidgetMainToolbarSections: - Main = "main_section" - - -class PylintWidgetToolbarItems: - FileComboBox = 'file_combo' - RateLabel = 'rate_label' - DateLabel = 'date_label' - Stretcher1 = 'stretcher_1' - Stretcher2 = 'stretcher_2' - - -# ---- Items -class CategoryItem(QTreeWidgetItem): - """ - Category item for results. - - Notes - ----- - Possible categories are Convention, Refactor, Warning and Error. - """ - - CATEGORIES = { - "Convention": { - 'translation_string': _("Convention"), - 'icon': ima.icon("convention") - }, - "Refactor": { - 'translation_string': _("Refactor"), - 'icon': ima.icon("refactor") - }, - "Warning": { - 'translation_string': _("Warning"), - 'icon': ima.icon("warning") - }, - "Error": { - 'translation_string': _("Error"), - 'icon': ima.icon("error") - } - } - - def __init__(self, parent, category, number_of_messages): - # Messages string to append to category. - if number_of_messages > 1 or number_of_messages == 0: - messages = _('messages') - else: - messages = _('message') - - # Category title. - title = self.CATEGORIES[category]['translation_string'] - title += f" ({number_of_messages} {messages})" - - super().__init__(parent, [title], QTreeWidgetItem.Type) - - # Set icon - icon = self.CATEGORIES[category]['icon'] - self.setIcon(0, icon) - - -# ---- Widgets -# ---------------------------------------------------------------------------- -# TODO: display results on 3 columns instead of 1: msg_id, lineno, message -class ResultsTree(OneColumnTree): - - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - def __init__(self, parent): - super().__init__(parent) - self.filename = None - self.results = None - self.data = None - self.set_title("") - - def activated(self, item): - """Double-click event""" - data = self.data.get(id(item)) - if data is not None: - fname, lineno = data - self.sig_edit_goto_requested.emit(fname, lineno, "") - - def clicked(self, item): - """Click event.""" - if isinstance(item, CategoryItem): - if item.isExpanded(): - self.collapseItem(item) - else: - self.expandItem(item) - else: - self.activated(item) - - def clear_results(self): - self.clear() - self.set_title("") - - def set_results(self, filename, results): - self.filename = filename - self.results = results - self.refresh() - - def refresh(self): - title = _("Results for ") + self.filename - self.set_title(title) - self.clear() - self.data = {} - - # Populating tree - results = ( - ("Convention", self.results["C:"]), - ("Refactor", self.results["R:"]), - ("Warning", self.results["W:"]), - ("Error", self.results["E:"]), - ) - - for category, messages in results: - title_item = CategoryItem(self, category, len(messages)) - if not messages: - title_item.setDisabled(True) - - modules = {} - for message_data in messages: - # If message data is legacy version without message_name - if len(message_data) == 4: - message_data = tuple(list(message_data) + [None]) - - module, lineno, message, msg_id, message_name = message_data - - basename = osp.splitext(osp.basename(self.filename))[0] - if not module.startswith(basename): - # Pylint bug - i_base = module.find(basename) - module = module[i_base:] - - dirname = osp.dirname(self.filename) - if module.startswith(".") or module == basename: - modname = osp.join(dirname, module) - else: - modname = osp.join(dirname, *module.split(".")) - - if osp.isdir(modname): - modname = osp.join(modname, "__init__") - - for ext in (".py", ".pyw"): - if osp.isfile(modname+ext): - modname = modname + ext - break - - if osp.isdir(self.filename): - parent = modules.get(modname) - if parent is None: - item = QTreeWidgetItem(title_item, [module], - QTreeWidgetItem.Type) - item.setIcon(0, ima.icon("python")) - modules[modname] = item - parent = item - else: - parent = title_item - - if len(msg_id) > 1: - if not message_name: - message_string = "{msg_id} " - else: - message_string = "{msg_id} ({message_name}) " - - message_string += "line {lineno}: {message}" - message_string = message_string.format( - msg_id=msg_id, message_name=message_name, - lineno=lineno, message=message) - msg_item = QTreeWidgetItem( - parent, [message_string], QTreeWidgetItem.Type) - msg_item.setIcon(0, ima.icon("arrow")) - self.data[id(msg_item)] = (modname, lineno) - - -class PylintWidget(PluginMainWidget): - """ - Pylint widget. - """ - ENABLE_SPINNER = True - - DATAPATH = get_conf_path("pylint.results") - VERSION = "1.1.0" - - # --- Signals - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - sig_start_analysis_requested = Signal() - """ - This signal will request the plugin to start the analysis. This is to be - able to interact with other plugins, which can only be done at the plugin - level. - """ - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent) - - # Attributes - self._process = None - self.output = None - self.error_output = None - self.filename = None - self.rdata = [] - self.curr_filenames = self.get_conf("history_filenames") - self.code_analysis_action = None - self.browse_action = None - - # Widgets - self.filecombo = PythonModulesComboBox( - self, id_=PylintWidgetToolbarItems.FileComboBox) - - self.ratelabel = QLabel(self) - self.ratelabel.ID = PylintWidgetToolbarItems.RateLabel - - self.datelabel = QLabel(self) - self.datelabel.ID = PylintWidgetToolbarItems.DateLabel - - self.treewidget = ResultsTree(self) - - if osp.isfile(self.DATAPATH): - try: - with open(self.DATAPATH, "rb") as fh: - data = pickle.loads(fh.read()) - - if data[0] == self.VERSION: - self.rdata = data[1:] - except (EOFError, ImportError): - pass - - # Widget setup - self.filecombo.setInsertPolicy(self.filecombo.InsertAtTop) - for fname in self.curr_filenames[::-1]: - self.set_filename(fname) - - # Layout - layout = QVBoxLayout() - layout.addWidget(self.treewidget) - self.setLayout(layout) - - # Signals - self.filecombo.valid.connect(self._check_new_file) - self.treewidget.sig_edit_goto_requested.connect( - self.sig_edit_goto_requested) - - def on_close(self): - self.stop_code_analysis() - - # --- Private API - # ------------------------------------------------------------------------ - @Slot() - def _start(self): - """Start the code analysis.""" - self.start_spinner() - self.output = "" - self.error_output = "" - self._process = process = QProcess(self) - - process.setProcessChannelMode(QProcess.SeparateChannels) - process.setWorkingDirectory(getcwd_or_home()) - process.readyReadStandardOutput.connect(self._read_output) - process.readyReadStandardError.connect( - lambda: self._read_output(error=True)) - process.finished.connect( - lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) - - command_args = self.get_command(self.get_filename()) - processEnvironment = QProcessEnvironment() - processEnvironment.insert("PYTHONIOENCODING", "utf8") - - # Needed due to changes in Pylint 2.14.0 - # See spyder-ide/spyder#18175 - if os.name == 'nt': - home_dir = get_home_dir() - user_profile = os.environ.get("USERPROFILE", home_dir) - processEnvironment.insert("USERPROFILE", user_profile) - - # resolve spyder-ide/spyder#14262 - if running_in_mac_app(): - pyhome = os.environ.get("PYTHONHOME") - processEnvironment.insert("PYTHONHOME", pyhome) - - process.setProcessEnvironment(processEnvironment) - process.start(sys.executable, command_args) - running = process.waitForStarted() - if not running: - self.stop_spinner() - QMessageBox.critical( - self, - _("Error"), - _("Process failed to start"), - ) - - def _read_output(self, error=False): - process = self._process - if error: - process.setReadChannel(QProcess.StandardError) - else: - process.setReadChannel(QProcess.StandardOutput) - - qba = QByteArray() - while process.bytesAvailable(): - if error: - qba += process.readAllStandardError() - else: - qba += process.readAllStandardOutput() - - text = str(qba.data(), "utf-8") - if error: - self.error_output += text - else: - self.output += text - - self.update_actions() - - def _finished(self, exit_code, exit_status): - if not self.output: - self.stop_spinner() - if self.error_output: - QMessageBox.critical( - self, - _("Error"), - self.error_output, - ) - print("pylint error:\n\n" + self.error_output, file=sys.stderr) - return - - filename = self.get_filename() - rate, previous, results = self.parse_output(self.output) - self._save_history() - self.set_data(filename, (time.localtime(), rate, previous, results)) - self.output = self.error_output + self.output - self.show_data(justanalyzed=True) - self.update_actions() - self.stop_spinner() - - def _check_new_file(self): - fname = self.get_filename() - if fname != self.filename: - self.filename = fname - self.show_data() - - def _is_running(self): - process = self._process - return process is not None and process.state() == QProcess.Running - - def _kill_process(self): - self._process.close() - self._process.waitForFinished(1000) - self.stop_spinner() - - def _update_combobox_history(self): - """Change the number of files listed in the history combobox.""" - max_entries = self.get_conf("max_entries") - if self.filecombo.count() > max_entries: - num_elements = self.filecombo.count() - diff = num_elements - max_entries - for __ in range(diff): - num_elements = self.filecombo.count() - self.filecombo.removeItem(num_elements - 1) - self.filecombo.selected() - else: - num_elements = self.filecombo.count() - diff = max_entries - num_elements - for i in range(num_elements, num_elements + diff): - if i >= len(self.curr_filenames): - break - act_filename = self.curr_filenames[i] - self.filecombo.insertItem(i, act_filename) - - def _save_history(self): - """Save the current history filenames.""" - if self.parent: - list_save_files = [] - for fname in self.curr_filenames: - if _("untitled") not in fname: - filename = osp.normpath(fname) - list_save_files.append(fname) - - self.curr_filenames = list_save_files[:MAX_HISTORY_ENTRIES] - self.set_conf("history_filenames", self.curr_filenames) - else: - self.curr_filenames = [] - - # --- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _("Code Analysis") - - def get_focus_widget(self): - return self.treewidget - - def setup(self): - change_history_depth_action = self.create_action( - PylintWidgetActions.ChangeHistory, - text=_("History..."), - tip=_("Set history maximum entries"), - icon=self.create_icon("history"), - triggered=self.change_history_depth, - ) - self.code_analysis_action = self.create_action( - PylintWidgetActions.RunCodeAnalysis, - text=_("Run code analysis"), - tip=_("Run code analysis"), - icon=self.create_icon("run"), - triggered=lambda: self.sig_start_analysis_requested.emit(), - ) - self.browse_action = self.create_action( - PylintWidgetActions.BrowseFile, - text=_("Select Python file"), - tip=_("Select Python file"), - icon=self.create_icon("fileopen"), - triggered=self.select_file, - ) - self.log_action = self.create_action( - PylintWidgetActions.ShowLog, - text=_("Output"), - tip=_("Complete output"), - icon=self.create_icon("log"), - triggered=self.show_log, - ) - - options_menu = self.get_options_menu() - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.CollapseAllAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Global, - ) - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.ExpandAllAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Global, - ) - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.CollapseSelectionAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Section, - ) - self.add_item_to_menu( - self.treewidget.get_action( - OneColumnTreeActions.ExpandSelectionAction), - menu=options_menu, - section=PylintWidgetOptionsMenuSections.Section, - ) - self.add_item_to_menu( - change_history_depth_action, - menu=options_menu, - section=PylintWidgetOptionsMenuSections.History, - ) - - # Update OneColumnTree contextual menu - self.add_item_to_menu( - change_history_depth_action, - menu=self.treewidget.menu, - section=PylintWidgetOptionsMenuSections.History, - ) - self.treewidget.restore_action.setVisible(False) - - toolbar = self.get_main_toolbar() - for item in [self.filecombo, self.browse_action, - self.code_analysis_action]: - self.add_item_to_toolbar( - item, - toolbar, - section=PylintWidgetMainToolbarSections.Main, - ) - - secondary_toolbar = self.create_toolbar("secondary") - for item in [self.ratelabel, - self.create_stretcher( - id_=PylintWidgetToolbarItems.Stretcher1), - self.datelabel, - self.create_stretcher( - id_=PylintWidgetToolbarItems.Stretcher2), - self.log_action]: - self.add_item_to_toolbar( - item, - secondary_toolbar, - section=PylintWidgetMainToolbarSections.Main, - ) - - self.show_data() - - if self.rdata: - self.remove_obsolete_items() - self.filecombo.insertItems(0, self.get_filenames()) - self.code_analysis_action.setEnabled(self.filecombo.is_valid()) - else: - self.code_analysis_action.setEnabled(False) - - # Signals - self.filecombo.valid.connect(self.code_analysis_action.setEnabled) - - @on_conf_change(option=['max_entries', 'history_filenames']) - def on_conf_update(self, option, value): - if option == "max_entries": - self._update_combobox_history() - elif option == "history_filenames": - self.curr_filenames = value - self._update_combobox_history() - - def update_actions(self): - if self._is_running(): - self.code_analysis_action.setIcon(self.create_icon("stop")) - else: - self.code_analysis_action.setIcon(self.create_icon("run")) - - self.remove_obsolete_items() - - def on_close(self): - self.stop_code_analysis() - - # --- Public API - # ------------------------------------------------------------------------ - @Slot() - @Slot(int) - def change_history_depth(self, value=None): - """ - Set history maximum entries. - - Parameters - ---------- - value: int or None, optional - The valur to set the maximum history depth. If no value is - provided, an input dialog will be launched. Default is None. - """ - if value is None: - dialog = QInputDialog(self) - - # Set dialog properties - dialog.setModal(False) - dialog.setWindowTitle(_("History")) - dialog.setLabelText(_("Maximum entries")) - dialog.setInputMode(QInputDialog.IntInput) - dialog.setIntRange(MIN_HISTORY_ENTRIES, MAX_HISTORY_ENTRIES) - dialog.setIntStep(1) - dialog.setIntValue(self.get_conf("max_entries")) - - # Connect slot - dialog.intValueSelected.connect( - lambda value: self.set_conf("max_entries", value)) - - dialog.show() - else: - self.set_conf("max_entries", value) - - def get_filename(self): - """ - Get current filename in combobox. - """ - return str(self.filecombo.currentText()) - - @Slot(str) - def set_filename(self, filename): - """ - Set current filename in combobox. - """ - if self._is_running(): - self._kill_process() - - filename = str(filename) - filename = osp.normpath(filename) # Normalize path for Windows - - # Don't try to reload saved analysis for filename, if filename - # is the one currently displayed. - # Fixes spyder-ide/spyder#13347 - if self.get_filename() == filename: - return - - index, _data = self.get_data(filename) - - if filename not in self.curr_filenames: - self.filecombo.insertItem(0, filename) - self.curr_filenames.insert(0, filename) - self.filecombo.setCurrentIndex(0) - else: - try: - index = self.filecombo.findText(filename) - self.filecombo.removeItem(index) - self.curr_filenames.pop(index) - except IndexError: - self.curr_filenames.remove(filename) - self.filecombo.insertItem(0, filename) - self.curr_filenames.insert(0, filename) - self.filecombo.setCurrentIndex(0) - - num_elements = self.filecombo.count() - if num_elements > self.get_conf("max_entries"): - self.filecombo.removeItem(num_elements - 1) - - self.filecombo.selected() - - def start_code_analysis(self, filename=None): - """ - Perform code analysis for given `filename`. - - If `filename` is None default to current filename in combobox. - - If this method is called while still running it will stop the code - analysis. - """ - if self._is_running(): - self._kill_process() - else: - if filename is not None: - self.set_filename(filename) - - if self.filecombo.is_valid(): - self._start() - - self.update_actions() - - def stop_code_analysis(self): - """ - Stop the code analysis process. - """ - if self._is_running(): - self._kill_process() - - def remove_obsolete_items(self): - """ - Removing obsolete items. - """ - self.rdata = [(filename, data) for filename, data in self.rdata - if is_module_or_package(filename)] - - def get_filenames(self): - """ - Return all filenames for which there is data available. - """ - return [filename for filename, _data in self.rdata] - - def get_data(self, filename): - """ - Get and load code analysis data for given `filename`. - """ - filename = osp.abspath(filename) - for index, (fname, data) in enumerate(self.rdata): - if fname == filename: - return index, data - else: - return None, None - - def set_data(self, filename, data): - """ - Set and save code analysis `data` for given `filename`. - """ - filename = osp.abspath(filename) - index, _data = self.get_data(filename) - if index is not None: - self.rdata.pop(index) - - self.rdata.insert(0, (filename, data)) - - while len(self.rdata) > self.get_conf("max_entries"): - self.rdata.pop(-1) - - with open(self.DATAPATH, "wb") as fh: - pickle.dump([self.VERSION] + self.rdata, fh, 2) - - def show_data(self, justanalyzed=False): - """ - Show data in treewidget. - """ - text_color = MAIN_TEXT_COLOR - prevrate_color = MAIN_PREVRATE_COLOR - - if not justanalyzed: - self.output = None - - self.log_action.setEnabled(self.output is not None - and len(self.output) > 0) - - if self._is_running(): - self._kill_process() - - filename = self.get_filename() - if not filename: - return - - _index, data = self.get_data(filename) - if data is None: - text = _("Source code has not been rated yet.") - self.treewidget.clear_results() - date_text = "" - else: - datetime, rate, previous_rate, results = data - if rate is None: - text = _("Analysis did not succeed " - "(see output for more details).") - self.treewidget.clear_results() - date_text = "" - else: - text_style = "%s " - rate_style = "%s" - prevrate_style = "%s" - color = DANGER_COLOR - if float(rate) > 5.: - color = SUCCESS_COLOR - elif float(rate) > 3.: - color = WARNING_COLOR - - text = _("Global evaluation:") - text = ((text_style % (text_color, text)) - + (rate_style % (color, ("%s/10" % rate)))) - if previous_rate: - text_prun = _("previous run:") - text_prun = " (%s %s/10)" % (text_prun, previous_rate) - text += prevrate_style % (prevrate_color, text_prun) - - self.treewidget.set_results(filename, results) - date = time.strftime("%Y-%m-%d %H:%M:%S", datetime) - date_text = text_style % (text_color, date) - - self.ratelabel.setText(text) - self.datelabel.setText(date_text) - - @Slot() - def show_log(self): - """ - Show output log dialog. - """ - if self.output: - output_dialog = TextEditor( - self.output, - title=_("Code analysis output"), - parent=self, - readonly=True - ) - output_dialog.resize(700, 500) - output_dialog.exec_() - - # --- Python Specific - # ------------------------------------------------------------------------ - def get_pylintrc_path(self, filename): - """ - Get the path to the most proximate pylintrc config to the file. - """ - search_paths = [ - # File"s directory - osp.dirname(filename), - # Working directory - getcwd_or_home(), - # Project directory - self.get_conf("project_dir"), - # Home directory - osp.expanduser("~"), - ] - - return get_pylintrc_path(search_paths=search_paths) - - @Slot() - def select_file(self, filename=None): - """ - Select filename using a open file dialog and set as current filename. - - If `filename` is provided, the dialog is not used. - """ - if filename is None: - self.sig_redirect_stdio_requested.emit(False) - filename, _selfilter = getopenfilename( - self, - _("Select Python file"), - getcwd_or_home(), - _("Python files") + " (*.py ; *.pyw)", - ) - self.sig_redirect_stdio_requested.emit(True) - - if filename: - self.set_filename(filename) - self.start_code_analysis() - - def get_command(self, filename): - """ - Return command to use to run code analysis on given filename - """ - command_args = [] - if PYLINT_VER is not None: - command_args = [ - "-m", - "pylint", - "--output-format=text", - "--msg-template=" - '{msg_id}:{symbol}:{line:3d},{column}: {msg}"', - ] - - pylintrc_path = self.get_pylintrc_path(filename=filename) - if pylintrc_path is not None: - command_args += ["--rcfile={}".format(pylintrc_path)] - - command_args.append(filename) - return command_args - - def parse_output(self, output): - """ - Parse output and return current revious rate and results. - """ - # Convention, Refactor, Warning, Error - results = {"C:": [], "R:": [], "W:": [], "E:": []} - txt_module = "************* Module " - - module = "" # Should not be needed - just in case something goes wrong - for line in output.splitlines(): - if line.startswith(txt_module): - # New module - module = line[len(txt_module):] - continue - # Supporting option include-ids: ("R3873:" instead of "R:") - if not re.match(r"^[CRWE]+([0-9]{4})?:", line): - continue - - items = {} - idx_0 = 0 - idx_1 = 0 - key_names = ["msg_id", "message_name", "line_nb", "message"] - for key_idx, key_name in enumerate(key_names): - if key_idx == len(key_names) - 1: - idx_1 = len(line) - else: - idx_1 = line.find(":", idx_0) - - if idx_1 < 0: - break - - item = line[(idx_0):idx_1] - if not item: - break - - if key_name == "line_nb": - item = int(item.split(",")[0]) - - items[key_name] = item - idx_0 = idx_1 + 1 - else: - pylint_item = (module, items["line_nb"], items["message"], - items["msg_id"], items["message_name"]) - results[line[0] + ":"].append(pylint_item) - - # Rate - rate = None - txt_rate = "Your code has been rated at " - i_rate = output.find(txt_rate) - if i_rate > 0: - i_rate_end = output.find("/10", i_rate) - if i_rate_end > 0: - rate = output[i_rate+len(txt_rate):i_rate_end] - - # Previous run - previous = "" - if rate is not None: - txt_prun = "previous run: " - i_prun = output.find(txt_prun, i_rate_end) - if i_prun > 0: - i_prun_end = output.find("/10", i_prun) - previous = output[i_prun+len(txt_prun):i_prun_end] - - return rate, previous, results - - -# ============================================================================= -# Tests -# ============================================================================= -def test(): - """Run pylint widget test""" - from spyder.utils.qthelpers import qapplication - from unittest.mock import MagicMock - - plugin_mock = MagicMock() - plugin_mock.CONF_SECTION = 'pylint' - - app = qapplication(test_time=20) - widget = PylintWidget(name="pylint", plugin=plugin_mock) - widget._setup() - widget.setup() - widget.resize(640, 480) - widget.show() - widget.start_code_analysis(filename=__file__) - sys.exit(app.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Pylint widget.""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os +import os.path as osp +import pickle +import re +import sys +import time + +# Third party imports +import pylint +from qtpy.compat import getopenfilename +from qtpy.QtCore import (QByteArray, QProcess, QProcessEnvironment, Signal, + Slot) +from qtpy.QtWidgets import (QInputDialog, QLabel, QMessageBox, QTreeWidgetItem, + QVBoxLayout) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.widgets.main_widget import PluginMainWidget +from spyder.config.base import get_conf_path, running_in_mac_app +from spyder.plugins.pylint.utils import get_pylintrc_path +from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home, get_home_dir +from spyder.utils.palette import QStylePalette, SpyderPalette +from spyder.widgets.comboboxes import (PythonModulesComboBox, + is_module_or_package) +from spyder.widgets.onecolumntree import OneColumnTree, OneColumnTreeActions + +# Localization +_ = get_translation("spyder") + + +# --- Constants +# ---------------------------------------------------------------------------- +PYLINT_VER = pylint.__version__ +MIN_HISTORY_ENTRIES = 5 +MAX_HISTORY_ENTRIES = 100 +DANGER_COLOR = SpyderPalette.COLOR_ERROR_1 +WARNING_COLOR = SpyderPalette.COLOR_WARN_1 +SUCCESS_COLOR = SpyderPalette.COLOR_SUCCESS_1 + + +# TODO: There should be some palette from the appearance plugin so this +# is easier to use +MAIN_TEXT_COLOR = QStylePalette.COLOR_TEXT_1 +MAIN_PREVRATE_COLOR = QStylePalette.COLOR_TEXT_1 + + + +class PylintWidgetActions: + ChangeHistory = "change_history_depth_action" + RunCodeAnalysis = "run_analysis_action" + BrowseFile = "browse_action" + ShowLog = "log_action" + + +class PylintWidgetOptionsMenuSections: + Global = "global_section" + Section = "section_section" + History = "history_section" + + +class PylintWidgetMainToolbarSections: + Main = "main_section" + + +class PylintWidgetToolbarItems: + FileComboBox = 'file_combo' + RateLabel = 'rate_label' + DateLabel = 'date_label' + Stretcher1 = 'stretcher_1' + Stretcher2 = 'stretcher_2' + + +# ---- Items +class CategoryItem(QTreeWidgetItem): + """ + Category item for results. + + Notes + ----- + Possible categories are Convention, Refactor, Warning and Error. + """ + + CATEGORIES = { + "Convention": { + 'translation_string': _("Convention"), + 'icon': ima.icon("convention") + }, + "Refactor": { + 'translation_string': _("Refactor"), + 'icon': ima.icon("refactor") + }, + "Warning": { + 'translation_string': _("Warning"), + 'icon': ima.icon("warning") + }, + "Error": { + 'translation_string': _("Error"), + 'icon': ima.icon("error") + } + } + + def __init__(self, parent, category, number_of_messages): + # Messages string to append to category. + if number_of_messages > 1 or number_of_messages == 0: + messages = _('messages') + else: + messages = _('message') + + # Category title. + title = self.CATEGORIES[category]['translation_string'] + title += f" ({number_of_messages} {messages})" + + super().__init__(parent, [title], QTreeWidgetItem.Type) + + # Set icon + icon = self.CATEGORIES[category]['icon'] + self.setIcon(0, icon) + + +# ---- Widgets +# ---------------------------------------------------------------------------- +# TODO: display results on 3 columns instead of 1: msg_id, lineno, message +class ResultsTree(OneColumnTree): + + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + def __init__(self, parent): + super().__init__(parent) + self.filename = None + self.results = None + self.data = None + self.set_title("") + + def activated(self, item): + """Double-click event""" + data = self.data.get(id(item)) + if data is not None: + fname, lineno = data + self.sig_edit_goto_requested.emit(fname, lineno, "") + + def clicked(self, item): + """Click event.""" + if isinstance(item, CategoryItem): + if item.isExpanded(): + self.collapseItem(item) + else: + self.expandItem(item) + else: + self.activated(item) + + def clear_results(self): + self.clear() + self.set_title("") + + def set_results(self, filename, results): + self.filename = filename + self.results = results + self.refresh() + + def refresh(self): + title = _("Results for ") + self.filename + self.set_title(title) + self.clear() + self.data = {} + + # Populating tree + results = ( + ("Convention", self.results["C:"]), + ("Refactor", self.results["R:"]), + ("Warning", self.results["W:"]), + ("Error", self.results["E:"]), + ) + + for category, messages in results: + title_item = CategoryItem(self, category, len(messages)) + if not messages: + title_item.setDisabled(True) + + modules = {} + for message_data in messages: + # If message data is legacy version without message_name + if len(message_data) == 4: + message_data = tuple(list(message_data) + [None]) + + module, lineno, message, msg_id, message_name = message_data + + basename = osp.splitext(osp.basename(self.filename))[0] + if not module.startswith(basename): + # Pylint bug + i_base = module.find(basename) + module = module[i_base:] + + dirname = osp.dirname(self.filename) + if module.startswith(".") or module == basename: + modname = osp.join(dirname, module) + else: + modname = osp.join(dirname, *module.split(".")) + + if osp.isdir(modname): + modname = osp.join(modname, "__init__") + + for ext in (".py", ".pyw"): + if osp.isfile(modname+ext): + modname = modname + ext + break + + if osp.isdir(self.filename): + parent = modules.get(modname) + if parent is None: + item = QTreeWidgetItem(title_item, [module], + QTreeWidgetItem.Type) + item.setIcon(0, ima.icon("python")) + modules[modname] = item + parent = item + else: + parent = title_item + + if len(msg_id) > 1: + if not message_name: + message_string = "{msg_id} " + else: + message_string = "{msg_id} ({message_name}) " + + message_string += "line {lineno}: {message}" + message_string = message_string.format( + msg_id=msg_id, message_name=message_name, + lineno=lineno, message=message) + msg_item = QTreeWidgetItem( + parent, [message_string], QTreeWidgetItem.Type) + msg_item.setIcon(0, ima.icon("arrow")) + self.data[id(msg_item)] = (modname, lineno) + + +class PylintWidget(PluginMainWidget): + """ + Pylint widget. + """ + ENABLE_SPINNER = True + + DATAPATH = get_conf_path("pylint.results") + VERSION = "1.1.0" + + # --- Signals + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + sig_start_analysis_requested = Signal() + """ + This signal will request the plugin to start the analysis. This is to be + able to interact with other plugins, which can only be done at the plugin + level. + """ + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent) + + # Attributes + self._process = None + self.output = None + self.error_output = None + self.filename = None + self.rdata = [] + self.curr_filenames = self.get_conf("history_filenames") + self.code_analysis_action = None + self.browse_action = None + + # Widgets + self.filecombo = PythonModulesComboBox( + self, id_=PylintWidgetToolbarItems.FileComboBox) + + self.ratelabel = QLabel(self) + self.ratelabel.ID = PylintWidgetToolbarItems.RateLabel + + self.datelabel = QLabel(self) + self.datelabel.ID = PylintWidgetToolbarItems.DateLabel + + self.treewidget = ResultsTree(self) + + if osp.isfile(self.DATAPATH): + try: + with open(self.DATAPATH, "rb") as fh: + data = pickle.loads(fh.read()) + + if data[0] == self.VERSION: + self.rdata = data[1:] + except (EOFError, ImportError): + pass + + # Widget setup + self.filecombo.setInsertPolicy(self.filecombo.InsertAtTop) + for fname in self.curr_filenames[::-1]: + self.set_filename(fname) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.treewidget) + self.setLayout(layout) + + # Signals + self.filecombo.valid.connect(self._check_new_file) + self.treewidget.sig_edit_goto_requested.connect( + self.sig_edit_goto_requested) + + def on_close(self): + self.stop_code_analysis() + + # --- Private API + # ------------------------------------------------------------------------ + @Slot() + def _start(self): + """Start the code analysis.""" + self.start_spinner() + self.output = "" + self.error_output = "" + self._process = process = QProcess(self) + + process.setProcessChannelMode(QProcess.SeparateChannels) + process.setWorkingDirectory(getcwd_or_home()) + process.readyReadStandardOutput.connect(self._read_output) + process.readyReadStandardError.connect( + lambda: self._read_output(error=True)) + process.finished.connect( + lambda ec, es=QProcess.ExitStatus: self._finished(ec, es)) + + command_args = self.get_command(self.get_filename()) + processEnvironment = QProcessEnvironment() + processEnvironment.insert("PYTHONIOENCODING", "utf8") + + # Needed due to changes in Pylint 2.14.0 + # See spyder-ide/spyder#18175 + if os.name == 'nt': + home_dir = get_home_dir() + user_profile = os.environ.get("USERPROFILE", home_dir) + processEnvironment.insert("USERPROFILE", user_profile) + + # resolve spyder-ide/spyder#14262 + if running_in_mac_app(): + pyhome = os.environ.get("PYTHONHOME") + processEnvironment.insert("PYTHONHOME", pyhome) + + process.setProcessEnvironment(processEnvironment) + process.start(sys.executable, command_args) + running = process.waitForStarted() + if not running: + self.stop_spinner() + QMessageBox.critical( + self, + _("Error"), + _("Process failed to start"), + ) + + def _read_output(self, error=False): + process = self._process + if error: + process.setReadChannel(QProcess.StandardError) + else: + process.setReadChannel(QProcess.StandardOutput) + + qba = QByteArray() + while process.bytesAvailable(): + if error: + qba += process.readAllStandardError() + else: + qba += process.readAllStandardOutput() + + text = str(qba.data(), "utf-8") + if error: + self.error_output += text + else: + self.output += text + + self.update_actions() + + def _finished(self, exit_code, exit_status): + if not self.output: + self.stop_spinner() + if self.error_output: + QMessageBox.critical( + self, + _("Error"), + self.error_output, + ) + print("pylint error:\n\n" + self.error_output, file=sys.stderr) + return + + filename = self.get_filename() + rate, previous, results = self.parse_output(self.output) + self._save_history() + self.set_data(filename, (time.localtime(), rate, previous, results)) + self.output = self.error_output + self.output + self.show_data(justanalyzed=True) + self.update_actions() + self.stop_spinner() + + def _check_new_file(self): + fname = self.get_filename() + if fname != self.filename: + self.filename = fname + self.show_data() + + def _is_running(self): + process = self._process + return process is not None and process.state() == QProcess.Running + + def _kill_process(self): + self._process.close() + self._process.waitForFinished(1000) + self.stop_spinner() + + def _update_combobox_history(self): + """Change the number of files listed in the history combobox.""" + max_entries = self.get_conf("max_entries") + if self.filecombo.count() > max_entries: + num_elements = self.filecombo.count() + diff = num_elements - max_entries + for __ in range(diff): + num_elements = self.filecombo.count() + self.filecombo.removeItem(num_elements - 1) + self.filecombo.selected() + else: + num_elements = self.filecombo.count() + diff = max_entries - num_elements + for i in range(num_elements, num_elements + diff): + if i >= len(self.curr_filenames): + break + act_filename = self.curr_filenames[i] + self.filecombo.insertItem(i, act_filename) + + def _save_history(self): + """Save the current history filenames.""" + if self.parent: + list_save_files = [] + for fname in self.curr_filenames: + if _("untitled") not in fname: + filename = osp.normpath(fname) + list_save_files.append(fname) + + self.curr_filenames = list_save_files[:MAX_HISTORY_ENTRIES] + self.set_conf("history_filenames", self.curr_filenames) + else: + self.curr_filenames = [] + + # --- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _("Code Analysis") + + def get_focus_widget(self): + return self.treewidget + + def setup(self): + change_history_depth_action = self.create_action( + PylintWidgetActions.ChangeHistory, + text=_("History..."), + tip=_("Set history maximum entries"), + icon=self.create_icon("history"), + triggered=self.change_history_depth, + ) + self.code_analysis_action = self.create_action( + PylintWidgetActions.RunCodeAnalysis, + text=_("Run code analysis"), + tip=_("Run code analysis"), + icon=self.create_icon("run"), + triggered=lambda: self.sig_start_analysis_requested.emit(), + ) + self.browse_action = self.create_action( + PylintWidgetActions.BrowseFile, + text=_("Select Python file"), + tip=_("Select Python file"), + icon=self.create_icon("fileopen"), + triggered=self.select_file, + ) + self.log_action = self.create_action( + PylintWidgetActions.ShowLog, + text=_("Output"), + tip=_("Complete output"), + icon=self.create_icon("log"), + triggered=self.show_log, + ) + + options_menu = self.get_options_menu() + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.CollapseAllAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Global, + ) + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.ExpandAllAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Global, + ) + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.CollapseSelectionAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Section, + ) + self.add_item_to_menu( + self.treewidget.get_action( + OneColumnTreeActions.ExpandSelectionAction), + menu=options_menu, + section=PylintWidgetOptionsMenuSections.Section, + ) + self.add_item_to_menu( + change_history_depth_action, + menu=options_menu, + section=PylintWidgetOptionsMenuSections.History, + ) + + # Update OneColumnTree contextual menu + self.add_item_to_menu( + change_history_depth_action, + menu=self.treewidget.menu, + section=PylintWidgetOptionsMenuSections.History, + ) + self.treewidget.restore_action.setVisible(False) + + toolbar = self.get_main_toolbar() + for item in [self.filecombo, self.browse_action, + self.code_analysis_action]: + self.add_item_to_toolbar( + item, + toolbar, + section=PylintWidgetMainToolbarSections.Main, + ) + + secondary_toolbar = self.create_toolbar("secondary") + for item in [self.ratelabel, + self.create_stretcher( + id_=PylintWidgetToolbarItems.Stretcher1), + self.datelabel, + self.create_stretcher( + id_=PylintWidgetToolbarItems.Stretcher2), + self.log_action]: + self.add_item_to_toolbar( + item, + secondary_toolbar, + section=PylintWidgetMainToolbarSections.Main, + ) + + self.show_data() + + if self.rdata: + self.remove_obsolete_items() + self.filecombo.insertItems(0, self.get_filenames()) + self.code_analysis_action.setEnabled(self.filecombo.is_valid()) + else: + self.code_analysis_action.setEnabled(False) + + # Signals + self.filecombo.valid.connect(self.code_analysis_action.setEnabled) + + @on_conf_change(option=['max_entries', 'history_filenames']) + def on_conf_update(self, option, value): + if option == "max_entries": + self._update_combobox_history() + elif option == "history_filenames": + self.curr_filenames = value + self._update_combobox_history() + + def update_actions(self): + if self._is_running(): + self.code_analysis_action.setIcon(self.create_icon("stop")) + else: + self.code_analysis_action.setIcon(self.create_icon("run")) + + self.remove_obsolete_items() + + def on_close(self): + self.stop_code_analysis() + + # --- Public API + # ------------------------------------------------------------------------ + @Slot() + @Slot(int) + def change_history_depth(self, value=None): + """ + Set history maximum entries. + + Parameters + ---------- + value: int or None, optional + The valur to set the maximum history depth. If no value is + provided, an input dialog will be launched. Default is None. + """ + if value is None: + dialog = QInputDialog(self) + + # Set dialog properties + dialog.setModal(False) + dialog.setWindowTitle(_("History")) + dialog.setLabelText(_("Maximum entries")) + dialog.setInputMode(QInputDialog.IntInput) + dialog.setIntRange(MIN_HISTORY_ENTRIES, MAX_HISTORY_ENTRIES) + dialog.setIntStep(1) + dialog.setIntValue(self.get_conf("max_entries")) + + # Connect slot + dialog.intValueSelected.connect( + lambda value: self.set_conf("max_entries", value)) + + dialog.show() + else: + self.set_conf("max_entries", value) + + def get_filename(self): + """ + Get current filename in combobox. + """ + return str(self.filecombo.currentText()) + + @Slot(str) + def set_filename(self, filename): + """ + Set current filename in combobox. + """ + if self._is_running(): + self._kill_process() + + filename = str(filename) + filename = osp.normpath(filename) # Normalize path for Windows + + # Don't try to reload saved analysis for filename, if filename + # is the one currently displayed. + # Fixes spyder-ide/spyder#13347 + if self.get_filename() == filename: + return + + index, _data = self.get_data(filename) + + if filename not in self.curr_filenames: + self.filecombo.insertItem(0, filename) + self.curr_filenames.insert(0, filename) + self.filecombo.setCurrentIndex(0) + else: + try: + index = self.filecombo.findText(filename) + self.filecombo.removeItem(index) + self.curr_filenames.pop(index) + except IndexError: + self.curr_filenames.remove(filename) + self.filecombo.insertItem(0, filename) + self.curr_filenames.insert(0, filename) + self.filecombo.setCurrentIndex(0) + + num_elements = self.filecombo.count() + if num_elements > self.get_conf("max_entries"): + self.filecombo.removeItem(num_elements - 1) + + self.filecombo.selected() + + def start_code_analysis(self, filename=None): + """ + Perform code analysis for given `filename`. + + If `filename` is None default to current filename in combobox. + + If this method is called while still running it will stop the code + analysis. + """ + if self._is_running(): + self._kill_process() + else: + if filename is not None: + self.set_filename(filename) + + if self.filecombo.is_valid(): + self._start() + + self.update_actions() + + def stop_code_analysis(self): + """ + Stop the code analysis process. + """ + if self._is_running(): + self._kill_process() + + def remove_obsolete_items(self): + """ + Removing obsolete items. + """ + self.rdata = [(filename, data) for filename, data in self.rdata + if is_module_or_package(filename)] + + def get_filenames(self): + """ + Return all filenames for which there is data available. + """ + return [filename for filename, _data in self.rdata] + + def get_data(self, filename): + """ + Get and load code analysis data for given `filename`. + """ + filename = osp.abspath(filename) + for index, (fname, data) in enumerate(self.rdata): + if fname == filename: + return index, data + else: + return None, None + + def set_data(self, filename, data): + """ + Set and save code analysis `data` for given `filename`. + """ + filename = osp.abspath(filename) + index, _data = self.get_data(filename) + if index is not None: + self.rdata.pop(index) + + self.rdata.insert(0, (filename, data)) + + while len(self.rdata) > self.get_conf("max_entries"): + self.rdata.pop(-1) + + with open(self.DATAPATH, "wb") as fh: + pickle.dump([self.VERSION] + self.rdata, fh, 2) + + def show_data(self, justanalyzed=False): + """ + Show data in treewidget. + """ + text_color = MAIN_TEXT_COLOR + prevrate_color = MAIN_PREVRATE_COLOR + + if not justanalyzed: + self.output = None + + self.log_action.setEnabled(self.output is not None + and len(self.output) > 0) + + if self._is_running(): + self._kill_process() + + filename = self.get_filename() + if not filename: + return + + _index, data = self.get_data(filename) + if data is None: + text = _("Source code has not been rated yet.") + self.treewidget.clear_results() + date_text = "" + else: + datetime, rate, previous_rate, results = data + if rate is None: + text = _("Analysis did not succeed " + "(see output for more details).") + self.treewidget.clear_results() + date_text = "" + else: + text_style = "%s " + rate_style = "%s" + prevrate_style = "%s" + color = DANGER_COLOR + if float(rate) > 5.: + color = SUCCESS_COLOR + elif float(rate) > 3.: + color = WARNING_COLOR + + text = _("Global evaluation:") + text = ((text_style % (text_color, text)) + + (rate_style % (color, ("%s/10" % rate)))) + if previous_rate: + text_prun = _("previous run:") + text_prun = " (%s %s/10)" % (text_prun, previous_rate) + text += prevrate_style % (prevrate_color, text_prun) + + self.treewidget.set_results(filename, results) + date = time.strftime("%Y-%m-%d %H:%M:%S", datetime) + date_text = text_style % (text_color, date) + + self.ratelabel.setText(text) + self.datelabel.setText(date_text) + + @Slot() + def show_log(self): + """ + Show output log dialog. + """ + if self.output: + output_dialog = TextEditor( + self.output, + title=_("Code analysis output"), + parent=self, + readonly=True + ) + output_dialog.resize(700, 500) + output_dialog.exec_() + + # --- Python Specific + # ------------------------------------------------------------------------ + def get_pylintrc_path(self, filename): + """ + Get the path to the most proximate pylintrc config to the file. + """ + search_paths = [ + # File"s directory + osp.dirname(filename), + # Working directory + getcwd_or_home(), + # Project directory + self.get_conf("project_dir"), + # Home directory + osp.expanduser("~"), + ] + + return get_pylintrc_path(search_paths=search_paths) + + @Slot() + def select_file(self, filename=None): + """ + Select filename using a open file dialog and set as current filename. + + If `filename` is provided, the dialog is not used. + """ + if filename is None: + self.sig_redirect_stdio_requested.emit(False) + filename, _selfilter = getopenfilename( + self, + _("Select Python file"), + getcwd_or_home(), + _("Python files") + " (*.py ; *.pyw)", + ) + self.sig_redirect_stdio_requested.emit(True) + + if filename: + self.set_filename(filename) + self.start_code_analysis() + + def get_command(self, filename): + """ + Return command to use to run code analysis on given filename + """ + command_args = [] + if PYLINT_VER is not None: + command_args = [ + "-m", + "pylint", + "--output-format=text", + "--msg-template=" + '{msg_id}:{symbol}:{line:3d},{column}: {msg}"', + ] + + pylintrc_path = self.get_pylintrc_path(filename=filename) + if pylintrc_path is not None: + command_args += ["--rcfile={}".format(pylintrc_path)] + + command_args.append(filename) + return command_args + + def parse_output(self, output): + """ + Parse output and return current revious rate and results. + """ + # Convention, Refactor, Warning, Error + results = {"C:": [], "R:": [], "W:": [], "E:": []} + txt_module = "************* Module " + + module = "" # Should not be needed - just in case something goes wrong + for line in output.splitlines(): + if line.startswith(txt_module): + # New module + module = line[len(txt_module):] + continue + # Supporting option include-ids: ("R3873:" instead of "R:") + if not re.match(r"^[CRWE]+([0-9]{4})?:", line): + continue + + items = {} + idx_0 = 0 + idx_1 = 0 + key_names = ["msg_id", "message_name", "line_nb", "message"] + for key_idx, key_name in enumerate(key_names): + if key_idx == len(key_names) - 1: + idx_1 = len(line) + else: + idx_1 = line.find(":", idx_0) + + if idx_1 < 0: + break + + item = line[(idx_0):idx_1] + if not item: + break + + if key_name == "line_nb": + item = int(item.split(",")[0]) + + items[key_name] = item + idx_0 = idx_1 + 1 + else: + pylint_item = (module, items["line_nb"], items["message"], + items["msg_id"], items["message_name"]) + results[line[0] + ":"].append(pylint_item) + + # Rate + rate = None + txt_rate = "Your code has been rated at " + i_rate = output.find(txt_rate) + if i_rate > 0: + i_rate_end = output.find("/10", i_rate) + if i_rate_end > 0: + rate = output[i_rate+len(txt_rate):i_rate_end] + + # Previous run + previous = "" + if rate is not None: + txt_prun = "previous run: " + i_prun = output.find(txt_prun, i_rate_end) + if i_prun > 0: + i_prun_end = output.find("/10", i_prun) + previous = output[i_prun+len(txt_prun):i_prun_end] + + return rate, previous, results + + +# ============================================================================= +# Tests +# ============================================================================= +def test(): + """Run pylint widget test""" + from spyder.utils.qthelpers import qapplication + from unittest.mock import MagicMock + + plugin_mock = MagicMock() + plugin_mock.CONF_SECTION = 'pylint' + + app = qapplication(test_time=20) + widget = PylintWidget(name="pylint", plugin=plugin_mock) + widget._setup() + widget.setup() + widget.resize(640, 480) + widget.show() + widget.start_code_analysis(filename=__file__) + sys.exit(app.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/pylint/plugin.py b/spyder/plugins/pylint/plugin.py index 1adf79faeee..fb82522e9a9 100644 --- a/spyder/plugins/pylint/plugin.py +++ b/spyder/plugins/pylint/plugin.py @@ -1,236 +1,236 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Pylint Code Analysis Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Qt, Signal, Slot - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.utils.programs import is_module_installed -from spyder.plugins.mainmenu.api import ApplicationMenus -from spyder.plugins.pylint.confpage import PylintConfigPage -from spyder.plugins.pylint.main_widget import (PylintWidget, - PylintWidgetActions) - - -# Localization -_ = get_translation("spyder") - - -class PylintActions: - AnalyzeCurrentFile = 'run analysis' - - -class Pylint(SpyderDockablePlugin): - - NAME = "pylint" - WIDGET_CLASS = PylintWidget - CONF_SECTION = NAME - CONF_WIDGET_CLASS = PylintConfigPage - REQUIRES = [Plugins.Preferences, Plugins.Editor] - OPTIONAL = [Plugins.MainMenu, Plugins.Projects] - CONF_FILE = False - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # --- Signals - sig_edit_goto_requested = Signal(str, int, str) - """ - This signal will request to open a file in a given row and column - using a code editor. - - Parameters - ---------- - path: str - Path to file. - row: int - Cursor starting row position. - word: str - Word to select on given row. - """ - - @staticmethod - def get_name(): - return _("Code Analysis") - - def get_description(self): - return _("Run Code Analysis.") - - def get_icon(self): - return self.create_icon("pylint") - - def on_initialize(self): - widget = self.get_widget() - - # Expose widget signals at the plugin level - widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) - widget.sig_redirect_stdio_requested.connect( - self.sig_redirect_stdio_requested) - widget.sig_start_analysis_requested.connect( - lambda: self.start_code_analysis()) - - # Add action to application menus - pylint_act = self.create_action( - PylintActions.AnalyzeCurrentFile, - text=_("Run code analysis"), - tip=_("Run code analysis"), - icon=self.create_icon("pylint"), - triggered=lambda: self.start_code_analysis(), - context=Qt.ApplicationShortcut, - register_shortcut=True - ) - pylint_act.setEnabled(is_module_installed("pylint")) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - - # Connect to Editor - widget.sig_edit_goto_requested.connect(editor.load) - editor.sig_editor_focus_changed.connect(self._set_filename) - - pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) - - # TODO: use new API when editor has migrated - editor.pythonfile_dependent_actions += [pylint_act] - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - widget = self.get_widget() - - # Connect to projects - projects = self.get_plugin(Plugins.Projects) - - projects.sig_project_loaded.connect(self._set_project_dir) - projects.sig_project_closed.connect(self._unset_project_dir) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - - pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) - mainmenu.add_item_to_application_menu( - pylint_act, menu_id=ApplicationMenus.Source) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - widget = self.get_widget() - editor = self.get_plugin(Plugins.Editor) - - # Connect to Editor - widget.sig_edit_goto_requested.disconnect(editor.load) - editor.sig_editor_focus_changed.disconnect(self._set_filename) - - pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) - - # TODO: use new API when editor has migrated - pylint_act.setVisible(False) - editor.pythonfile_dependent_actions.remove(pylint_act) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardown(self): - # Disconnect from projects - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self._set_project_dir) - projects.sig_project_closed.disconnect(self._unset_project_dir) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - PylintActions.AnalyzeCurrentFile, - menu_id=ApplicationMenus.Source - ) - - # --- Private API - # ------------------------------------------------------------------------ - @Slot() - def _set_filename(self): - """ - Set filename without code analysis. - """ - try: - editor = self.get_plugin(Plugins.Editor) - if editor: - self.get_widget().set_filename(editor.get_current_filename()) - except SpyderAPIError: - # Editor was deleted - pass - - def _set_project_dir(self, value): - widget = self.get_widget() - widget.set_conf("project_dir", value) - - def _unset_project_dir(self, _unused): - widget = self.get_widget() - widget.set_conf("project_dir", None) - - # --- Public API - # ------------------------------------------------------------------------ - def change_history_depth(self, value=None): - """ - Change history maximum number of entries. - - Parameters - ---------- - value: int or None, optional - The valur to set the maximum history depth. If no value is - provided, an input dialog will be launched. Default is None. - """ - self.get_widget().change_history_depth(value=value) - - def get_filename(self): - """ - Get current filename in combobox. - """ - return self.get_widget().get_filename() - - def start_code_analysis(self, filename=None): - """ - Perform code analysis for given `filename`. - - If `filename` is None default to current filename in combobox. - - If this method is called while still running it will stop the code - analysis. - """ - editor = self.get_plugin(Plugins.Editor) - if editor: - if self.get_conf("save_before", True) and not editor.save(): - return - - if filename is None: - filename = self.get_widget().get_filename() - - self.switch_to_plugin(force_focus=True) - self.get_widget().start_code_analysis(filename) - - def stop_code_analysis(self): - """ - Stop the code analysis process. - """ - self.get_widget().stop_code_analysis() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Pylint Code Analysis Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Qt, Signal, Slot + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.utils.programs import is_module_installed +from spyder.plugins.mainmenu.api import ApplicationMenus +from spyder.plugins.pylint.confpage import PylintConfigPage +from spyder.plugins.pylint.main_widget import (PylintWidget, + PylintWidgetActions) + + +# Localization +_ = get_translation("spyder") + + +class PylintActions: + AnalyzeCurrentFile = 'run analysis' + + +class Pylint(SpyderDockablePlugin): + + NAME = "pylint" + WIDGET_CLASS = PylintWidget + CONF_SECTION = NAME + CONF_WIDGET_CLASS = PylintConfigPage + REQUIRES = [Plugins.Preferences, Plugins.Editor] + OPTIONAL = [Plugins.MainMenu, Plugins.Projects] + CONF_FILE = False + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # --- Signals + sig_edit_goto_requested = Signal(str, int, str) + """ + This signal will request to open a file in a given row and column + using a code editor. + + Parameters + ---------- + path: str + Path to file. + row: int + Cursor starting row position. + word: str + Word to select on given row. + """ + + @staticmethod + def get_name(): + return _("Code Analysis") + + def get_description(self): + return _("Run Code Analysis.") + + def get_icon(self): + return self.create_icon("pylint") + + def on_initialize(self): + widget = self.get_widget() + + # Expose widget signals at the plugin level + widget.sig_edit_goto_requested.connect(self.sig_edit_goto_requested) + widget.sig_redirect_stdio_requested.connect( + self.sig_redirect_stdio_requested) + widget.sig_start_analysis_requested.connect( + lambda: self.start_code_analysis()) + + # Add action to application menus + pylint_act = self.create_action( + PylintActions.AnalyzeCurrentFile, + text=_("Run code analysis"), + tip=_("Run code analysis"), + icon=self.create_icon("pylint"), + triggered=lambda: self.start_code_analysis(), + context=Qt.ApplicationShortcut, + register_shortcut=True + ) + pylint_act.setEnabled(is_module_installed("pylint")) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + + # Connect to Editor + widget.sig_edit_goto_requested.connect(editor.load) + editor.sig_editor_focus_changed.connect(self._set_filename) + + pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) + + # TODO: use new API when editor has migrated + editor.pythonfile_dependent_actions += [pylint_act] + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + widget = self.get_widget() + + # Connect to projects + projects = self.get_plugin(Plugins.Projects) + + projects.sig_project_loaded.connect(self._set_project_dir) + projects.sig_project_closed.connect(self._unset_project_dir) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + + pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) + mainmenu.add_item_to_application_menu( + pylint_act, menu_id=ApplicationMenus.Source) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + widget = self.get_widget() + editor = self.get_plugin(Plugins.Editor) + + # Connect to Editor + widget.sig_edit_goto_requested.disconnect(editor.load) + editor.sig_editor_focus_changed.disconnect(self._set_filename) + + pylint_act = self.get_action(PylintActions.AnalyzeCurrentFile) + + # TODO: use new API when editor has migrated + pylint_act.setVisible(False) + editor.pythonfile_dependent_actions.remove(pylint_act) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardown(self): + # Disconnect from projects + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self._set_project_dir) + projects.sig_project_closed.disconnect(self._unset_project_dir) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + PylintActions.AnalyzeCurrentFile, + menu_id=ApplicationMenus.Source + ) + + # --- Private API + # ------------------------------------------------------------------------ + @Slot() + def _set_filename(self): + """ + Set filename without code analysis. + """ + try: + editor = self.get_plugin(Plugins.Editor) + if editor: + self.get_widget().set_filename(editor.get_current_filename()) + except SpyderAPIError: + # Editor was deleted + pass + + def _set_project_dir(self, value): + widget = self.get_widget() + widget.set_conf("project_dir", value) + + def _unset_project_dir(self, _unused): + widget = self.get_widget() + widget.set_conf("project_dir", None) + + # --- Public API + # ------------------------------------------------------------------------ + def change_history_depth(self, value=None): + """ + Change history maximum number of entries. + + Parameters + ---------- + value: int or None, optional + The valur to set the maximum history depth. If no value is + provided, an input dialog will be launched. Default is None. + """ + self.get_widget().change_history_depth(value=value) + + def get_filename(self): + """ + Get current filename in combobox. + """ + return self.get_widget().get_filename() + + def start_code_analysis(self, filename=None): + """ + Perform code analysis for given `filename`. + + If `filename` is None default to current filename in combobox. + + If this method is called while still running it will stop the code + analysis. + """ + editor = self.get_plugin(Plugins.Editor) + if editor: + if self.get_conf("save_before", True) and not editor.save(): + return + + if filename is None: + filename = self.get_widget().get_filename() + + self.switch_to_plugin(force_focus=True) + self.get_widget().start_code_analysis(filename) + + def stop_code_analysis(self): + """ + Stop the code analysis process. + """ + self.get_widget().stop_code_analysis() diff --git a/spyder/plugins/run/confpage.py b/spyder/plugins/run/confpage.py index 5ad5663d515..287a35f7be3 100644 --- a/spyder/plugins/run/confpage.py +++ b/spyder/plugins/run/confpage.py @@ -1,142 +1,142 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Run configuration page.""" - -# Third party imports -from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, - QVBoxLayout) - -# Local imports -from spyder.api.preferences import PluginConfigPage -from spyder.api.translations import get_translation -from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN, - ALWAYS_OPEN_FIRST_RUN_OPTION, - CLEAR_ALL_VARIABLES, - CONSOLE_NAMESPACE, - CURRENT_INTERPRETER, - CURRENT_INTERPRETER_OPTION, CW_DIR, - DEDICATED_INTERPRETER, - DEDICATED_INTERPRETER_OPTION, - FILE_DIR, FIXED_DIR, INTERACT, - POST_MORTEM, SYSTERM_INTERPRETER, - SYSTERM_INTERPRETER_OPTION, - WDIR_FIXED_DIR_OPTION, - WDIR_USE_CWD_DIR_OPTION, - WDIR_USE_FIXED_DIR_OPTION, - WDIR_USE_SCRIPT_DIR_OPTION) -from spyder.utils.misc import getcwd_or_home - -# Localization -_ = get_translation("spyder") - - -class RunConfigPage(PluginConfigPage): - """Default Run Settings configuration page.""" - - def setup_page(self): - about_label = QLabel(_("The following are the default options for " - "running files.These options may be overriden " - "using the Configuration per file entry " - "of the Run menu.")) - about_label.setWordWrap(True) - - interpreter_group = QGroupBox(_("Console")) - interpreter_bg = QButtonGroup(interpreter_group) - self.current_radio = self.create_radiobutton( - CURRENT_INTERPRETER, - CURRENT_INTERPRETER_OPTION, - True, - button_group=interpreter_bg) - self.dedicated_radio = self.create_radiobutton( - DEDICATED_INTERPRETER, - DEDICATED_INTERPRETER_OPTION, - False, - button_group=interpreter_bg) - self.systerm_radio = self.create_radiobutton( - SYSTERM_INTERPRETER, - SYSTERM_INTERPRETER_OPTION, False, - button_group=interpreter_bg) - - interpreter_layout = QVBoxLayout() - interpreter_group.setLayout(interpreter_layout) - interpreter_layout.addWidget(self.current_radio) - interpreter_layout.addWidget(self.dedicated_radio) - interpreter_layout.addWidget(self.systerm_radio) - - general_group = QGroupBox(_("General settings")) - post_mortem = self.create_checkbox(POST_MORTEM, 'post_mortem', False) - clear_variables = self.create_checkbox(CLEAR_ALL_VARIABLES, - 'clear_namespace', False) - console_namespace = self.create_checkbox(CONSOLE_NAMESPACE, - 'console_namespace', False) - - general_layout = QVBoxLayout() - general_layout.addWidget(clear_variables) - general_layout.addWidget(console_namespace) - general_layout.addWidget(post_mortem) - general_group.setLayout(general_layout) - - wdir_group = QGroupBox(_("Working directory settings")) - wdir_bg = QButtonGroup(wdir_group) - wdir_label = QLabel(_("Default working directory is:")) - wdir_label.setWordWrap(True) - dirname_radio = self.create_radiobutton( - FILE_DIR, - WDIR_USE_SCRIPT_DIR_OPTION, - True, - button_group=wdir_bg) - cwd_radio = self.create_radiobutton( - CW_DIR, - WDIR_USE_CWD_DIR_OPTION, - False, - button_group=wdir_bg) - - thisdir_radio = self.create_radiobutton( - FIXED_DIR, - WDIR_USE_FIXED_DIR_OPTION, - False, - button_group=wdir_bg) - thisdir_bd = self.create_browsedir("", WDIR_FIXED_DIR_OPTION, - getcwd_or_home()) - thisdir_radio.toggled.connect(thisdir_bd.setEnabled) - dirname_radio.toggled.connect(thisdir_bd.setDisabled) - cwd_radio.toggled.connect(thisdir_bd.setDisabled) - thisdir_layout = QHBoxLayout() - thisdir_layout.addWidget(thisdir_radio) - thisdir_layout.addWidget(thisdir_bd) - - wdir_layout = QVBoxLayout() - wdir_layout.addWidget(wdir_label) - wdir_layout.addWidget(dirname_radio) - wdir_layout.addWidget(cwd_radio) - wdir_layout.addLayout(thisdir_layout) - wdir_group.setLayout(wdir_layout) - - external_group = QGroupBox(_("External system terminal")) - interact_after = self.create_checkbox(INTERACT, 'interact', False) - - external_layout = QVBoxLayout() - external_layout.addWidget(interact_after) - external_group.setLayout(external_layout) - - firstrun_cb = self.create_checkbox( - ALWAYS_OPEN_FIRST_RUN % _("Run Settings dialog"), - ALWAYS_OPEN_FIRST_RUN_OPTION, - False) - - vlayout = QVBoxLayout(self) - vlayout.addWidget(about_label) - vlayout.addSpacing(10) - vlayout.addWidget(interpreter_group) - vlayout.addWidget(general_group) - vlayout.addWidget(wdir_group) - vlayout.addWidget(external_group) - vlayout.addWidget(firstrun_cb) - vlayout.addStretch(1) - - def apply_settings(self): - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Run configuration page.""" + +# Third party imports +from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, + QVBoxLayout) + +# Local imports +from spyder.api.preferences import PluginConfigPage +from spyder.api.translations import get_translation +from spyder.plugins.run.widgets import (ALWAYS_OPEN_FIRST_RUN, + ALWAYS_OPEN_FIRST_RUN_OPTION, + CLEAR_ALL_VARIABLES, + CONSOLE_NAMESPACE, + CURRENT_INTERPRETER, + CURRENT_INTERPRETER_OPTION, CW_DIR, + DEDICATED_INTERPRETER, + DEDICATED_INTERPRETER_OPTION, + FILE_DIR, FIXED_DIR, INTERACT, + POST_MORTEM, SYSTERM_INTERPRETER, + SYSTERM_INTERPRETER_OPTION, + WDIR_FIXED_DIR_OPTION, + WDIR_USE_CWD_DIR_OPTION, + WDIR_USE_FIXED_DIR_OPTION, + WDIR_USE_SCRIPT_DIR_OPTION) +from spyder.utils.misc import getcwd_or_home + +# Localization +_ = get_translation("spyder") + + +class RunConfigPage(PluginConfigPage): + """Default Run Settings configuration page.""" + + def setup_page(self): + about_label = QLabel(_("The following are the default options for " + "running files.These options may be overriden " + "using the Configuration per file entry " + "of the Run menu.")) + about_label.setWordWrap(True) + + interpreter_group = QGroupBox(_("Console")) + interpreter_bg = QButtonGroup(interpreter_group) + self.current_radio = self.create_radiobutton( + CURRENT_INTERPRETER, + CURRENT_INTERPRETER_OPTION, + True, + button_group=interpreter_bg) + self.dedicated_radio = self.create_radiobutton( + DEDICATED_INTERPRETER, + DEDICATED_INTERPRETER_OPTION, + False, + button_group=interpreter_bg) + self.systerm_radio = self.create_radiobutton( + SYSTERM_INTERPRETER, + SYSTERM_INTERPRETER_OPTION, False, + button_group=interpreter_bg) + + interpreter_layout = QVBoxLayout() + interpreter_group.setLayout(interpreter_layout) + interpreter_layout.addWidget(self.current_radio) + interpreter_layout.addWidget(self.dedicated_radio) + interpreter_layout.addWidget(self.systerm_radio) + + general_group = QGroupBox(_("General settings")) + post_mortem = self.create_checkbox(POST_MORTEM, 'post_mortem', False) + clear_variables = self.create_checkbox(CLEAR_ALL_VARIABLES, + 'clear_namespace', False) + console_namespace = self.create_checkbox(CONSOLE_NAMESPACE, + 'console_namespace', False) + + general_layout = QVBoxLayout() + general_layout.addWidget(clear_variables) + general_layout.addWidget(console_namespace) + general_layout.addWidget(post_mortem) + general_group.setLayout(general_layout) + + wdir_group = QGroupBox(_("Working directory settings")) + wdir_bg = QButtonGroup(wdir_group) + wdir_label = QLabel(_("Default working directory is:")) + wdir_label.setWordWrap(True) + dirname_radio = self.create_radiobutton( + FILE_DIR, + WDIR_USE_SCRIPT_DIR_OPTION, + True, + button_group=wdir_bg) + cwd_radio = self.create_radiobutton( + CW_DIR, + WDIR_USE_CWD_DIR_OPTION, + False, + button_group=wdir_bg) + + thisdir_radio = self.create_radiobutton( + FIXED_DIR, + WDIR_USE_FIXED_DIR_OPTION, + False, + button_group=wdir_bg) + thisdir_bd = self.create_browsedir("", WDIR_FIXED_DIR_OPTION, + getcwd_or_home()) + thisdir_radio.toggled.connect(thisdir_bd.setEnabled) + dirname_radio.toggled.connect(thisdir_bd.setDisabled) + cwd_radio.toggled.connect(thisdir_bd.setDisabled) + thisdir_layout = QHBoxLayout() + thisdir_layout.addWidget(thisdir_radio) + thisdir_layout.addWidget(thisdir_bd) + + wdir_layout = QVBoxLayout() + wdir_layout.addWidget(wdir_label) + wdir_layout.addWidget(dirname_radio) + wdir_layout.addWidget(cwd_radio) + wdir_layout.addLayout(thisdir_layout) + wdir_group.setLayout(wdir_layout) + + external_group = QGroupBox(_("External system terminal")) + interact_after = self.create_checkbox(INTERACT, 'interact', False) + + external_layout = QVBoxLayout() + external_layout.addWidget(interact_after) + external_group.setLayout(external_layout) + + firstrun_cb = self.create_checkbox( + ALWAYS_OPEN_FIRST_RUN % _("Run Settings dialog"), + ALWAYS_OPEN_FIRST_RUN_OPTION, + False) + + vlayout = QVBoxLayout(self) + vlayout.addWidget(about_label) + vlayout.addSpacing(10) + vlayout.addWidget(interpreter_group) + vlayout.addWidget(general_group) + vlayout.addWidget(wdir_group) + vlayout.addWidget(external_group) + vlayout.addWidget(firstrun_cb) + vlayout.addStretch(1) + + def apply_settings(self): + pass diff --git a/spyder/plugins/run/plugin.py b/spyder/plugins/run/plugin.py index c8f196991ab..feef5b45db1 100644 --- a/spyder/plugins/run/plugin.py +++ b/spyder/plugins/run/plugin.py @@ -1,65 +1,65 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Run Plugin. -""" - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.run.confpage import RunConfigPage - -# Localization -_ = get_translation('spyder') - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Run(SpyderPluginV2): - """ - Run Plugin. - """ - - NAME = "run" - # TODO: Fix requires to reflect the desired order in the preferences - REQUIRES = [Plugins.Preferences] - CONTAINER_CLASS = None - CONF_SECTION = NAME - CONF_WIDGET_CLASS = RunConfigPage - CONF_FILE = False - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Run") - - def get_description(self): - return _("Manage run configuration.") - - def get_icon(self): - return self.create_icon('run') - - def on_initialize(self): - pass - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - # --- Public API - # ------------------------------------------------------------------------ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Run Plugin. +""" + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.run.confpage import RunConfigPage + +# Localization +_ = get_translation('spyder') + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Run(SpyderPluginV2): + """ + Run Plugin. + """ + + NAME = "run" + # TODO: Fix requires to reflect the desired order in the preferences + REQUIRES = [Plugins.Preferences] + CONTAINER_CLASS = None + CONF_SECTION = NAME + CONF_WIDGET_CLASS = RunConfigPage + CONF_FILE = False + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Run") + + def get_description(self): + return _("Manage run configuration.") + + def get_icon(self): + return self.create_icon('run') + + def on_initialize(self): + pass + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + # --- Public API + # ------------------------------------------------------------------------ diff --git a/spyder/plugins/run/widgets.py b/spyder/plugins/run/widgets.py index 69ed9cccb6e..96f414a41bb 100644 --- a/spyder/plugins/run/widgets.py +++ b/spyder/plugins/run/widgets.py @@ -1,522 +1,522 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Run dialogs and widgets and data models.""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import QSize, Qt, Signal, Slot -from qtpy.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, - QFrame, QGridLayout, QGroupBox, QHBoxLayout, - QLabel, QLineEdit, QMessageBox, QPushButton, - QRadioButton, QSizePolicy, QScrollArea, - QStackedWidget, QVBoxLayout, QWidget) - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.manager import CONF -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import create_toolbutton - -# Localization -_ = get_translation("spyder") - -RUN_DEFAULT_CONFIG = _("Run file with default configuration") -RUN_CUSTOM_CONFIG = _("Run file with custom configuration") -CURRENT_INTERPRETER = _("Execute in current console") -DEDICATED_INTERPRETER = _("Execute in a dedicated console") -SYSTERM_INTERPRETER = _("Execute in an external system terminal") - -CURRENT_INTERPRETER_OPTION = 'default/interpreter/current' -DEDICATED_INTERPRETER_OPTION = 'default/interpreter/dedicated' -SYSTERM_INTERPRETER_OPTION = 'default/interpreter/systerm' - -WDIR_USE_SCRIPT_DIR_OPTION = 'default/wdir/use_script_directory' -WDIR_USE_CWD_DIR_OPTION = 'default/wdir/use_cwd_directory' -WDIR_USE_FIXED_DIR_OPTION = 'default/wdir/use_fixed_directory' -WDIR_FIXED_DIR_OPTION = 'default/wdir/fixed_directory' - -ALWAYS_OPEN_FIRST_RUN = _("Always show %s on a first file run") -ALWAYS_OPEN_FIRST_RUN_OPTION = 'open_on_firstrun' - -CLEAR_ALL_VARIABLES = _("Remove all variables before execution") -CONSOLE_NAMESPACE = _("Run in console's namespace instead of an empty one") -POST_MORTEM = _("Directly enter debugging when errors appear") -INTERACT = _("Interact with the Python console after execution") - -FILE_DIR = _("The directory of the file being executed") -CW_DIR = _("The current working directory") -FIXED_DIR = _("The following directory:") - - -class RunConfiguration(object): - """Run configuration""" - - def __init__(self, fname=None): - self.default = None - self.args = None - self.args_enabled = None - self.wdir = None - self.wdir_enabled = None - self.current = None - self.systerm = None - self.interact = None - self.post_mortem = None - self.python_args = None - self.python_args_enabled = None - self.clear_namespace = None - self.console_namespace = None - self.file_dir = None - self.cw_dir = None - self.fixed_dir = None - self.dir = None - - self.set(CONF.get('run', 'defaultconfiguration', default={})) - - def set(self, options): - self.default = options.get('default', True) - self.args = options.get('args', '') - self.args_enabled = options.get('args/enabled', False) - self.current = options.get('current', - CONF.get('run', CURRENT_INTERPRETER_OPTION, True)) - self.systerm = options.get('systerm', - CONF.get('run', SYSTERM_INTERPRETER_OPTION, False)) - self.interact = options.get('interact', - CONF.get('run', 'interact', False)) - self.post_mortem = options.get('post_mortem', - CONF.get('run', 'post_mortem', False)) - self.python_args = options.get('python_args', '') - self.python_args_enabled = options.get('python_args/enabled', False) - self.clear_namespace = options.get('clear_namespace', - CONF.get('run', 'clear_namespace', False)) - self.console_namespace = options.get('console_namespace', - CONF.get('run', 'console_namespace', False)) - self.file_dir = options.get('file_dir', - CONF.get('run', WDIR_USE_SCRIPT_DIR_OPTION, True)) - self.cw_dir = options.get('cw_dir', - CONF.get('run', WDIR_USE_CWD_DIR_OPTION, False)) - self.fixed_dir = options.get('fixed_dir', - CONF.get('run', WDIR_USE_FIXED_DIR_OPTION, False)) - self.dir = options.get('dir', '') - - def get(self): - return { - 'default': self.default, - 'args/enabled': self.args_enabled, - 'args': self.args, - 'workdir/enabled': self.wdir_enabled, - 'workdir': self.wdir, - 'current': self.current, - 'systerm': self.systerm, - 'interact': self.interact, - 'post_mortem': self.post_mortem, - 'python_args/enabled': self.python_args_enabled, - 'python_args': self.python_args, - 'clear_namespace': self.clear_namespace, - 'console_namespace': self.console_namespace, - 'file_dir': self.file_dir, - 'cw_dir': self.cw_dir, - 'fixed_dir': self.fixed_dir, - 'dir': self.dir - } - - def get_working_directory(self): - return self.dir - - def get_arguments(self): - if self.args_enabled: - return self.args - else: - return '' - - def get_python_arguments(self): - if self.python_args_enabled: - return self.python_args - else: - return '' - - -def _get_run_configurations(): - history_count = CONF.get('run', 'history', 20) - try: - return [(filename, options) - for filename, options in CONF.get('run', 'configurations', []) - if osp.isfile(filename)][:history_count] - except ValueError: - CONF.set('run', 'configurations', []) - return [] - - -def _set_run_configurations(configurations): - history_count = CONF.get('run', 'history', 20) - CONF.set('run', 'configurations', configurations[:history_count]) - - -def get_run_configuration(fname): - """Return script *fname* run configuration""" - configurations = _get_run_configurations() - for filename, options in configurations: - if fname == filename: - runconf = RunConfiguration() - runconf.set(options) - return runconf - - -class RunConfigOptions(QWidget): - """Run configuration options""" - def __init__(self, parent=None): - QWidget.__init__(self, parent) - - self.dir = None - self.runconf = RunConfiguration() - firstrun_o = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION, False) - - # --- Run settings --- - self.run_default_config_radio = QRadioButton(RUN_DEFAULT_CONFIG) - self.run_custom_config_radio = QRadioButton(RUN_CUSTOM_CONFIG) - - # --- Interpreter --- - interpreter_group = QGroupBox(_("Console")) - interpreter_group.setDisabled(True) - - self.run_custom_config_radio.toggled.connect( - interpreter_group.setEnabled) - - interpreter_layout = QVBoxLayout(interpreter_group) - - self.current_radio = QRadioButton(CURRENT_INTERPRETER) - interpreter_layout.addWidget(self.current_radio) - - self.dedicated_radio = QRadioButton(DEDICATED_INTERPRETER) - interpreter_layout.addWidget(self.dedicated_radio) - - self.systerm_radio = QRadioButton(SYSTERM_INTERPRETER) - interpreter_layout.addWidget(self.systerm_radio) - - # --- System terminal --- - external_group = QWidget() - external_group.setDisabled(True) - self.systerm_radio.toggled.connect(external_group.setEnabled) - - external_layout = QGridLayout() - external_group.setLayout(external_layout) - self.interact_cb = QCheckBox(INTERACT) - external_layout.addWidget(self.interact_cb, 1, 0, 1, -1) - - self.pclo_cb = QCheckBox(_("Command line options:")) - external_layout.addWidget(self.pclo_cb, 3, 0) - self.pclo_edit = QLineEdit() - self.pclo_cb.toggled.connect(self.pclo_edit.setEnabled) - self.pclo_edit.setEnabled(False) - self.pclo_edit.setToolTip(_("-u is added to the " - "other options you set here")) - external_layout.addWidget(self.pclo_edit, 3, 1) - - interpreter_layout.addWidget(external_group) - - # --- General settings ---- - common_group = QGroupBox(_("General settings")) - common_group.setDisabled(True) - - self.run_custom_config_radio.toggled.connect(common_group.setEnabled) - - common_layout = QGridLayout(common_group) - - self.clear_var_cb = QCheckBox(CLEAR_ALL_VARIABLES) - common_layout.addWidget(self.clear_var_cb, 0, 0) - - self.console_ns_cb = QCheckBox(CONSOLE_NAMESPACE) - common_layout.addWidget(self.console_ns_cb, 1, 0) - - self.post_mortem_cb = QCheckBox(POST_MORTEM) - common_layout.addWidget(self.post_mortem_cb, 2, 0) - - self.clo_cb = QCheckBox(_("Command line options:")) - common_layout.addWidget(self.clo_cb, 3, 0) - self.clo_edit = QLineEdit() - self.clo_cb.toggled.connect(self.clo_edit.setEnabled) - self.clo_edit.setEnabled(False) - common_layout.addWidget(self.clo_edit, 3, 1) - - # --- Working directory --- - wdir_group = QGroupBox(_("Working directory settings")) - wdir_group.setDisabled(True) - - self.run_custom_config_radio.toggled.connect(wdir_group.setEnabled) - - wdir_layout = QVBoxLayout(wdir_group) - - self.file_dir_radio = QRadioButton(FILE_DIR) - wdir_layout.addWidget(self.file_dir_radio) - - self.cwd_radio = QRadioButton(CW_DIR) - wdir_layout.addWidget(self.cwd_radio) - - fixed_dir_layout = QHBoxLayout() - self.fixed_dir_radio = QRadioButton(FIXED_DIR) - fixed_dir_layout.addWidget(self.fixed_dir_radio) - self.wd_edit = QLineEdit() - self.fixed_dir_radio.toggled.connect(self.wd_edit.setEnabled) - self.wd_edit.setEnabled(False) - fixed_dir_layout.addWidget(self.wd_edit) - browse_btn = create_toolbutton( - self, - triggered=self.select_directory, - icon=ima.icon('DirOpenIcon'), - tip=_("Select directory") - ) - fixed_dir_layout.addWidget(browse_btn) - wdir_layout.addLayout(fixed_dir_layout) - - # Checkbox to preserve the old behavior, i.e. always open the dialog - # on first run - self.firstrun_cb = QCheckBox(ALWAYS_OPEN_FIRST_RUN % _("this dialog")) - self.firstrun_cb.clicked.connect(self.set_firstrun_o) - self.firstrun_cb.setChecked(firstrun_o) - - layout = QVBoxLayout(self) - layout.addWidget(self.run_default_config_radio) - layout.addWidget(self.run_custom_config_radio) - layout.addWidget(interpreter_group) - layout.addWidget(common_group) - layout.addWidget(wdir_group) - layout.addWidget(self.firstrun_cb) - layout.addStretch(100) - - def select_directory(self): - """Select directory""" - basedir = str(self.wd_edit.text()) - if not osp.isdir(basedir): - basedir = getcwd_or_home() - directory = getexistingdirectory(self, _("Select directory"), basedir) - if directory: - self.wd_edit.setText(directory) - self.dir = directory - - def set(self, options): - self.runconf.set(options) - if self.runconf.default: - self.run_default_config_radio.setChecked(True) - else: - self.run_custom_config_radio.setChecked(True) - self.clo_cb.setChecked(self.runconf.args_enabled) - self.clo_edit.setText(self.runconf.args) - if self.runconf.current: - self.current_radio.setChecked(True) - elif self.runconf.systerm: - self.systerm_radio.setChecked(True) - else: - self.dedicated_radio.setChecked(True) - self.interact_cb.setChecked(self.runconf.interact) - self.post_mortem_cb.setChecked(self.runconf.post_mortem) - self.pclo_cb.setChecked(self.runconf.python_args_enabled) - self.pclo_edit.setText(self.runconf.python_args) - self.clear_var_cb.setChecked(self.runconf.clear_namespace) - self.console_ns_cb.setChecked(self.runconf.console_namespace) - self.file_dir_radio.setChecked(self.runconf.file_dir) - self.cwd_radio.setChecked(self.runconf.cw_dir) - self.fixed_dir_radio.setChecked(self.runconf.fixed_dir) - self.dir = self.runconf.dir - self.wd_edit.setText(self.dir) - - def get(self): - self.runconf.default = self.run_default_config_radio.isChecked() - self.runconf.args_enabled = self.clo_cb.isChecked() - self.runconf.args = str(self.clo_edit.text()) - self.runconf.current = self.current_radio.isChecked() - self.runconf.systerm = self.systerm_radio.isChecked() - self.runconf.interact = self.interact_cb.isChecked() - self.runconf.post_mortem = self.post_mortem_cb.isChecked() - self.runconf.python_args_enabled = self.pclo_cb.isChecked() - self.runconf.python_args = str(self.pclo_edit.text()) - self.runconf.clear_namespace = self.clear_var_cb.isChecked() - self.runconf.console_namespace = self.console_ns_cb.isChecked() - self.runconf.file_dir = self.file_dir_radio.isChecked() - self.runconf.cw_dir = self.cwd_radio.isChecked() - self.runconf.fixed_dir = self.fixed_dir_radio.isChecked() - self.runconf.dir = self.wd_edit.text() - return self.runconf.get() - - def is_valid(self): - wdir = str(self.wd_edit.text()) - if not self.fixed_dir_radio.isChecked() or osp.isdir(wdir): - return True - else: - QMessageBox.critical(self, _("Run configuration"), - _("The following working directory is " - "not valid:
    %s") % wdir) - return False - - def set_firstrun_o(self): - CONF.set('run', ALWAYS_OPEN_FIRST_RUN_OPTION, - self.firstrun_cb.isChecked()) - - -class BaseRunConfigDialog(QDialog): - """Run configuration dialog box, base widget""" - size_change = Signal(QSize) - - def __init__(self, parent=None): - QDialog.__init__(self, parent) - self.setWindowFlags( - self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - - # Destroying the C++ object right after closing the dialog box, - # otherwise it may be garbage-collected in another QThread - # (e.g. the editor's analysis thread in Spyder), thus leading to - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - - self.setWindowIcon(ima.icon('run_settings')) - layout = QVBoxLayout() - self.setLayout(layout) - - def add_widgets(self, *widgets_or_spacings): - """Add widgets/spacing to dialog vertical layout""" - layout = self.layout() - for widget_or_spacing in widgets_or_spacings: - if isinstance(widget_or_spacing, int): - layout.addSpacing(widget_or_spacing) - else: - layout.addWidget(widget_or_spacing) - return layout - - def add_button_box(self, stdbtns): - """Create dialog button box and add it to the dialog layout""" - bbox = QDialogButtonBox(stdbtns) - run_btn = bbox.addButton(_("Run"), QDialogButtonBox.AcceptRole) - run_btn.clicked.connect(self.run_btn_clicked) - bbox.accepted.connect(self.accept) - bbox.rejected.connect(self.reject) - btnlayout = QHBoxLayout() - btnlayout.addStretch(1) - btnlayout.addWidget(bbox) - self.layout().addLayout(btnlayout) - - def resizeEvent(self, event): - """ - Reimplement Qt method to be able to save the widget's size from the - main application - """ - QDialog.resizeEvent(self, event) - self.size_change.emit(self.size()) - - def run_btn_clicked(self): - """Run button was just clicked""" - pass - - def setup(self, fname): - """Setup Run Configuration dialog with filename *fname*""" - raise NotImplementedError - - -class RunConfigOneDialog(BaseRunConfigDialog): - """Run configuration dialog box: single file version""" - - def __init__(self, parent=None): - BaseRunConfigDialog.__init__(self, parent) - self.filename = None - self.runconfigoptions = None - - def setup(self, fname): - """Setup Run Configuration dialog with filename *fname*""" - self.filename = fname - self.runconfigoptions = RunConfigOptions(self) - self.runconfigoptions.set(RunConfiguration(fname).get()) - scrollarea = QScrollArea(self) - scrollarea.setWidget(self.runconfigoptions) - scrollarea.setMinimumWidth(560) - scrollarea.setWidgetResizable(True) - self.add_widgets(scrollarea) - self.add_button_box(QDialogButtonBox.Cancel) - self.setWindowTitle(_("Run settings for %s") % osp.basename(fname)) - - @Slot() - def accept(self): - """Reimplement Qt method""" - if not self.runconfigoptions.is_valid(): - return - configurations = _get_run_configurations() - configurations.insert(0, (self.filename, self.runconfigoptions.get())) - _set_run_configurations(configurations) - QDialog.accept(self) - - def get_configuration(self): - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.runconfigoptions.runconf - - -class RunConfigDialog(BaseRunConfigDialog): - """Run configuration dialog box: multiple file version""" - - def __init__(self, parent=None): - BaseRunConfigDialog.__init__(self, parent) - self.file_to_run = None - self.combo = None - self.stack = None - - def run_btn_clicked(self): - """Run button was just clicked""" - self.file_to_run = str(self.combo.currentText()) - - def setup(self, fname): - """Setup Run Configuration dialog with filename *fname*""" - combo_label = QLabel(_("Select a run configuration:")) - self.combo = QComboBox() - self.combo.setMaxVisibleItems(20) - - self.stack = QStackedWidget() - - configurations = _get_run_configurations() - for index, (filename, options) in enumerate(configurations): - if fname == filename: - break - else: - # There is no run configuration for script *fname*: - # creating a temporary configuration that will be kept only if - # dialog changes are accepted by the user - configurations.insert(0, (fname, RunConfiguration(fname).get())) - index = 0 - for filename, options in configurations: - widget = RunConfigOptions(self) - widget.set(options) - widget.layout().setContentsMargins(0, 0, 0, 0) - self.combo.addItem(filename) - self.stack.addWidget(widget) - self.combo.currentIndexChanged.connect(self.stack.setCurrentIndex) - self.combo.setCurrentIndex(index) - - layout = self.add_widgets(combo_label, self.combo, 10, self.stack) - widget_dialog = QWidget() - widget_dialog.setLayout(layout) - scrollarea = QScrollArea(self) - scrollarea.setWidget(widget_dialog) - scrollarea.setMinimumWidth(600) - scrollarea.setWidgetResizable(True) - scroll_layout = QVBoxLayout(self) - scroll_layout.addWidget(scrollarea) - self.add_button_box(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - - self.setWindowTitle(_("Run configuration per file")) - - def accept(self): - """Reimplement Qt method""" - configurations = [] - for index in range(self.stack.count()): - filename = str(self.combo.itemText(index)) - runconfigoptions = self.stack.widget(index) - if index == self.stack.currentIndex() and\ - not runconfigoptions.is_valid(): - return - options = runconfigoptions.get() - configurations.append( (filename, options) ) - _set_run_configurations(configurations) - QDialog.accept(self) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Run dialogs and widgets and data models.""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import QSize, Qt, Signal, Slot +from qtpy.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, + QFrame, QGridLayout, QGroupBox, QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QPushButton, + QRadioButton, QSizePolicy, QScrollArea, + QStackedWidget, QVBoxLayout, QWidget) + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.manager import CONF +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import create_toolbutton + +# Localization +_ = get_translation("spyder") + +RUN_DEFAULT_CONFIG = _("Run file with default configuration") +RUN_CUSTOM_CONFIG = _("Run file with custom configuration") +CURRENT_INTERPRETER = _("Execute in current console") +DEDICATED_INTERPRETER = _("Execute in a dedicated console") +SYSTERM_INTERPRETER = _("Execute in an external system terminal") + +CURRENT_INTERPRETER_OPTION = 'default/interpreter/current' +DEDICATED_INTERPRETER_OPTION = 'default/interpreter/dedicated' +SYSTERM_INTERPRETER_OPTION = 'default/interpreter/systerm' + +WDIR_USE_SCRIPT_DIR_OPTION = 'default/wdir/use_script_directory' +WDIR_USE_CWD_DIR_OPTION = 'default/wdir/use_cwd_directory' +WDIR_USE_FIXED_DIR_OPTION = 'default/wdir/use_fixed_directory' +WDIR_FIXED_DIR_OPTION = 'default/wdir/fixed_directory' + +ALWAYS_OPEN_FIRST_RUN = _("Always show %s on a first file run") +ALWAYS_OPEN_FIRST_RUN_OPTION = 'open_on_firstrun' + +CLEAR_ALL_VARIABLES = _("Remove all variables before execution") +CONSOLE_NAMESPACE = _("Run in console's namespace instead of an empty one") +POST_MORTEM = _("Directly enter debugging when errors appear") +INTERACT = _("Interact with the Python console after execution") + +FILE_DIR = _("The directory of the file being executed") +CW_DIR = _("The current working directory") +FIXED_DIR = _("The following directory:") + + +class RunConfiguration(object): + """Run configuration""" + + def __init__(self, fname=None): + self.default = None + self.args = None + self.args_enabled = None + self.wdir = None + self.wdir_enabled = None + self.current = None + self.systerm = None + self.interact = None + self.post_mortem = None + self.python_args = None + self.python_args_enabled = None + self.clear_namespace = None + self.console_namespace = None + self.file_dir = None + self.cw_dir = None + self.fixed_dir = None + self.dir = None + + self.set(CONF.get('run', 'defaultconfiguration', default={})) + + def set(self, options): + self.default = options.get('default', True) + self.args = options.get('args', '') + self.args_enabled = options.get('args/enabled', False) + self.current = options.get('current', + CONF.get('run', CURRENT_INTERPRETER_OPTION, True)) + self.systerm = options.get('systerm', + CONF.get('run', SYSTERM_INTERPRETER_OPTION, False)) + self.interact = options.get('interact', + CONF.get('run', 'interact', False)) + self.post_mortem = options.get('post_mortem', + CONF.get('run', 'post_mortem', False)) + self.python_args = options.get('python_args', '') + self.python_args_enabled = options.get('python_args/enabled', False) + self.clear_namespace = options.get('clear_namespace', + CONF.get('run', 'clear_namespace', False)) + self.console_namespace = options.get('console_namespace', + CONF.get('run', 'console_namespace', False)) + self.file_dir = options.get('file_dir', + CONF.get('run', WDIR_USE_SCRIPT_DIR_OPTION, True)) + self.cw_dir = options.get('cw_dir', + CONF.get('run', WDIR_USE_CWD_DIR_OPTION, False)) + self.fixed_dir = options.get('fixed_dir', + CONF.get('run', WDIR_USE_FIXED_DIR_OPTION, False)) + self.dir = options.get('dir', '') + + def get(self): + return { + 'default': self.default, + 'args/enabled': self.args_enabled, + 'args': self.args, + 'workdir/enabled': self.wdir_enabled, + 'workdir': self.wdir, + 'current': self.current, + 'systerm': self.systerm, + 'interact': self.interact, + 'post_mortem': self.post_mortem, + 'python_args/enabled': self.python_args_enabled, + 'python_args': self.python_args, + 'clear_namespace': self.clear_namespace, + 'console_namespace': self.console_namespace, + 'file_dir': self.file_dir, + 'cw_dir': self.cw_dir, + 'fixed_dir': self.fixed_dir, + 'dir': self.dir + } + + def get_working_directory(self): + return self.dir + + def get_arguments(self): + if self.args_enabled: + return self.args + else: + return '' + + def get_python_arguments(self): + if self.python_args_enabled: + return self.python_args + else: + return '' + + +def _get_run_configurations(): + history_count = CONF.get('run', 'history', 20) + try: + return [(filename, options) + for filename, options in CONF.get('run', 'configurations', []) + if osp.isfile(filename)][:history_count] + except ValueError: + CONF.set('run', 'configurations', []) + return [] + + +def _set_run_configurations(configurations): + history_count = CONF.get('run', 'history', 20) + CONF.set('run', 'configurations', configurations[:history_count]) + + +def get_run_configuration(fname): + """Return script *fname* run configuration""" + configurations = _get_run_configurations() + for filename, options in configurations: + if fname == filename: + runconf = RunConfiguration() + runconf.set(options) + return runconf + + +class RunConfigOptions(QWidget): + """Run configuration options""" + def __init__(self, parent=None): + QWidget.__init__(self, parent) + + self.dir = None + self.runconf = RunConfiguration() + firstrun_o = CONF.get('run', ALWAYS_OPEN_FIRST_RUN_OPTION, False) + + # --- Run settings --- + self.run_default_config_radio = QRadioButton(RUN_DEFAULT_CONFIG) + self.run_custom_config_radio = QRadioButton(RUN_CUSTOM_CONFIG) + + # --- Interpreter --- + interpreter_group = QGroupBox(_("Console")) + interpreter_group.setDisabled(True) + + self.run_custom_config_radio.toggled.connect( + interpreter_group.setEnabled) + + interpreter_layout = QVBoxLayout(interpreter_group) + + self.current_radio = QRadioButton(CURRENT_INTERPRETER) + interpreter_layout.addWidget(self.current_radio) + + self.dedicated_radio = QRadioButton(DEDICATED_INTERPRETER) + interpreter_layout.addWidget(self.dedicated_radio) + + self.systerm_radio = QRadioButton(SYSTERM_INTERPRETER) + interpreter_layout.addWidget(self.systerm_radio) + + # --- System terminal --- + external_group = QWidget() + external_group.setDisabled(True) + self.systerm_radio.toggled.connect(external_group.setEnabled) + + external_layout = QGridLayout() + external_group.setLayout(external_layout) + self.interact_cb = QCheckBox(INTERACT) + external_layout.addWidget(self.interact_cb, 1, 0, 1, -1) + + self.pclo_cb = QCheckBox(_("Command line options:")) + external_layout.addWidget(self.pclo_cb, 3, 0) + self.pclo_edit = QLineEdit() + self.pclo_cb.toggled.connect(self.pclo_edit.setEnabled) + self.pclo_edit.setEnabled(False) + self.pclo_edit.setToolTip(_("-u is added to the " + "other options you set here")) + external_layout.addWidget(self.pclo_edit, 3, 1) + + interpreter_layout.addWidget(external_group) + + # --- General settings ---- + common_group = QGroupBox(_("General settings")) + common_group.setDisabled(True) + + self.run_custom_config_radio.toggled.connect(common_group.setEnabled) + + common_layout = QGridLayout(common_group) + + self.clear_var_cb = QCheckBox(CLEAR_ALL_VARIABLES) + common_layout.addWidget(self.clear_var_cb, 0, 0) + + self.console_ns_cb = QCheckBox(CONSOLE_NAMESPACE) + common_layout.addWidget(self.console_ns_cb, 1, 0) + + self.post_mortem_cb = QCheckBox(POST_MORTEM) + common_layout.addWidget(self.post_mortem_cb, 2, 0) + + self.clo_cb = QCheckBox(_("Command line options:")) + common_layout.addWidget(self.clo_cb, 3, 0) + self.clo_edit = QLineEdit() + self.clo_cb.toggled.connect(self.clo_edit.setEnabled) + self.clo_edit.setEnabled(False) + common_layout.addWidget(self.clo_edit, 3, 1) + + # --- Working directory --- + wdir_group = QGroupBox(_("Working directory settings")) + wdir_group.setDisabled(True) + + self.run_custom_config_radio.toggled.connect(wdir_group.setEnabled) + + wdir_layout = QVBoxLayout(wdir_group) + + self.file_dir_radio = QRadioButton(FILE_DIR) + wdir_layout.addWidget(self.file_dir_radio) + + self.cwd_radio = QRadioButton(CW_DIR) + wdir_layout.addWidget(self.cwd_radio) + + fixed_dir_layout = QHBoxLayout() + self.fixed_dir_radio = QRadioButton(FIXED_DIR) + fixed_dir_layout.addWidget(self.fixed_dir_radio) + self.wd_edit = QLineEdit() + self.fixed_dir_radio.toggled.connect(self.wd_edit.setEnabled) + self.wd_edit.setEnabled(False) + fixed_dir_layout.addWidget(self.wd_edit) + browse_btn = create_toolbutton( + self, + triggered=self.select_directory, + icon=ima.icon('DirOpenIcon'), + tip=_("Select directory") + ) + fixed_dir_layout.addWidget(browse_btn) + wdir_layout.addLayout(fixed_dir_layout) + + # Checkbox to preserve the old behavior, i.e. always open the dialog + # on first run + self.firstrun_cb = QCheckBox(ALWAYS_OPEN_FIRST_RUN % _("this dialog")) + self.firstrun_cb.clicked.connect(self.set_firstrun_o) + self.firstrun_cb.setChecked(firstrun_o) + + layout = QVBoxLayout(self) + layout.addWidget(self.run_default_config_radio) + layout.addWidget(self.run_custom_config_radio) + layout.addWidget(interpreter_group) + layout.addWidget(common_group) + layout.addWidget(wdir_group) + layout.addWidget(self.firstrun_cb) + layout.addStretch(100) + + def select_directory(self): + """Select directory""" + basedir = str(self.wd_edit.text()) + if not osp.isdir(basedir): + basedir = getcwd_or_home() + directory = getexistingdirectory(self, _("Select directory"), basedir) + if directory: + self.wd_edit.setText(directory) + self.dir = directory + + def set(self, options): + self.runconf.set(options) + if self.runconf.default: + self.run_default_config_radio.setChecked(True) + else: + self.run_custom_config_radio.setChecked(True) + self.clo_cb.setChecked(self.runconf.args_enabled) + self.clo_edit.setText(self.runconf.args) + if self.runconf.current: + self.current_radio.setChecked(True) + elif self.runconf.systerm: + self.systerm_radio.setChecked(True) + else: + self.dedicated_radio.setChecked(True) + self.interact_cb.setChecked(self.runconf.interact) + self.post_mortem_cb.setChecked(self.runconf.post_mortem) + self.pclo_cb.setChecked(self.runconf.python_args_enabled) + self.pclo_edit.setText(self.runconf.python_args) + self.clear_var_cb.setChecked(self.runconf.clear_namespace) + self.console_ns_cb.setChecked(self.runconf.console_namespace) + self.file_dir_radio.setChecked(self.runconf.file_dir) + self.cwd_radio.setChecked(self.runconf.cw_dir) + self.fixed_dir_radio.setChecked(self.runconf.fixed_dir) + self.dir = self.runconf.dir + self.wd_edit.setText(self.dir) + + def get(self): + self.runconf.default = self.run_default_config_radio.isChecked() + self.runconf.args_enabled = self.clo_cb.isChecked() + self.runconf.args = str(self.clo_edit.text()) + self.runconf.current = self.current_radio.isChecked() + self.runconf.systerm = self.systerm_radio.isChecked() + self.runconf.interact = self.interact_cb.isChecked() + self.runconf.post_mortem = self.post_mortem_cb.isChecked() + self.runconf.python_args_enabled = self.pclo_cb.isChecked() + self.runconf.python_args = str(self.pclo_edit.text()) + self.runconf.clear_namespace = self.clear_var_cb.isChecked() + self.runconf.console_namespace = self.console_ns_cb.isChecked() + self.runconf.file_dir = self.file_dir_radio.isChecked() + self.runconf.cw_dir = self.cwd_radio.isChecked() + self.runconf.fixed_dir = self.fixed_dir_radio.isChecked() + self.runconf.dir = self.wd_edit.text() + return self.runconf.get() + + def is_valid(self): + wdir = str(self.wd_edit.text()) + if not self.fixed_dir_radio.isChecked() or osp.isdir(wdir): + return True + else: + QMessageBox.critical(self, _("Run configuration"), + _("The following working directory is " + "not valid:
    %s") % wdir) + return False + + def set_firstrun_o(self): + CONF.set('run', ALWAYS_OPEN_FIRST_RUN_OPTION, + self.firstrun_cb.isChecked()) + + +class BaseRunConfigDialog(QDialog): + """Run configuration dialog box, base widget""" + size_change = Signal(QSize) + + def __init__(self, parent=None): + QDialog.__init__(self, parent) + self.setWindowFlags( + self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + # Destroying the C++ object right after closing the dialog box, + # otherwise it may be garbage-collected in another QThread + # (e.g. the editor's analysis thread in Spyder), thus leading to + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + + self.setWindowIcon(ima.icon('run_settings')) + layout = QVBoxLayout() + self.setLayout(layout) + + def add_widgets(self, *widgets_or_spacings): + """Add widgets/spacing to dialog vertical layout""" + layout = self.layout() + for widget_or_spacing in widgets_or_spacings: + if isinstance(widget_or_spacing, int): + layout.addSpacing(widget_or_spacing) + else: + layout.addWidget(widget_or_spacing) + return layout + + def add_button_box(self, stdbtns): + """Create dialog button box and add it to the dialog layout""" + bbox = QDialogButtonBox(stdbtns) + run_btn = bbox.addButton(_("Run"), QDialogButtonBox.AcceptRole) + run_btn.clicked.connect(self.run_btn_clicked) + bbox.accepted.connect(self.accept) + bbox.rejected.connect(self.reject) + btnlayout = QHBoxLayout() + btnlayout.addStretch(1) + btnlayout.addWidget(bbox) + self.layout().addLayout(btnlayout) + + def resizeEvent(self, event): + """ + Reimplement Qt method to be able to save the widget's size from the + main application + """ + QDialog.resizeEvent(self, event) + self.size_change.emit(self.size()) + + def run_btn_clicked(self): + """Run button was just clicked""" + pass + + def setup(self, fname): + """Setup Run Configuration dialog with filename *fname*""" + raise NotImplementedError + + +class RunConfigOneDialog(BaseRunConfigDialog): + """Run configuration dialog box: single file version""" + + def __init__(self, parent=None): + BaseRunConfigDialog.__init__(self, parent) + self.filename = None + self.runconfigoptions = None + + def setup(self, fname): + """Setup Run Configuration dialog with filename *fname*""" + self.filename = fname + self.runconfigoptions = RunConfigOptions(self) + self.runconfigoptions.set(RunConfiguration(fname).get()) + scrollarea = QScrollArea(self) + scrollarea.setWidget(self.runconfigoptions) + scrollarea.setMinimumWidth(560) + scrollarea.setWidgetResizable(True) + self.add_widgets(scrollarea) + self.add_button_box(QDialogButtonBox.Cancel) + self.setWindowTitle(_("Run settings for %s") % osp.basename(fname)) + + @Slot() + def accept(self): + """Reimplement Qt method""" + if not self.runconfigoptions.is_valid(): + return + configurations = _get_run_configurations() + configurations.insert(0, (self.filename, self.runconfigoptions.get())) + _set_run_configurations(configurations) + QDialog.accept(self) + + def get_configuration(self): + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.runconfigoptions.runconf + + +class RunConfigDialog(BaseRunConfigDialog): + """Run configuration dialog box: multiple file version""" + + def __init__(self, parent=None): + BaseRunConfigDialog.__init__(self, parent) + self.file_to_run = None + self.combo = None + self.stack = None + + def run_btn_clicked(self): + """Run button was just clicked""" + self.file_to_run = str(self.combo.currentText()) + + def setup(self, fname): + """Setup Run Configuration dialog with filename *fname*""" + combo_label = QLabel(_("Select a run configuration:")) + self.combo = QComboBox() + self.combo.setMaxVisibleItems(20) + + self.stack = QStackedWidget() + + configurations = _get_run_configurations() + for index, (filename, options) in enumerate(configurations): + if fname == filename: + break + else: + # There is no run configuration for script *fname*: + # creating a temporary configuration that will be kept only if + # dialog changes are accepted by the user + configurations.insert(0, (fname, RunConfiguration(fname).get())) + index = 0 + for filename, options in configurations: + widget = RunConfigOptions(self) + widget.set(options) + widget.layout().setContentsMargins(0, 0, 0, 0) + self.combo.addItem(filename) + self.stack.addWidget(widget) + self.combo.currentIndexChanged.connect(self.stack.setCurrentIndex) + self.combo.setCurrentIndex(index) + + layout = self.add_widgets(combo_label, self.combo, 10, self.stack) + widget_dialog = QWidget() + widget_dialog.setLayout(layout) + scrollarea = QScrollArea(self) + scrollarea.setWidget(widget_dialog) + scrollarea.setMinimumWidth(600) + scrollarea.setWidgetResizable(True) + scroll_layout = QVBoxLayout(self) + scroll_layout.addWidget(scrollarea) + self.add_button_box(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + + self.setWindowTitle(_("Run configuration per file")) + + def accept(self): + """Reimplement Qt method""" + configurations = [] + for index in range(self.stack.count()): + filename = str(self.combo.itemText(index)) + runconfigoptions = self.stack.widget(index) + if index == self.stack.currentIndex() and\ + not runconfigoptions.is_valid(): + return + options = runconfigoptions.get() + configurations.append( (filename, options) ) + _set_run_configurations(configurations) + QDialog.accept(self) diff --git a/spyder/plugins/shortcuts/__init__.py b/spyder/plugins/shortcuts/__init__.py index 9b9fc59c0b0..105d8a94af6 100644 --- a/spyder/plugins/shortcuts/__init__.py +++ b/spyder/plugins/shortcuts/__init__.py @@ -1,12 +1,12 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -spyder.plugins.shortcuts -======================== - -Shortcuts Plugin. -""" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.plugins.shortcuts +======================== + +Shortcuts Plugin. +""" diff --git a/spyder/plugins/shortcuts/api.py b/spyder/plugins/shortcuts/api.py index c6c22acd43d..33bc7f3919f 100644 --- a/spyder/plugins/shortcuts/api.py +++ b/spyder/plugins/shortcuts/api.py @@ -1,7 +1,7 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shortcut API.""" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shortcut API.""" diff --git a/spyder/plugins/shortcuts/confpage.py b/spyder/plugins/shortcuts/confpage.py index fa991cf2134..2e9552a058d 100644 --- a/spyder/plugins/shortcuts/confpage.py +++ b/spyder/plugins/shortcuts/confpage.py @@ -1,95 +1,95 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shortcut configuration page.""" - -# Standard library imports -import re - -# Third party imports -from qtpy import PYQT5 -from qtpy.QtWidgets import (QHBoxLayout, QLabel, QMessageBox, QPushButton, - QVBoxLayout) - -# Local imports -from spyder.api.preferences import PluginConfigPage -from spyder.api.translations import get_translation -from spyder.plugins.shortcuts.widgets.table import (ShortcutFinder, - ShortcutsTable) -from spyder.utils.icon_manager import ima - -# Localization -_ = get_translation('spyder') - - -class ShortcutsConfigPage(PluginConfigPage): - APPLY_CONF_PAGE_SETTINGS = True - - def setup_page(self): - # Widgets - self.table = ShortcutsTable(self, text_color=ima.MAIN_FG_COLOR) - self.finder = ShortcutFinder(self.table, self.table.set_regex) - self.label_finder = QLabel(_('Search: ')) - self.reset_btn = QPushButton(_("Reset to default values")) - self.top_label = QLabel( - _("Here you can browse the list of all available shortcuts in " - "Spyder. You can also customize them by double-clicking on any " - "entry in this table.")) - - # Widget setup - self.table.finder = self.finder - self.table.set_shortcut_data(self.plugin.get_shortcut_data()) - self.table.load_shortcuts() - self.table.finder.setPlaceholderText( - _("Search for a shortcut in the table above")) - self.top_label.setWordWrap(True) - - # Layout - hlayout = QHBoxLayout() - vlayout = QVBoxLayout() - vlayout.addWidget(self.top_label) - hlayout.addWidget(self.label_finder) - hlayout.addWidget(self.finder) - vlayout.addWidget(self.table) - vlayout.addLayout(hlayout) - vlayout.addWidget(self.reset_btn) - self.setLayout(vlayout) - - self.setTabOrder(self.table, self.finder) - self.setTabOrder(self.finder, self.reset_btn) - - # Signals - self.table.proxy_model.dataChanged.connect( - lambda i1, i2, roles, opt='', sect='': self.has_been_modified( - sect, opt)) - self.reset_btn.clicked.connect(self.reset_to_default) - - def check_settings(self): - self.table.check_shortcuts() - - def reset_to_default(self, force=False): - """Reset to default values of the shortcuts making a confirmation.""" - if not force: - reset = QMessageBox.warning( - self, - _("Shortcuts reset"), - _("Do you want to reset to default values?"), - QMessageBox.Yes | QMessageBox.No, - ) - - if reset == QMessageBox.No: - return - - self.plugin.reset_shortcuts() - self.plugin.apply_shortcuts() - self.table.load_shortcuts() - self.load_from_conf() - self.set_modified(False) - - def apply_settings(self, options): - self.table.save_shortcuts() - self.plugin.apply_shortcuts() - self.plugin.apply_conf(options) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shortcut configuration page.""" + +# Standard library imports +import re + +# Third party imports +from qtpy import PYQT5 +from qtpy.QtWidgets import (QHBoxLayout, QLabel, QMessageBox, QPushButton, + QVBoxLayout) + +# Local imports +from spyder.api.preferences import PluginConfigPage +from spyder.api.translations import get_translation +from spyder.plugins.shortcuts.widgets.table import (ShortcutFinder, + ShortcutsTable) +from spyder.utils.icon_manager import ima + +# Localization +_ = get_translation('spyder') + + +class ShortcutsConfigPage(PluginConfigPage): + APPLY_CONF_PAGE_SETTINGS = True + + def setup_page(self): + # Widgets + self.table = ShortcutsTable(self, text_color=ima.MAIN_FG_COLOR) + self.finder = ShortcutFinder(self.table, self.table.set_regex) + self.label_finder = QLabel(_('Search: ')) + self.reset_btn = QPushButton(_("Reset to default values")) + self.top_label = QLabel( + _("Here you can browse the list of all available shortcuts in " + "Spyder. You can also customize them by double-clicking on any " + "entry in this table.")) + + # Widget setup + self.table.finder = self.finder + self.table.set_shortcut_data(self.plugin.get_shortcut_data()) + self.table.load_shortcuts() + self.table.finder.setPlaceholderText( + _("Search for a shortcut in the table above")) + self.top_label.setWordWrap(True) + + # Layout + hlayout = QHBoxLayout() + vlayout = QVBoxLayout() + vlayout.addWidget(self.top_label) + hlayout.addWidget(self.label_finder) + hlayout.addWidget(self.finder) + vlayout.addWidget(self.table) + vlayout.addLayout(hlayout) + vlayout.addWidget(self.reset_btn) + self.setLayout(vlayout) + + self.setTabOrder(self.table, self.finder) + self.setTabOrder(self.finder, self.reset_btn) + + # Signals + self.table.proxy_model.dataChanged.connect( + lambda i1, i2, roles, opt='', sect='': self.has_been_modified( + sect, opt)) + self.reset_btn.clicked.connect(self.reset_to_default) + + def check_settings(self): + self.table.check_shortcuts() + + def reset_to_default(self, force=False): + """Reset to default values of the shortcuts making a confirmation.""" + if not force: + reset = QMessageBox.warning( + self, + _("Shortcuts reset"), + _("Do you want to reset to default values?"), + QMessageBox.Yes | QMessageBox.No, + ) + + if reset == QMessageBox.No: + return + + self.plugin.reset_shortcuts() + self.plugin.apply_shortcuts() + self.table.load_shortcuts() + self.load_from_conf() + self.set_modified(False) + + def apply_settings(self, options): + self.table.save_shortcuts() + self.plugin.apply_shortcuts() + self.plugin.apply_conf(options) diff --git a/spyder/plugins/shortcuts/plugin.py b/spyder/plugins/shortcuts/plugin.py index 3b86b4c3044..9364461a7b6 100644 --- a/spyder/plugins/shortcuts/plugin.py +++ b/spyder/plugins/shortcuts/plugin.py @@ -1,248 +1,248 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Shortcuts Plugin. -""" - -# Standard library imports -import configparser -import sys - -# Third party imports -from qtpy.QtCore import Qt, Signal -from qtpy.QtGui import QKeySequence -from qtpy.QtWidgets import QAction, QShortcut - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections -from spyder.plugins.shortcuts.confpage import ShortcutsConfigPage -from spyder.plugins.shortcuts.widgets.summary import ShortcutsSummaryDialog -from spyder.utils.qthelpers import add_shortcut_to_tooltip, SpyderAction - -# Localization -_ = get_translation('spyder') - - -class ShortcutActions: - ShortcutSummaryAction = "show_shortcut_summary_action" - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Shortcuts(SpyderPluginV2): - """ - Shortcuts Plugin. - """ - - NAME = 'shortcuts' - # TODO: Fix requires to reflect the desired order in the preferences - REQUIRES = [Plugins.Preferences] - OPTIONAL = [Plugins.MainMenu] - CONF_WIDGET_CLASS = ShortcutsConfigPage - CONF_SECTION = NAME - CONF_FILE = False - CAN_BE_DISABLED = False - - # --- Signals - # ------------------------------------------------------------------------ - sig_shortcuts_updated = Signal() - """ - This signal is emitted to inform shortcuts have been updated. - """ - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Keyboard shortcuts") - - def get_description(self): - return _("Manage application, widget and actions shortcuts.") - - def get_icon(self): - return self.create_icon('keyboard') - - def on_initialize(self): - self._shortcut_data = [] - self.create_action( - ShortcutActions.ShortcutSummaryAction, - text=_("Shortcuts Summary"), - triggered=lambda: self.show_summary(), - register_shortcut=True, - context=Qt.ApplicationShortcut, - ) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - shortcuts_action = self.get_action( - ShortcutActions.ShortcutSummaryAction) - - # Add to Help menu. - mainmenu.add_item_to_application_menu( - shortcuts_action, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.Documentation, - ) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - ShortcutActions.ShortcutSummaryAction, - menu_id=ApplicationMenus.Help - ) - - def on_mainwindow_visible(self): - self.apply_shortcuts() - - # --- Public API - # ------------------------------------------------------------------------ - def get_shortcut_data(self): - """ - Return the registered shortcut data from the main application window. - """ - return self._shortcut_data - - def reset_shortcuts(self): - """Reset shrotcuts.""" - if self._conf: - self._conf.reset_shortcuts() - - def show_summary(self): - """Reset shortcuts.""" - dlg = ShortcutsSummaryDialog(None) - dlg.exec_() - - def register_shortcut(self, qaction_or_qshortcut, context, name, - add_shortcut_to_tip=True, plugin_name=None): - """ - Register QAction or QShortcut to Spyder main application, - with shortcut (context, name, default) - """ - self._shortcut_data.append((qaction_or_qshortcut, context, - name, add_shortcut_to_tip, plugin_name)) - - def unregister_shortcut(self, qaction_or_qshortcut, context, name, - add_shortcut_to_tip=True, plugin_name=None): - """ - Unregister QAction or QShortcut from Spyder main application. - """ - data = (qaction_or_qshortcut, context, name, add_shortcut_to_tip, - plugin_name) - - if data in self._shortcut_data: - self._shortcut_data.remove(data) - - def apply_shortcuts(self): - """ - Apply shortcuts settings to all widgets/plugins. - """ - toberemoved = [] - - # TODO: Check shortcut existence based on action existence, so that we - # can update shortcut names without showing the old ones on the - # preferences - for index, (qobject, context, name, add_shortcut_to_tip, - plugin_name) in enumerate(self._shortcut_data): - try: - shortcut_sequence = self.get_shortcut(context, name, - plugin_name) - except (configparser.NoSectionError, configparser.NoOptionError): - # If shortcut does not exist, save it to CONF. This is an - # action for which there is no shortcut assigned (yet) in - # the configuration - self.set_shortcut(context, name, '', plugin_name) - shortcut_sequence = '' - - if shortcut_sequence: - keyseq = QKeySequence(shortcut_sequence) - else: - # Needed to remove old sequences that were cleared. - # See spyder-ide/spyder#12992 - keyseq = QKeySequence() - - # Do not register shortcuts for the toggle view action. - # The shortcut will be displayed only on the menus and handled by - # about to show/hide signals. - if (name.startswith('switch to') - and isinstance(qobject, SpyderAction)): - keyseq = QKeySequence() - - try: - if isinstance(qobject, QAction): - if (sys.platform == 'darwin' - and qobject._shown_shortcut == 'missing'): - qobject._shown_shortcut = keyseq - else: - qobject.setShortcut(keyseq) - - if add_shortcut_to_tip: - add_shortcut_to_tooltip(qobject, context, name) - elif isinstance(qobject, QShortcut): - qobject.setKey(keyseq) - except RuntimeError: - # Object has been deleted - toberemoved.append(index) - - for index in sorted(toberemoved, reverse=True): - self._shortcut_data.pop(index) - - self.sig_shortcuts_updated.emit() - - def get_shortcut(self, context, name, plugin_name=None): - """ - Get keyboard shortcut (key sequence string). - - Parameters - ---------- - context: - Context must be either '_' for global or the name of a plugin. - name: str - Name of the shortcut. - plugin_id: spyder.api.plugins.SpyderpluginV2 or None - The plugin for which the shortcut is registered. Default is None. - - Returns - ------- - Shortcut - A shortcut object. - """ - return self._conf.get_shortcut(context, name, plugin_name=plugin_name) - - def set_shortcut(self, context, name, keystr, plugin_id=None): - """ - Set keyboard shortcut (key sequence string). - - Parameters - ---------- - context: - Context must be either '_' for global or the name of a plugin. - name: str - Name of the shortcut. - keystr: str - Shortcut keys in string form. - plugin_id: spyder.api.plugins.SpyderpluginV2 or None - The plugin for which the shortcut is registered. Default is None. - """ - self._conf.set_shortcut(context, name, keystr, plugin_name=plugin_id) +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Shortcuts Plugin. +""" + +# Standard library imports +import configparser +import sys + +# Third party imports +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QKeySequence +from qtpy.QtWidgets import QAction, QShortcut + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections +from spyder.plugins.shortcuts.confpage import ShortcutsConfigPage +from spyder.plugins.shortcuts.widgets.summary import ShortcutsSummaryDialog +from spyder.utils.qthelpers import add_shortcut_to_tooltip, SpyderAction + +# Localization +_ = get_translation('spyder') + + +class ShortcutActions: + ShortcutSummaryAction = "show_shortcut_summary_action" + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Shortcuts(SpyderPluginV2): + """ + Shortcuts Plugin. + """ + + NAME = 'shortcuts' + # TODO: Fix requires to reflect the desired order in the preferences + REQUIRES = [Plugins.Preferences] + OPTIONAL = [Plugins.MainMenu] + CONF_WIDGET_CLASS = ShortcutsConfigPage + CONF_SECTION = NAME + CONF_FILE = False + CAN_BE_DISABLED = False + + # --- Signals + # ------------------------------------------------------------------------ + sig_shortcuts_updated = Signal() + """ + This signal is emitted to inform shortcuts have been updated. + """ + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Keyboard shortcuts") + + def get_description(self): + return _("Manage application, widget and actions shortcuts.") + + def get_icon(self): + return self.create_icon('keyboard') + + def on_initialize(self): + self._shortcut_data = [] + self.create_action( + ShortcutActions.ShortcutSummaryAction, + text=_("Shortcuts Summary"), + triggered=lambda: self.show_summary(), + register_shortcut=True, + context=Qt.ApplicationShortcut, + ) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + shortcuts_action = self.get_action( + ShortcutActions.ShortcutSummaryAction) + + # Add to Help menu. + mainmenu.add_item_to_application_menu( + shortcuts_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Documentation, + ) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + ShortcutActions.ShortcutSummaryAction, + menu_id=ApplicationMenus.Help + ) + + def on_mainwindow_visible(self): + self.apply_shortcuts() + + # --- Public API + # ------------------------------------------------------------------------ + def get_shortcut_data(self): + """ + Return the registered shortcut data from the main application window. + """ + return self._shortcut_data + + def reset_shortcuts(self): + """Reset shrotcuts.""" + if self._conf: + self._conf.reset_shortcuts() + + def show_summary(self): + """Reset shortcuts.""" + dlg = ShortcutsSummaryDialog(None) + dlg.exec_() + + def register_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=True, plugin_name=None): + """ + Register QAction or QShortcut to Spyder main application, + with shortcut (context, name, default) + """ + self._shortcut_data.append((qaction_or_qshortcut, context, + name, add_shortcut_to_tip, plugin_name)) + + def unregister_shortcut(self, qaction_or_qshortcut, context, name, + add_shortcut_to_tip=True, plugin_name=None): + """ + Unregister QAction or QShortcut from Spyder main application. + """ + data = (qaction_or_qshortcut, context, name, add_shortcut_to_tip, + plugin_name) + + if data in self._shortcut_data: + self._shortcut_data.remove(data) + + def apply_shortcuts(self): + """ + Apply shortcuts settings to all widgets/plugins. + """ + toberemoved = [] + + # TODO: Check shortcut existence based on action existence, so that we + # can update shortcut names without showing the old ones on the + # preferences + for index, (qobject, context, name, add_shortcut_to_tip, + plugin_name) in enumerate(self._shortcut_data): + try: + shortcut_sequence = self.get_shortcut(context, name, + plugin_name) + except (configparser.NoSectionError, configparser.NoOptionError): + # If shortcut does not exist, save it to CONF. This is an + # action for which there is no shortcut assigned (yet) in + # the configuration + self.set_shortcut(context, name, '', plugin_name) + shortcut_sequence = '' + + if shortcut_sequence: + keyseq = QKeySequence(shortcut_sequence) + else: + # Needed to remove old sequences that were cleared. + # See spyder-ide/spyder#12992 + keyseq = QKeySequence() + + # Do not register shortcuts for the toggle view action. + # The shortcut will be displayed only on the menus and handled by + # about to show/hide signals. + if (name.startswith('switch to') + and isinstance(qobject, SpyderAction)): + keyseq = QKeySequence() + + try: + if isinstance(qobject, QAction): + if (sys.platform == 'darwin' + and qobject._shown_shortcut == 'missing'): + qobject._shown_shortcut = keyseq + else: + qobject.setShortcut(keyseq) + + if add_shortcut_to_tip: + add_shortcut_to_tooltip(qobject, context, name) + elif isinstance(qobject, QShortcut): + qobject.setKey(keyseq) + except RuntimeError: + # Object has been deleted + toberemoved.append(index) + + for index in sorted(toberemoved, reverse=True): + self._shortcut_data.pop(index) + + self.sig_shortcuts_updated.emit() + + def get_shortcut(self, context, name, plugin_name=None): + """ + Get keyboard shortcut (key sequence string). + + Parameters + ---------- + context: + Context must be either '_' for global or the name of a plugin. + name: str + Name of the shortcut. + plugin_id: spyder.api.plugins.SpyderpluginV2 or None + The plugin for which the shortcut is registered. Default is None. + + Returns + ------- + Shortcut + A shortcut object. + """ + return self._conf.get_shortcut(context, name, plugin_name=plugin_name) + + def set_shortcut(self, context, name, keystr, plugin_id=None): + """ + Set keyboard shortcut (key sequence string). + + Parameters + ---------- + context: + Context must be either '_' for global or the name of a plugin. + name: str + Name of the shortcut. + keystr: str + Shortcut keys in string form. + plugin_id: spyder.api.plugins.SpyderpluginV2 or None + The plugin for which the shortcut is registered. Default is None. + """ + self._conf.set_shortcut(context, name, keystr, plugin_name=plugin_id) diff --git a/spyder/plugins/shortcuts/widgets/table.py b/spyder/plugins/shortcuts/widgets/table.py index 5a375fb9913..1f95d42778d 100644 --- a/spyder/plugins/shortcuts/widgets/table.py +++ b/spyder/plugins/shortcuts/widgets/table.py @@ -1,940 +1,940 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Shortcut management widgets.""" - -# Standard library importsimport re -import re - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QAbstractTableModel, QEvent, QModelIndex, - QSortFilterProxyModel, Qt, Slot) -from qtpy.QtGui import QIcon, QKeySequence -from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog, - QGridLayout, QHBoxLayout, QKeySequenceEdit, - QLabel, QLineEdit, QMessageBox, QPushButton, - QSpacerItem, QTableView, QVBoxLayout) - -# Local imports -from spyder.api.translations import get_translation -from spyder.config.manager import CONF -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import create_toolbutton -from spyder.utils.stringmatching import get_search_regex, get_search_scores -from spyder.widgets.helperwidgets import (VALID_FINDER_CHARS, - CustomSortFilterProxy, - FinderLineEdit, HelperToolButton, - HTMLDelegate) - -# Localization -_ = get_translation('spyder') - - -# Valid shortcut keys -SINGLE_KEYS = ["F{}".format(_i) for _i in range(1, 36)] + ["Del", "Esc"] -EDITOR_SINGLE_KEYS = SINGLE_KEYS + ["Home", "End", "Ins", "Enter", - "Return", "Backspace", "Tab", - "PageUp", "PageDown", "Clear", "Pause", - "Left", "Up", "Right", "Down"] - -# Key sequences blacklist for the shortcut editor dialog -BLACKLIST = {} - -# Error codes for the shortcut editor dialog -NO_WARNING = 0 -SEQUENCE_EMPTY = 1 -SEQUENCE_CONFLICT = 2 -INVALID_KEY = 3 -IN_BLACKLIST = 4 - - -class ShortcutTranslator(QKeySequenceEdit): - """ - A QKeySequenceEdit that is not meant to be shown and is used only - to convert QKeyEvent into QKeySequence. To our knowledge, this is - the only way to do this within the Qt framework, because the code that does - this in Qt is protected. Porting the code to Python would be nearly - impossible because it relies on low level and OS-dependent Qt libraries - that are not public for the most part. - """ - - def __init__(self): - super(ShortcutTranslator, self).__init__() - self.hide() - - def keyevent_to_keyseq(self, event): - """Return a QKeySequence representation of the provided QKeyEvent.""" - self.keyPressEvent(event) - event.accept() - return self.keySequence() - - def keyReleaseEvent(self, event): - """Qt Override""" - return False - - def timerEvent(self, event): - """Qt Override""" - return False - - def event(self, event): - """Qt Override""" - return False - - -class ShortcutLineEdit(QLineEdit): - """QLineEdit that filters its key press and release events.""" - - def __init__(self, parent): - super(ShortcutLineEdit, self).__init__(parent) - self.setReadOnly(True) - - tw = self.fontMetrics().width( - "Ctrl+Shift+Alt+Backspace, Ctrl+Shift+Alt+Backspace") - fw = self.style().pixelMetric(self.style().PM_DefaultFrameWidth) - self.setMinimumWidth(tw + (2 * fw) + 4) - # We need to add 4 to take into account the horizontalMargin of the - # line edit, whose value is hardcoded in qt. - - def keyPressEvent(self, e): - """Qt Override""" - self.parent().keyPressEvent(e) - - def keyReleaseEvent(self, e): - """Qt Override""" - self.parent().keyReleaseEvent(e) - - def setText(self, sequence): - """Qt method extension.""" - self.setToolTip(sequence) - super(ShortcutLineEdit, self).setText(sequence) - - -class ShortcutFinder(FinderLineEdit): - """Textbox for filtering listed shortcuts in the table.""" - - def keyPressEvent(self, event): - """Qt and FilterLineEdit Override.""" - key = event.key() - if key in [Qt.Key_Up]: - self._parent.previous_row() - elif key in [Qt.Key_Down]: - self._parent.next_row() - elif key in [Qt.Key_Enter, Qt.Key_Return]: - self._parent.show_editor() - else: - super(ShortcutFinder, self).keyPressEvent(event) - - -class ShortcutEditor(QDialog): - """A dialog for entering key sequences.""" - - def __init__(self, parent, context, name, sequence, shortcuts): - super(ShortcutEditor, self).__init__(parent) - self._parent = parent - self.setWindowFlags(self.windowFlags() & - ~Qt.WindowContextHelpButtonHint) - - self.context = context - self.name = name - self.shortcuts = shortcuts - self.current_sequence = sequence or _('') - self._qsequences = list() - - self.setup() - self.update_warning() - - @property - def new_sequence(self): - """Return a string representation of the new key sequence.""" - return ', '.join(self._qsequences) - - @property - def new_qsequence(self): - """Return the QKeySequence object of the new key sequence.""" - return QKeySequence(self.new_sequence) - - def setup(self): - """Setup the ShortcutEditor with the provided arguments.""" - # Widgets - icon_info = HelperToolButton() - icon_info.setIcon(ima.get_std_icon('MessageBoxInformation')) - layout_icon_info = QVBoxLayout() - layout_icon_info.setContentsMargins(0, 0, 0, 0) - layout_icon_info.setSpacing(0) - layout_icon_info.addWidget(icon_info) - layout_icon_info.addStretch(100) - - self.label_info = QLabel() - self.label_info.setText( - _("Press the new shortcut and select 'Ok' to confirm, " - "click 'Cancel' to revert to the previous state, " - "or use 'Clear' to unbind the command from a shortcut.")) - self.label_info.setAlignment(Qt.AlignTop | Qt.AlignLeft) - self.label_info.setWordWrap(True) - layout_info = QHBoxLayout() - layout_info.setContentsMargins(0, 0, 0, 0) - layout_info.addLayout(layout_icon_info) - layout_info.addWidget(self.label_info) - layout_info.setStretch(1, 100) - - self.label_current_sequence = QLabel(_("Current shortcut:")) - self.text_current_sequence = QLabel(self.current_sequence) - - self.label_new_sequence = QLabel(_("New shortcut:")) - self.text_new_sequence = ShortcutLineEdit(self) - self.text_new_sequence.setPlaceholderText(_("Press shortcut.")) - - self.helper_button = HelperToolButton() - self.helper_button.setIcon(QIcon()) - self.label_warning = QLabel() - self.label_warning.setWordWrap(True) - self.label_warning.setAlignment(Qt.AlignTop | Qt.AlignLeft) - - self.button_default = QPushButton(_('Default')) - self.button_ok = QPushButton(_('Ok')) - self.button_ok.setEnabled(False) - self.button_clear = QPushButton(_('Clear')) - self.button_cancel = QPushButton(_('Cancel')) - button_box = QHBoxLayout() - button_box.addWidget(self.button_default) - button_box.addStretch(100) - button_box.addWidget(self.button_ok) - button_box.addWidget(self.button_clear) - button_box.addWidget(self.button_cancel) - - # New Sequence button box - self.btn_clear_sequence = create_toolbutton( - self, icon=ima.icon('editclear'), - tip=_("Clear all entered key sequences"), - triggered=self.clear_new_sequence) - self.button_back_sequence = create_toolbutton( - self, icon=ima.icon('previous'), - tip=_("Remove last key sequence entered"), - triggered=self.back_new_sequence) - - newseq_btnbar = QHBoxLayout() - newseq_btnbar.setSpacing(0) - newseq_btnbar.setContentsMargins(0, 0, 0, 0) - newseq_btnbar.addWidget(self.button_back_sequence) - newseq_btnbar.addWidget(self.btn_clear_sequence) - - # Setup widgets - self.setWindowTitle(_('Shortcut: {0}').format(self.name)) - self.helper_button.setToolTip('') - style = """ - QToolButton { - margin:1px; - border: 0px solid grey; - padding:0px; - border-radius: 0px; - }""" - self.helper_button.setStyleSheet(style) - icon_info.setToolTip('') - icon_info.setStyleSheet(style) - - # Layout - layout_sequence = QGridLayout() - layout_sequence.setContentsMargins(0, 0, 0, 0) - layout_sequence.addLayout(layout_info, 0, 0, 1, 4) - layout_sequence.addItem(QSpacerItem(15, 15), 1, 0, 1, 4) - layout_sequence.addWidget(self.label_current_sequence, 2, 0) - layout_sequence.addWidget(self.text_current_sequence, 2, 2) - layout_sequence.addWidget(self.label_new_sequence, 3, 0) - layout_sequence.addWidget(self.helper_button, 3, 1) - layout_sequence.addWidget(self.text_new_sequence, 3, 2) - layout_sequence.addLayout(newseq_btnbar, 3, 3) - layout_sequence.addWidget(self.label_warning, 4, 2, 1, 2) - layout_sequence.setColumnStretch(2, 100) - layout_sequence.setRowStretch(4, 100) - - layout = QVBoxLayout(self) - layout.addLayout(layout_sequence) - layout.addSpacing(10) - layout.addLayout(button_box) - layout.setSizeConstraint(layout.SetFixedSize) - - # Signals - self.button_ok.clicked.connect(self.accept_override) - self.button_clear.clicked.connect(self.unbind_shortcut) - self.button_cancel.clicked.connect(self.reject) - self.button_default.clicked.connect(self.set_sequence_to_default) - - # Set all widget to no focus so that we can register key - # press event. - widgets = ( - self.label_warning, self.helper_button, self.text_new_sequence, - self.button_clear, self.button_default, self.button_cancel, - self.button_ok, self.btn_clear_sequence, self.button_back_sequence) - for w in widgets: - w.setFocusPolicy(Qt.NoFocus) - w.clearFocus() - - @Slot() - def reject(self): - """Slot for rejected signal.""" - # Added for spyder-ide/spyder#5426. Due to the focusPolicy of - # Qt.NoFocus for the buttons, if the cancel button was clicked without - # first setting focus to the button, it would cause a seg fault crash. - self.button_cancel.setFocus() - super(ShortcutEditor, self).reject() - - @Slot() - def accept(self): - """Slot for accepted signal.""" - # Added for spyder-ide/spyder#5426. Due to the focusPolicy of - # Qt.NoFocus for the buttons, if the cancel button was clicked without - # first setting focus to the button, it would cause a seg fault crash. - self.button_ok.setFocus() - super(ShortcutEditor, self).accept() - - def event(self, event): - """Qt method override.""" - # We reroute all ShortcutOverride events to our keyPressEvent and block - # any KeyPress and Shortcut event. This allows to register default - # Qt shortcuts for which no key press event are emitted. - # See spyder-ide/spyder/issues/10786. - if event.type() == QEvent.ShortcutOverride: - self.keyPressEvent(event) - return True - elif event.type() in [QEvent.KeyPress, QEvent.Shortcut]: - return True - else: - return super(ShortcutEditor, self).event(event) - - def keyPressEvent(self, event): - """Qt method override.""" - event_key = event.key() - if not event_key or event_key == Qt.Key_unknown: - return - if len(self._qsequences) == 4: - # QKeySequence accepts a maximum of 4 different sequences. - return - if event_key in [Qt.Key_Control, Qt.Key_Shift, - Qt.Key_Alt, Qt.Key_Meta]: - # The event corresponds to just and only a special key. - return - - translator = ShortcutTranslator() - event_keyseq = translator.keyevent_to_keyseq(event) - event_keystr = event_keyseq.toString(QKeySequence.PortableText) - self._qsequences.append(event_keystr) - self.update_warning() - - def check_conflicts(self): - """Check shortcuts for conflicts.""" - conflicts = [] - if len(self._qsequences) == 0: - return conflicts - - new_qsequence = self.new_qsequence - for shortcut in self.shortcuts: - shortcut_qsequence = QKeySequence.fromString(str(shortcut.key)) - if shortcut_qsequence.isEmpty(): - continue - if (shortcut.context, shortcut.name) == (self.context, self.name): - continue - if shortcut.context in [self.context, '_'] or self.context == '_': - if (shortcut_qsequence.matches(new_qsequence) or - new_qsequence.matches(shortcut_qsequence)): - conflicts.append(shortcut) - return conflicts - - def check_ascii(self): - """ - Check that all characters in the new sequence are ascii or else the - shortcut will not work. - """ - try: - self.new_sequence.encode('ascii') - except UnicodeEncodeError: - return False - else: - return True - - def check_singlekey(self): - """Check if the first sub-sequence of the new key sequence is valid.""" - if len(self._qsequences) == 0: - return True - else: - keystr = self._qsequences[0] - valid_single_keys = (EDITOR_SINGLE_KEYS if - self.context == 'editor' else SINGLE_KEYS) - if any((m in keystr for m in ('Ctrl', 'Alt', 'Shift', 'Meta'))): - return True - else: - # This means that the the first subsequence is composed of - # a single key with no modifier. - valid_single_keys = (EDITOR_SINGLE_KEYS if - self.context == 'editor' else SINGLE_KEYS) - if any((k == keystr for k in valid_single_keys)): - return True - else: - return False - - def update_warning(self): - """Update the warning label, buttons state and sequence text.""" - new_qsequence = self.new_qsequence - new_sequence = self.new_sequence - self.text_new_sequence.setText( - new_qsequence.toString(QKeySequence.NativeText)) - - conflicts = self.check_conflicts() - if len(self._qsequences) == 0: - warning = SEQUENCE_EMPTY - tip = '' - icon = QIcon() - elif conflicts: - warning = SEQUENCE_CONFLICT - template = '

    {0}

    {1}{2}' - tip_title = _('This key sequence conflicts with:') - tip_body = '' - for s in conflicts: - tip_body += ' ' * 2 - tip_body += ' - {0}: {1}
    '.format(s.context, s.name) - tip_body += '
    ' - if len(conflicts) == 1: - tip_override = _("Press 'Ok' to unbind it and assign it to") - else: - tip_override = _("Press 'Ok' to unbind them and assign it to") - tip_override += ' {}.'.format(self.name) - tip = template.format(tip_title, tip_body, tip_override) - icon = ima.get_std_icon('MessageBoxWarning') - elif new_sequence in BLACKLIST: - warning = IN_BLACKLIST - tip = _('This key sequence is forbidden.') - icon = ima.get_std_icon('MessageBoxWarning') - elif self.check_singlekey() is False or self.check_ascii() is False: - warning = INVALID_KEY - tip = _('This key sequence is invalid.') - icon = ima.get_std_icon('MessageBoxWarning') - else: - warning = NO_WARNING - tip = _('This key sequence is valid.') - icon = ima.get_std_icon('DialogApplyButton') - - self.warning = warning - self.conflicts = conflicts - - self.helper_button.setIcon(icon) - self.button_ok.setEnabled( - self.warning in [NO_WARNING, SEQUENCE_CONFLICT]) - self.label_warning.setText(tip) - - def set_sequence_from_str(self, sequence): - """ - This is a convenience method to set the new QKeySequence of the - shortcut editor from a string. - """ - self._qsequences = [QKeySequence(s) for s in sequence.split(', ')] - self.update_warning() - - def set_sequence_to_default(self): - """Set the new sequence to the default value defined in the config.""" - sequence = CONF.get_default( - 'shortcuts', "{}/{}".format(self.context, self.name)) - if sequence: - self._qsequences = sequence.split(', ') - self.update_warning() - else: - self.unbind_shortcut() - - def back_new_sequence(self): - """Remove the last subsequence from the sequence compound.""" - self._qsequences = self._qsequences[:-1] - self.update_warning() - - def clear_new_sequence(self): - """Clear the new sequence.""" - self._qsequences = [] - self.update_warning() - - def unbind_shortcut(self): - """Unbind the shortcut.""" - self._qsequences = [] - self.accept() - - def accept_override(self): - """Unbind all conflicted shortcuts, and accept the new one""" - conflicts = self.check_conflicts() - if conflicts: - for shortcut in conflicts: - shortcut.key = '' - self.accept() - - -class Shortcut(object): - """Shortcut convenience class for holding shortcut context, name, - original ordering index, key sequence for the shortcut and localized text. - """ - - def __init__(self, context, name, key=None): - self.index = 0 # Sorted index. Populated when loading shortcuts - self.context = context - self.name = name - self.key = key - - def __str__(self): - return "{0}/{1}: {2}".format(self.context, self.name, self.key) - - def load(self): - self.key = CONF.get_shortcut(self.context, self.name) - - def save(self): - CONF.set_shortcut(self.context, self.name, self.key) - - -CONTEXT, NAME, SEQUENCE, SEARCH_SCORE = [0, 1, 2, 3] - - -class ShortcutsModel(QAbstractTableModel): - def __init__(self, parent, text_color=None, text_color_highlight=None): - QAbstractTableModel.__init__(self) - self._parent = parent - - self.shortcuts = [] - self.scores = [] - self.rich_text = [] - self.normal_text = [] - self.context_rich_text = [] - self.letters = '' - self.label = QLabel() - self.widths = [] - - # Needed to compensate for the HTMLDelegate color selection unawarness - palette = parent.palette() - if text_color is None: - self.text_color = palette.text().color().name() - else: - self.text_color = text_color - - if text_color_highlight is None: - self.text_color_highlight = \ - palette.highlightedText().color().name() - else: - self.text_color_highlight = text_color_highlight - - def current_index(self): - """Get the currently selected index in the parent table view.""" - i = self._parent.proxy_model.mapToSource(self._parent.currentIndex()) - return i - - def sortByName(self): - """Qt Override.""" - self.shortcuts = sorted(self.shortcuts, - key=lambda x: x.context+'/'+x.name) - self.reset() - - def flags(self, index): - """Qt Override.""" - if not index.isValid(): - return Qt.ItemIsEnabled - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index))) - - def data(self, index, role=Qt.DisplayRole): - """Qt Override.""" - row = index.row() - if not index.isValid() or not (0 <= row < len(self.shortcuts)): - return to_qvariant() - - shortcut = self.shortcuts[row] - key = shortcut.key - column = index.column() - - if role == Qt.DisplayRole: - color = self.text_color - if self._parent == QApplication.focusWidget(): - if self.current_index().row() == row: - color = self.text_color_highlight - else: - color = self.text_color - if column == CONTEXT: - if len(self.context_rich_text) > 0: - text = self.context_rich_text[row] - else: - text = shortcut.context - text = '

    {1}

    '.format(color, text) - return to_qvariant(text) - elif column == NAME: - text = self.rich_text[row] - text = '

    {1}

    '.format(color, text) - return to_qvariant(text) - elif column == SEQUENCE: - text = QKeySequence(key).toString(QKeySequence.NativeText) - return to_qvariant(text) - elif column == SEARCH_SCORE: - # Treating search scores as a table column simplifies the - # sorting once a score for a specific string in the finder - # has been defined. This column however should always remain - # hidden. - return to_qvariant(self.scores[row]) - elif role == Qt.TextAlignmentRole: - return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) - return to_qvariant() - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """Qt Override.""" - if role == Qt.TextAlignmentRole: - if orientation == Qt.Horizontal: - return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) - return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) - if role != Qt.DisplayRole: - return to_qvariant() - if orientation == Qt.Horizontal: - if section == CONTEXT: - return to_qvariant(_("Context")) - elif section == NAME: - return to_qvariant(_("Name")) - elif section == SEQUENCE: - return to_qvariant(_("Shortcut")) - elif section == SEARCH_SCORE: - return to_qvariant(_("Score")) - return to_qvariant() - - def rowCount(self, index=QModelIndex()): - """Qt Override.""" - return len(self.shortcuts) - - def columnCount(self, index=QModelIndex()): - """Qt Override.""" - return 4 - - def setData(self, index, value, role=Qt.EditRole): - """Qt Override.""" - if index.isValid() and 0 <= index.row() < len(self.shortcuts): - shortcut = self.shortcuts[index.row()] - column = index.column() - text = from_qvariant(value, str) - if column == SEQUENCE: - shortcut.key = text - self.dataChanged.emit(index, index) - return True - return False - - def update_search_letters(self, text): - """Update search letters with text input in search box.""" - self.letters = text - contexts = [shortcut.context for shortcut in self.shortcuts] - names = [shortcut.name for shortcut in self.shortcuts] - context_results = get_search_scores( - text, contexts, template='{0}') - results = get_search_scores(text, names, template='{0}') - __, self.context_rich_text, context_scores = ( - zip(*context_results)) - self.normal_text, self.rich_text, self.scores = zip(*results) - self.scores = [x + y for x, y in zip(self.scores, context_scores)] - self.reset() - - def update_active_row(self): - """Update active row to update color in selected text.""" - self.data(self.current_index()) - - def row(self, row_num): - """Get row based on model index. Needed for the custom proxy model.""" - return self.shortcuts[row_num] - - def reset(self): - """"Reset model to take into account new search letters.""" - self.beginResetModel() - self.endResetModel() - - -class ShortcutsTable(QTableView): - def __init__(self, - parent=None, text_color=None, text_color_highlight=None): - QTableView.__init__(self, parent) - self._parent = parent - self.finder = None - self.shortcut_data = None - self.source_model = ShortcutsModel( - self, - text_color=text_color, - text_color_highlight=text_color_highlight) - self.proxy_model = ShortcutsSortFilterProxy(self) - self.last_regex = '' - - self.proxy_model.setSourceModel(self.source_model) - self.proxy_model.setDynamicSortFilter(True) - self.proxy_model.setFilterByColumn(CONTEXT) - self.proxy_model.setFilterByColumn(NAME) - self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) - self.setModel(self.proxy_model) - - self.hideColumn(SEARCH_SCORE) - self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9)) - self.setItemDelegateForColumn(CONTEXT, HTMLDelegate(self, margin=9)) - self.setSelectionBehavior(QAbstractItemView.SelectRows) - self.setSelectionMode(QAbstractItemView.SingleSelection) - self.setSortingEnabled(True) - self.setEditTriggers(QAbstractItemView.AllEditTriggers) - self.selectionModel().selectionChanged.connect(self.selection) - - self.verticalHeader().hide() - - def set_shortcut_data(self, shortcut_data): - """ - Shortcut data comes from the registration of actions on the main - window. This allows to only display the right actions on the - shortcut table. This also allows to display the localize text. - """ - self.shortcut_data = shortcut_data - - def focusOutEvent(self, e): - """Qt Override.""" - self.source_model.update_active_row() - super(ShortcutsTable, self).focusOutEvent(e) - - def focusInEvent(self, e): - """Qt Override.""" - super(ShortcutsTable, self).focusInEvent(e) - self.selectRow(self.currentIndex().row()) - - def selection(self, index): - """Update selected row.""" - self.update() - self.isActiveWindow() - - def adjust_cells(self): - """Adjust column size based on contents.""" - self.resizeColumnsToContents() - fm = self.horizontalHeader().fontMetrics() - names = [fm.width(s.name + ' '*9) for s in self.source_model.shortcuts] - if len(names) == 0: - # This condition only applies during testing - names = [0] - self.setColumnWidth(NAME, max(names)) - self.horizontalHeader().setStretchLastSection(True) - - def load_shortcuts(self): - """Load shortcuts and assign to table model.""" - # item[1] -> context, item[2] -> name - # Data might be capitalized so we user lower() - # See: spyder-ide/spyder/#12415 - shortcut_data = set([(item[1].lower(), item[2].lower()) for item - in self.shortcut_data]) - shortcut_data = list(sorted(set(shortcut_data))) - shortcuts = [] - - for context, name, keystr in CONF.iter_shortcuts(): - if (context, name) in shortcut_data: - context = context.lower() - name = name.lower() - # Only add to table actions that are registered from the main - # window - shortcut = Shortcut(context, name, keystr) - shortcuts.append(shortcut) - - shortcuts = sorted(shortcuts, key=lambda item: item.context+item.name) - - # Store the original order of shortcuts - for i, shortcut in enumerate(shortcuts): - shortcut.index = i - - self.source_model.shortcuts = shortcuts - self.source_model.scores = [0]*len(shortcuts) - self.source_model.rich_text = [s.name for s in shortcuts] - self.source_model.reset() - self.adjust_cells() - self.sortByColumn(CONTEXT, Qt.AscendingOrder) - - def check_shortcuts(self): - """Check shortcuts for conflicts.""" - conflicts = [] - for index, sh1 in enumerate(self.source_model.shortcuts): - if index == len(self.source_model.shortcuts)-1: - break - if str(sh1.key) == '': - continue - for sh2 in self.source_model.shortcuts[index+1:]: - if sh2 is sh1: - continue - if str(sh2.key) == str(sh1.key) \ - and (sh1.context == sh2.context or sh1.context == '_' or - sh2.context == '_'): - conflicts.append((sh1, sh2)) - if conflicts: - self.parent().show_this_page.emit() - cstr = "\n".join(['%s <---> %s' % (sh1, sh2) - for sh1, sh2 in conflicts]) - QMessageBox.warning(self, _("Conflicts"), - _("The following conflicts have been " - "detected:")+"\n"+cstr, QMessageBox.Ok) - - def save_shortcuts(self): - """Save shortcuts from table model.""" - self.check_shortcuts() - for shortcut in self.source_model.shortcuts: - shortcut.save() - - def show_editor(self): - """Create, setup and display the shortcut editor dialog.""" - index = self.proxy_model.mapToSource(self.currentIndex()) - row, column = index.row(), index.column() - shortcuts = self.source_model.shortcuts - context = shortcuts[row].context - name = shortcuts[row].name - - sequence_index = self.source_model.index(row, SEQUENCE) - sequence = sequence_index.data() - - dialog = ShortcutEditor(self, context, name, sequence, shortcuts) - - if dialog.exec_(): - new_sequence = dialog.new_sequence - self.source_model.setData(sequence_index, new_sequence) - - def set_regex(self, regex=None, reset=False): - """Update the regex text for the shortcut finder.""" - if reset: - text = '' - else: - text = self.finder.text().replace(' ', '').lower() - - self.proxy_model.set_filter(text) - self.source_model.update_search_letters(text) - self.sortByColumn(SEARCH_SCORE, Qt.AscendingOrder) - - if self.last_regex != regex: - self.selectRow(0) - self.last_regex = regex - - def next_row(self): - """Move to next row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row + 1 == rows: - row = -1 - self.selectRow(row + 1) - - def previous_row(self): - """Move to previous row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row == 0: - row = rows - self.selectRow(row - 1) - - def keyPressEvent(self, event): - """Qt Override.""" - key = event.key() - if key in [Qt.Key_Enter, Qt.Key_Return]: - self.show_editor() - elif key in [Qt.Key_Tab]: - self.finder.setFocus() - elif key in [Qt.Key_Backtab]: - self.parent().reset_btn.setFocus() - elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: - super(ShortcutsTable, self).keyPressEvent(event) - elif key not in [Qt.Key_Escape, Qt.Key_Space]: - text = event.text() - if text: - if re.search(VALID_FINDER_CHARS, text) is not None: - self.finder.setFocus() - self.finder.set_text(text) - elif key in [Qt.Key_Escape]: - self.finder.keyPressEvent(event) - - def mouseDoubleClickEvent(self, event): - """Qt Override.""" - self.show_editor() - self.update() - - -class ShortcutsSortFilterProxy(QSortFilterProxyModel): - """Custom proxy for supporting shortcuts multifiltering.""" - - def __init__(self, parent=None): - """Initialize the multiple sort filter proxy.""" - super(ShortcutsSortFilterProxy, self).__init__(parent) - self._parent = parent - self.pattern = re.compile(r'') - self.filters = {} - - def setFilterByColumn(self, column): - """Set regular expression in the given column.""" - self.filters[column] = self.pattern - self.invalidateFilter() - - def set_filter(self, text): - """Set regular expression for filter.""" - for key, __ in self.filters.items(): - self.pattern = get_search_regex(text) - if self.pattern and text: - self._parent.setSortingEnabled(False) - else: - self._parent.setSortingEnabled(True) - self.filters[key] = self.pattern - self.invalidateFilter() - - def clearFilter(self, column): - """Clear the filter of the given column.""" - self.filters.pop(column) - self.invalidateFilter() - - def clearFilters(self): - """Clear all the filters.""" - self.filters = {} - self.invalidateFilter() - - def filterAcceptsRow(self, row_num, parent): - """Qt override. - - Reimplemented to allow filtering in multiple columns. - """ - results = [] - for key, regex in self.filters.items(): - model = self.sourceModel() - idx = model.index(row_num, key, parent) - if idx.isValid(): - name = model.row(row_num).name - r_name = re.search(regex, name) - if r_name is None: - r_name = '' - context = model.row(row_num).context - r_context = re.search(regex, context) - if r_context is None: - r_context = '' - results.append(r_name) - results.append(r_context) - return any(results) - - -def load_shortcuts_data(): - """ - Load shortcuts from CONF for testing. - """ - shortcut_data = [] - for context, name, __ in CONF.iter_shortcuts(): - context = context.lower() - name = name.lower() - shortcut_data.append((None, context, name, None, None)) - return shortcut_data - - -def load_shortcuts(shortcut_table): - """ - Load shortcuts into `shortcut_table`. - """ - shortcut_data = load_shortcuts_data() - shortcut_table.set_shortcut_data(shortcut_data) - shortcut_table.load_shortcuts() - return shortcut_table - - -def test(): - from spyder.utils.qthelpers import qapplication - - app = qapplication() - table = ShortcutsTable() - table = load_shortcuts(table) - table.show() - app.exec_() - - table.check_shortcuts() - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shortcut management widgets.""" + +# Standard library importsimport re +import re + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QAbstractTableModel, QEvent, QModelIndex, + QSortFilterProxyModel, Qt, Slot) +from qtpy.QtGui import QIcon, QKeySequence +from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog, + QGridLayout, QHBoxLayout, QKeySequenceEdit, + QLabel, QLineEdit, QMessageBox, QPushButton, + QSpacerItem, QTableView, QVBoxLayout) + +# Local imports +from spyder.api.translations import get_translation +from spyder.config.manager import CONF +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import create_toolbutton +from spyder.utils.stringmatching import get_search_regex, get_search_scores +from spyder.widgets.helperwidgets import (VALID_FINDER_CHARS, + CustomSortFilterProxy, + FinderLineEdit, HelperToolButton, + HTMLDelegate) + +# Localization +_ = get_translation('spyder') + + +# Valid shortcut keys +SINGLE_KEYS = ["F{}".format(_i) for _i in range(1, 36)] + ["Del", "Esc"] +EDITOR_SINGLE_KEYS = SINGLE_KEYS + ["Home", "End", "Ins", "Enter", + "Return", "Backspace", "Tab", + "PageUp", "PageDown", "Clear", "Pause", + "Left", "Up", "Right", "Down"] + +# Key sequences blacklist for the shortcut editor dialog +BLACKLIST = {} + +# Error codes for the shortcut editor dialog +NO_WARNING = 0 +SEQUENCE_EMPTY = 1 +SEQUENCE_CONFLICT = 2 +INVALID_KEY = 3 +IN_BLACKLIST = 4 + + +class ShortcutTranslator(QKeySequenceEdit): + """ + A QKeySequenceEdit that is not meant to be shown and is used only + to convert QKeyEvent into QKeySequence. To our knowledge, this is + the only way to do this within the Qt framework, because the code that does + this in Qt is protected. Porting the code to Python would be nearly + impossible because it relies on low level and OS-dependent Qt libraries + that are not public for the most part. + """ + + def __init__(self): + super(ShortcutTranslator, self).__init__() + self.hide() + + def keyevent_to_keyseq(self, event): + """Return a QKeySequence representation of the provided QKeyEvent.""" + self.keyPressEvent(event) + event.accept() + return self.keySequence() + + def keyReleaseEvent(self, event): + """Qt Override""" + return False + + def timerEvent(self, event): + """Qt Override""" + return False + + def event(self, event): + """Qt Override""" + return False + + +class ShortcutLineEdit(QLineEdit): + """QLineEdit that filters its key press and release events.""" + + def __init__(self, parent): + super(ShortcutLineEdit, self).__init__(parent) + self.setReadOnly(True) + + tw = self.fontMetrics().width( + "Ctrl+Shift+Alt+Backspace, Ctrl+Shift+Alt+Backspace") + fw = self.style().pixelMetric(self.style().PM_DefaultFrameWidth) + self.setMinimumWidth(tw + (2 * fw) + 4) + # We need to add 4 to take into account the horizontalMargin of the + # line edit, whose value is hardcoded in qt. + + def keyPressEvent(self, e): + """Qt Override""" + self.parent().keyPressEvent(e) + + def keyReleaseEvent(self, e): + """Qt Override""" + self.parent().keyReleaseEvent(e) + + def setText(self, sequence): + """Qt method extension.""" + self.setToolTip(sequence) + super(ShortcutLineEdit, self).setText(sequence) + + +class ShortcutFinder(FinderLineEdit): + """Textbox for filtering listed shortcuts in the table.""" + + def keyPressEvent(self, event): + """Qt and FilterLineEdit Override.""" + key = event.key() + if key in [Qt.Key_Up]: + self._parent.previous_row() + elif key in [Qt.Key_Down]: + self._parent.next_row() + elif key in [Qt.Key_Enter, Qt.Key_Return]: + self._parent.show_editor() + else: + super(ShortcutFinder, self).keyPressEvent(event) + + +class ShortcutEditor(QDialog): + """A dialog for entering key sequences.""" + + def __init__(self, parent, context, name, sequence, shortcuts): + super(ShortcutEditor, self).__init__(parent) + self._parent = parent + self.setWindowFlags(self.windowFlags() & + ~Qt.WindowContextHelpButtonHint) + + self.context = context + self.name = name + self.shortcuts = shortcuts + self.current_sequence = sequence or _('') + self._qsequences = list() + + self.setup() + self.update_warning() + + @property + def new_sequence(self): + """Return a string representation of the new key sequence.""" + return ', '.join(self._qsequences) + + @property + def new_qsequence(self): + """Return the QKeySequence object of the new key sequence.""" + return QKeySequence(self.new_sequence) + + def setup(self): + """Setup the ShortcutEditor with the provided arguments.""" + # Widgets + icon_info = HelperToolButton() + icon_info.setIcon(ima.get_std_icon('MessageBoxInformation')) + layout_icon_info = QVBoxLayout() + layout_icon_info.setContentsMargins(0, 0, 0, 0) + layout_icon_info.setSpacing(0) + layout_icon_info.addWidget(icon_info) + layout_icon_info.addStretch(100) + + self.label_info = QLabel() + self.label_info.setText( + _("Press the new shortcut and select 'Ok' to confirm, " + "click 'Cancel' to revert to the previous state, " + "or use 'Clear' to unbind the command from a shortcut.")) + self.label_info.setAlignment(Qt.AlignTop | Qt.AlignLeft) + self.label_info.setWordWrap(True) + layout_info = QHBoxLayout() + layout_info.setContentsMargins(0, 0, 0, 0) + layout_info.addLayout(layout_icon_info) + layout_info.addWidget(self.label_info) + layout_info.setStretch(1, 100) + + self.label_current_sequence = QLabel(_("Current shortcut:")) + self.text_current_sequence = QLabel(self.current_sequence) + + self.label_new_sequence = QLabel(_("New shortcut:")) + self.text_new_sequence = ShortcutLineEdit(self) + self.text_new_sequence.setPlaceholderText(_("Press shortcut.")) + + self.helper_button = HelperToolButton() + self.helper_button.setIcon(QIcon()) + self.label_warning = QLabel() + self.label_warning.setWordWrap(True) + self.label_warning.setAlignment(Qt.AlignTop | Qt.AlignLeft) + + self.button_default = QPushButton(_('Default')) + self.button_ok = QPushButton(_('Ok')) + self.button_ok.setEnabled(False) + self.button_clear = QPushButton(_('Clear')) + self.button_cancel = QPushButton(_('Cancel')) + button_box = QHBoxLayout() + button_box.addWidget(self.button_default) + button_box.addStretch(100) + button_box.addWidget(self.button_ok) + button_box.addWidget(self.button_clear) + button_box.addWidget(self.button_cancel) + + # New Sequence button box + self.btn_clear_sequence = create_toolbutton( + self, icon=ima.icon('editclear'), + tip=_("Clear all entered key sequences"), + triggered=self.clear_new_sequence) + self.button_back_sequence = create_toolbutton( + self, icon=ima.icon('previous'), + tip=_("Remove last key sequence entered"), + triggered=self.back_new_sequence) + + newseq_btnbar = QHBoxLayout() + newseq_btnbar.setSpacing(0) + newseq_btnbar.setContentsMargins(0, 0, 0, 0) + newseq_btnbar.addWidget(self.button_back_sequence) + newseq_btnbar.addWidget(self.btn_clear_sequence) + + # Setup widgets + self.setWindowTitle(_('Shortcut: {0}').format(self.name)) + self.helper_button.setToolTip('') + style = """ + QToolButton { + margin:1px; + border: 0px solid grey; + padding:0px; + border-radius: 0px; + }""" + self.helper_button.setStyleSheet(style) + icon_info.setToolTip('') + icon_info.setStyleSheet(style) + + # Layout + layout_sequence = QGridLayout() + layout_sequence.setContentsMargins(0, 0, 0, 0) + layout_sequence.addLayout(layout_info, 0, 0, 1, 4) + layout_sequence.addItem(QSpacerItem(15, 15), 1, 0, 1, 4) + layout_sequence.addWidget(self.label_current_sequence, 2, 0) + layout_sequence.addWidget(self.text_current_sequence, 2, 2) + layout_sequence.addWidget(self.label_new_sequence, 3, 0) + layout_sequence.addWidget(self.helper_button, 3, 1) + layout_sequence.addWidget(self.text_new_sequence, 3, 2) + layout_sequence.addLayout(newseq_btnbar, 3, 3) + layout_sequence.addWidget(self.label_warning, 4, 2, 1, 2) + layout_sequence.setColumnStretch(2, 100) + layout_sequence.setRowStretch(4, 100) + + layout = QVBoxLayout(self) + layout.addLayout(layout_sequence) + layout.addSpacing(10) + layout.addLayout(button_box) + layout.setSizeConstraint(layout.SetFixedSize) + + # Signals + self.button_ok.clicked.connect(self.accept_override) + self.button_clear.clicked.connect(self.unbind_shortcut) + self.button_cancel.clicked.connect(self.reject) + self.button_default.clicked.connect(self.set_sequence_to_default) + + # Set all widget to no focus so that we can register key + # press event. + widgets = ( + self.label_warning, self.helper_button, self.text_new_sequence, + self.button_clear, self.button_default, self.button_cancel, + self.button_ok, self.btn_clear_sequence, self.button_back_sequence) + for w in widgets: + w.setFocusPolicy(Qt.NoFocus) + w.clearFocus() + + @Slot() + def reject(self): + """Slot for rejected signal.""" + # Added for spyder-ide/spyder#5426. Due to the focusPolicy of + # Qt.NoFocus for the buttons, if the cancel button was clicked without + # first setting focus to the button, it would cause a seg fault crash. + self.button_cancel.setFocus() + super(ShortcutEditor, self).reject() + + @Slot() + def accept(self): + """Slot for accepted signal.""" + # Added for spyder-ide/spyder#5426. Due to the focusPolicy of + # Qt.NoFocus for the buttons, if the cancel button was clicked without + # first setting focus to the button, it would cause a seg fault crash. + self.button_ok.setFocus() + super(ShortcutEditor, self).accept() + + def event(self, event): + """Qt method override.""" + # We reroute all ShortcutOverride events to our keyPressEvent and block + # any KeyPress and Shortcut event. This allows to register default + # Qt shortcuts for which no key press event are emitted. + # See spyder-ide/spyder/issues/10786. + if event.type() == QEvent.ShortcutOverride: + self.keyPressEvent(event) + return True + elif event.type() in [QEvent.KeyPress, QEvent.Shortcut]: + return True + else: + return super(ShortcutEditor, self).event(event) + + def keyPressEvent(self, event): + """Qt method override.""" + event_key = event.key() + if not event_key or event_key == Qt.Key_unknown: + return + if len(self._qsequences) == 4: + # QKeySequence accepts a maximum of 4 different sequences. + return + if event_key in [Qt.Key_Control, Qt.Key_Shift, + Qt.Key_Alt, Qt.Key_Meta]: + # The event corresponds to just and only a special key. + return + + translator = ShortcutTranslator() + event_keyseq = translator.keyevent_to_keyseq(event) + event_keystr = event_keyseq.toString(QKeySequence.PortableText) + self._qsequences.append(event_keystr) + self.update_warning() + + def check_conflicts(self): + """Check shortcuts for conflicts.""" + conflicts = [] + if len(self._qsequences) == 0: + return conflicts + + new_qsequence = self.new_qsequence + for shortcut in self.shortcuts: + shortcut_qsequence = QKeySequence.fromString(str(shortcut.key)) + if shortcut_qsequence.isEmpty(): + continue + if (shortcut.context, shortcut.name) == (self.context, self.name): + continue + if shortcut.context in [self.context, '_'] or self.context == '_': + if (shortcut_qsequence.matches(new_qsequence) or + new_qsequence.matches(shortcut_qsequence)): + conflicts.append(shortcut) + return conflicts + + def check_ascii(self): + """ + Check that all characters in the new sequence are ascii or else the + shortcut will not work. + """ + try: + self.new_sequence.encode('ascii') + except UnicodeEncodeError: + return False + else: + return True + + def check_singlekey(self): + """Check if the first sub-sequence of the new key sequence is valid.""" + if len(self._qsequences) == 0: + return True + else: + keystr = self._qsequences[0] + valid_single_keys = (EDITOR_SINGLE_KEYS if + self.context == 'editor' else SINGLE_KEYS) + if any((m in keystr for m in ('Ctrl', 'Alt', 'Shift', 'Meta'))): + return True + else: + # This means that the the first subsequence is composed of + # a single key with no modifier. + valid_single_keys = (EDITOR_SINGLE_KEYS if + self.context == 'editor' else SINGLE_KEYS) + if any((k == keystr for k in valid_single_keys)): + return True + else: + return False + + def update_warning(self): + """Update the warning label, buttons state and sequence text.""" + new_qsequence = self.new_qsequence + new_sequence = self.new_sequence + self.text_new_sequence.setText( + new_qsequence.toString(QKeySequence.NativeText)) + + conflicts = self.check_conflicts() + if len(self._qsequences) == 0: + warning = SEQUENCE_EMPTY + tip = '' + icon = QIcon() + elif conflicts: + warning = SEQUENCE_CONFLICT + template = '

    {0}

    {1}{2}' + tip_title = _('This key sequence conflicts with:') + tip_body = '' + for s in conflicts: + tip_body += ' ' * 2 + tip_body += ' - {0}: {1}
    '.format(s.context, s.name) + tip_body += '
    ' + if len(conflicts) == 1: + tip_override = _("Press 'Ok' to unbind it and assign it to") + else: + tip_override = _("Press 'Ok' to unbind them and assign it to") + tip_override += ' {}.'.format(self.name) + tip = template.format(tip_title, tip_body, tip_override) + icon = ima.get_std_icon('MessageBoxWarning') + elif new_sequence in BLACKLIST: + warning = IN_BLACKLIST + tip = _('This key sequence is forbidden.') + icon = ima.get_std_icon('MessageBoxWarning') + elif self.check_singlekey() is False or self.check_ascii() is False: + warning = INVALID_KEY + tip = _('This key sequence is invalid.') + icon = ima.get_std_icon('MessageBoxWarning') + else: + warning = NO_WARNING + tip = _('This key sequence is valid.') + icon = ima.get_std_icon('DialogApplyButton') + + self.warning = warning + self.conflicts = conflicts + + self.helper_button.setIcon(icon) + self.button_ok.setEnabled( + self.warning in [NO_WARNING, SEQUENCE_CONFLICT]) + self.label_warning.setText(tip) + + def set_sequence_from_str(self, sequence): + """ + This is a convenience method to set the new QKeySequence of the + shortcut editor from a string. + """ + self._qsequences = [QKeySequence(s) for s in sequence.split(', ')] + self.update_warning() + + def set_sequence_to_default(self): + """Set the new sequence to the default value defined in the config.""" + sequence = CONF.get_default( + 'shortcuts', "{}/{}".format(self.context, self.name)) + if sequence: + self._qsequences = sequence.split(', ') + self.update_warning() + else: + self.unbind_shortcut() + + def back_new_sequence(self): + """Remove the last subsequence from the sequence compound.""" + self._qsequences = self._qsequences[:-1] + self.update_warning() + + def clear_new_sequence(self): + """Clear the new sequence.""" + self._qsequences = [] + self.update_warning() + + def unbind_shortcut(self): + """Unbind the shortcut.""" + self._qsequences = [] + self.accept() + + def accept_override(self): + """Unbind all conflicted shortcuts, and accept the new one""" + conflicts = self.check_conflicts() + if conflicts: + for shortcut in conflicts: + shortcut.key = '' + self.accept() + + +class Shortcut(object): + """Shortcut convenience class for holding shortcut context, name, + original ordering index, key sequence for the shortcut and localized text. + """ + + def __init__(self, context, name, key=None): + self.index = 0 # Sorted index. Populated when loading shortcuts + self.context = context + self.name = name + self.key = key + + def __str__(self): + return "{0}/{1}: {2}".format(self.context, self.name, self.key) + + def load(self): + self.key = CONF.get_shortcut(self.context, self.name) + + def save(self): + CONF.set_shortcut(self.context, self.name, self.key) + + +CONTEXT, NAME, SEQUENCE, SEARCH_SCORE = [0, 1, 2, 3] + + +class ShortcutsModel(QAbstractTableModel): + def __init__(self, parent, text_color=None, text_color_highlight=None): + QAbstractTableModel.__init__(self) + self._parent = parent + + self.shortcuts = [] + self.scores = [] + self.rich_text = [] + self.normal_text = [] + self.context_rich_text = [] + self.letters = '' + self.label = QLabel() + self.widths = [] + + # Needed to compensate for the HTMLDelegate color selection unawarness + palette = parent.palette() + if text_color is None: + self.text_color = palette.text().color().name() + else: + self.text_color = text_color + + if text_color_highlight is None: + self.text_color_highlight = \ + palette.highlightedText().color().name() + else: + self.text_color_highlight = text_color_highlight + + def current_index(self): + """Get the currently selected index in the parent table view.""" + i = self._parent.proxy_model.mapToSource(self._parent.currentIndex()) + return i + + def sortByName(self): + """Qt Override.""" + self.shortcuts = sorted(self.shortcuts, + key=lambda x: x.context+'/'+x.name) + self.reset() + + def flags(self, index): + """Qt Override.""" + if not index.isValid(): + return Qt.ItemIsEnabled + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index))) + + def data(self, index, role=Qt.DisplayRole): + """Qt Override.""" + row = index.row() + if not index.isValid() or not (0 <= row < len(self.shortcuts)): + return to_qvariant() + + shortcut = self.shortcuts[row] + key = shortcut.key + column = index.column() + + if role == Qt.DisplayRole: + color = self.text_color + if self._parent == QApplication.focusWidget(): + if self.current_index().row() == row: + color = self.text_color_highlight + else: + color = self.text_color + if column == CONTEXT: + if len(self.context_rich_text) > 0: + text = self.context_rich_text[row] + else: + text = shortcut.context + text = '

    {1}

    '.format(color, text) + return to_qvariant(text) + elif column == NAME: + text = self.rich_text[row] + text = '

    {1}

    '.format(color, text) + return to_qvariant(text) + elif column == SEQUENCE: + text = QKeySequence(key).toString(QKeySequence.NativeText) + return to_qvariant(text) + elif column == SEARCH_SCORE: + # Treating search scores as a table column simplifies the + # sorting once a score for a specific string in the finder + # has been defined. This column however should always remain + # hidden. + return to_qvariant(self.scores[row]) + elif role == Qt.TextAlignmentRole: + return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) + return to_qvariant() + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """Qt Override.""" + if role == Qt.TextAlignmentRole: + if orientation == Qt.Horizontal: + return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) + return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) + if role != Qt.DisplayRole: + return to_qvariant() + if orientation == Qt.Horizontal: + if section == CONTEXT: + return to_qvariant(_("Context")) + elif section == NAME: + return to_qvariant(_("Name")) + elif section == SEQUENCE: + return to_qvariant(_("Shortcut")) + elif section == SEARCH_SCORE: + return to_qvariant(_("Score")) + return to_qvariant() + + def rowCount(self, index=QModelIndex()): + """Qt Override.""" + return len(self.shortcuts) + + def columnCount(self, index=QModelIndex()): + """Qt Override.""" + return 4 + + def setData(self, index, value, role=Qt.EditRole): + """Qt Override.""" + if index.isValid() and 0 <= index.row() < len(self.shortcuts): + shortcut = self.shortcuts[index.row()] + column = index.column() + text = from_qvariant(value, str) + if column == SEQUENCE: + shortcut.key = text + self.dataChanged.emit(index, index) + return True + return False + + def update_search_letters(self, text): + """Update search letters with text input in search box.""" + self.letters = text + contexts = [shortcut.context for shortcut in self.shortcuts] + names = [shortcut.name for shortcut in self.shortcuts] + context_results = get_search_scores( + text, contexts, template='{0}') + results = get_search_scores(text, names, template='{0}') + __, self.context_rich_text, context_scores = ( + zip(*context_results)) + self.normal_text, self.rich_text, self.scores = zip(*results) + self.scores = [x + y for x, y in zip(self.scores, context_scores)] + self.reset() + + def update_active_row(self): + """Update active row to update color in selected text.""" + self.data(self.current_index()) + + def row(self, row_num): + """Get row based on model index. Needed for the custom proxy model.""" + return self.shortcuts[row_num] + + def reset(self): + """"Reset model to take into account new search letters.""" + self.beginResetModel() + self.endResetModel() + + +class ShortcutsTable(QTableView): + def __init__(self, + parent=None, text_color=None, text_color_highlight=None): + QTableView.__init__(self, parent) + self._parent = parent + self.finder = None + self.shortcut_data = None + self.source_model = ShortcutsModel( + self, + text_color=text_color, + text_color_highlight=text_color_highlight) + self.proxy_model = ShortcutsSortFilterProxy(self) + self.last_regex = '' + + self.proxy_model.setSourceModel(self.source_model) + self.proxy_model.setDynamicSortFilter(True) + self.proxy_model.setFilterByColumn(CONTEXT) + self.proxy_model.setFilterByColumn(NAME) + self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) + self.setModel(self.proxy_model) + + self.hideColumn(SEARCH_SCORE) + self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9)) + self.setItemDelegateForColumn(CONTEXT, HTMLDelegate(self, margin=9)) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + self.setSelectionMode(QAbstractItemView.SingleSelection) + self.setSortingEnabled(True) + self.setEditTriggers(QAbstractItemView.AllEditTriggers) + self.selectionModel().selectionChanged.connect(self.selection) + + self.verticalHeader().hide() + + def set_shortcut_data(self, shortcut_data): + """ + Shortcut data comes from the registration of actions on the main + window. This allows to only display the right actions on the + shortcut table. This also allows to display the localize text. + """ + self.shortcut_data = shortcut_data + + def focusOutEvent(self, e): + """Qt Override.""" + self.source_model.update_active_row() + super(ShortcutsTable, self).focusOutEvent(e) + + def focusInEvent(self, e): + """Qt Override.""" + super(ShortcutsTable, self).focusInEvent(e) + self.selectRow(self.currentIndex().row()) + + def selection(self, index): + """Update selected row.""" + self.update() + self.isActiveWindow() + + def adjust_cells(self): + """Adjust column size based on contents.""" + self.resizeColumnsToContents() + fm = self.horizontalHeader().fontMetrics() + names = [fm.width(s.name + ' '*9) for s in self.source_model.shortcuts] + if len(names) == 0: + # This condition only applies during testing + names = [0] + self.setColumnWidth(NAME, max(names)) + self.horizontalHeader().setStretchLastSection(True) + + def load_shortcuts(self): + """Load shortcuts and assign to table model.""" + # item[1] -> context, item[2] -> name + # Data might be capitalized so we user lower() + # See: spyder-ide/spyder/#12415 + shortcut_data = set([(item[1].lower(), item[2].lower()) for item + in self.shortcut_data]) + shortcut_data = list(sorted(set(shortcut_data))) + shortcuts = [] + + for context, name, keystr in CONF.iter_shortcuts(): + if (context, name) in shortcut_data: + context = context.lower() + name = name.lower() + # Only add to table actions that are registered from the main + # window + shortcut = Shortcut(context, name, keystr) + shortcuts.append(shortcut) + + shortcuts = sorted(shortcuts, key=lambda item: item.context+item.name) + + # Store the original order of shortcuts + for i, shortcut in enumerate(shortcuts): + shortcut.index = i + + self.source_model.shortcuts = shortcuts + self.source_model.scores = [0]*len(shortcuts) + self.source_model.rich_text = [s.name for s in shortcuts] + self.source_model.reset() + self.adjust_cells() + self.sortByColumn(CONTEXT, Qt.AscendingOrder) + + def check_shortcuts(self): + """Check shortcuts for conflicts.""" + conflicts = [] + for index, sh1 in enumerate(self.source_model.shortcuts): + if index == len(self.source_model.shortcuts)-1: + break + if str(sh1.key) == '': + continue + for sh2 in self.source_model.shortcuts[index+1:]: + if sh2 is sh1: + continue + if str(sh2.key) == str(sh1.key) \ + and (sh1.context == sh2.context or sh1.context == '_' or + sh2.context == '_'): + conflicts.append((sh1, sh2)) + if conflicts: + self.parent().show_this_page.emit() + cstr = "\n".join(['%s <---> %s' % (sh1, sh2) + for sh1, sh2 in conflicts]) + QMessageBox.warning(self, _("Conflicts"), + _("The following conflicts have been " + "detected:")+"\n"+cstr, QMessageBox.Ok) + + def save_shortcuts(self): + """Save shortcuts from table model.""" + self.check_shortcuts() + for shortcut in self.source_model.shortcuts: + shortcut.save() + + def show_editor(self): + """Create, setup and display the shortcut editor dialog.""" + index = self.proxy_model.mapToSource(self.currentIndex()) + row, column = index.row(), index.column() + shortcuts = self.source_model.shortcuts + context = shortcuts[row].context + name = shortcuts[row].name + + sequence_index = self.source_model.index(row, SEQUENCE) + sequence = sequence_index.data() + + dialog = ShortcutEditor(self, context, name, sequence, shortcuts) + + if dialog.exec_(): + new_sequence = dialog.new_sequence + self.source_model.setData(sequence_index, new_sequence) + + def set_regex(self, regex=None, reset=False): + """Update the regex text for the shortcut finder.""" + if reset: + text = '' + else: + text = self.finder.text().replace(' ', '').lower() + + self.proxy_model.set_filter(text) + self.source_model.update_search_letters(text) + self.sortByColumn(SEARCH_SCORE, Qt.AscendingOrder) + + if self.last_regex != regex: + self.selectRow(0) + self.last_regex = regex + + def next_row(self): + """Move to next row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row + 1 == rows: + row = -1 + self.selectRow(row + 1) + + def previous_row(self): + """Move to previous row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row == 0: + row = rows + self.selectRow(row - 1) + + def keyPressEvent(self, event): + """Qt Override.""" + key = event.key() + if key in [Qt.Key_Enter, Qt.Key_Return]: + self.show_editor() + elif key in [Qt.Key_Tab]: + self.finder.setFocus() + elif key in [Qt.Key_Backtab]: + self.parent().reset_btn.setFocus() + elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: + super(ShortcutsTable, self).keyPressEvent(event) + elif key not in [Qt.Key_Escape, Qt.Key_Space]: + text = event.text() + if text: + if re.search(VALID_FINDER_CHARS, text) is not None: + self.finder.setFocus() + self.finder.set_text(text) + elif key in [Qt.Key_Escape]: + self.finder.keyPressEvent(event) + + def mouseDoubleClickEvent(self, event): + """Qt Override.""" + self.show_editor() + self.update() + + +class ShortcutsSortFilterProxy(QSortFilterProxyModel): + """Custom proxy for supporting shortcuts multifiltering.""" + + def __init__(self, parent=None): + """Initialize the multiple sort filter proxy.""" + super(ShortcutsSortFilterProxy, self).__init__(parent) + self._parent = parent + self.pattern = re.compile(r'') + self.filters = {} + + def setFilterByColumn(self, column): + """Set regular expression in the given column.""" + self.filters[column] = self.pattern + self.invalidateFilter() + + def set_filter(self, text): + """Set regular expression for filter.""" + for key, __ in self.filters.items(): + self.pattern = get_search_regex(text) + if self.pattern and text: + self._parent.setSortingEnabled(False) + else: + self._parent.setSortingEnabled(True) + self.filters[key] = self.pattern + self.invalidateFilter() + + def clearFilter(self, column): + """Clear the filter of the given column.""" + self.filters.pop(column) + self.invalidateFilter() + + def clearFilters(self): + """Clear all the filters.""" + self.filters = {} + self.invalidateFilter() + + def filterAcceptsRow(self, row_num, parent): + """Qt override. + + Reimplemented to allow filtering in multiple columns. + """ + results = [] + for key, regex in self.filters.items(): + model = self.sourceModel() + idx = model.index(row_num, key, parent) + if idx.isValid(): + name = model.row(row_num).name + r_name = re.search(regex, name) + if r_name is None: + r_name = '' + context = model.row(row_num).context + r_context = re.search(regex, context) + if r_context is None: + r_context = '' + results.append(r_name) + results.append(r_context) + return any(results) + + +def load_shortcuts_data(): + """ + Load shortcuts from CONF for testing. + """ + shortcut_data = [] + for context, name, __ in CONF.iter_shortcuts(): + context = context.lower() + name = name.lower() + shortcut_data.append((None, context, name, None, None)) + return shortcut_data + + +def load_shortcuts(shortcut_table): + """ + Load shortcuts into `shortcut_table`. + """ + shortcut_data = load_shortcuts_data() + shortcut_table.set_shortcut_data(shortcut_data) + shortcut_table.load_shortcuts() + return shortcut_table + + +def test(): + from spyder.utils.qthelpers import qapplication + + app = qapplication() + table = ShortcutsTable() + table = load_shortcuts(table) + table.show() + app.exec_() + + table.check_shortcuts() + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/statusbar/container.py b/spyder/plugins/statusbar/container.py index af712e6862a..7196adcdc9e 100644 --- a/spyder/plugins/statusbar/container.py +++ b/spyder/plugins/statusbar/container.py @@ -1,65 +1,65 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Status bar container. -""" - -# Third-party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.plugins.statusbar.widgets.status import ( - ClockStatus, CPUStatus, MemoryStatus -) - - -class StatusBarContainer(PluginMainContainer): - - sig_show_status_bar_requested = Signal(bool) - """ - This signal is emmitted when the user wants to show/hide the - status bar. - """ - - def setup(self): - # Basic status widgets - self.mem_status = MemoryStatus(parent=self) - self.cpu_status = CPUStatus(parent=self) - self.clock_status = ClockStatus(parent=self) - - @on_conf_change(option='memory_usage/enable') - def enable_mem_status(self, value): - self.mem_status.setVisible(value) - - @on_conf_change(option='memory_usage/timeout') - def set_mem_interval(self, value): - self.mem_status.set_interval(value) - - @on_conf_change(option='cpu_usage/enable') - def enable_cpu_status(self, value): - self.cpu_status.setVisible(value) - - @on_conf_change(option='cpu_usage/timeout') - def set_cpu_interval(self, value): - self.cpu_status.set_interval(value) - - @on_conf_change(option='clock/enable') - def enable_clock_status(self, value): - self.clock_status.setVisible(value) - - @on_conf_change(option='clock/timeout') - def set_clock_interval(self, value): - self.clock_status.set_interval(value) - - @on_conf_change(option='show_status_bar') - def show_status_bar(self, value): - self.sig_show_status_bar_requested.emit(value) - - def update_actions(self): - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Status bar container. +""" + +# Third-party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.statusbar.widgets.status import ( + ClockStatus, CPUStatus, MemoryStatus +) + + +class StatusBarContainer(PluginMainContainer): + + sig_show_status_bar_requested = Signal(bool) + """ + This signal is emmitted when the user wants to show/hide the + status bar. + """ + + def setup(self): + # Basic status widgets + self.mem_status = MemoryStatus(parent=self) + self.cpu_status = CPUStatus(parent=self) + self.clock_status = ClockStatus(parent=self) + + @on_conf_change(option='memory_usage/enable') + def enable_mem_status(self, value): + self.mem_status.setVisible(value) + + @on_conf_change(option='memory_usage/timeout') + def set_mem_interval(self, value): + self.mem_status.set_interval(value) + + @on_conf_change(option='cpu_usage/enable') + def enable_cpu_status(self, value): + self.cpu_status.setVisible(value) + + @on_conf_change(option='cpu_usage/timeout') + def set_cpu_interval(self, value): + self.cpu_status.set_interval(value) + + @on_conf_change(option='clock/enable') + def enable_clock_status(self, value): + self.clock_status.setVisible(value) + + @on_conf_change(option='clock/timeout') + def set_clock_interval(self, value): + self.clock_status.set_interval(value) + + @on_conf_change(option='show_status_bar') + def show_status_bar(self, value): + self.sig_show_status_bar_requested.emit(value) + + def update_actions(self): + pass diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py index 0cb3097c4e1..b35d350727b 100644 --- a/spyder/plugins/statusbar/plugin.py +++ b/spyder/plugins/statusbar/plugin.py @@ -1,247 +1,247 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Status bar plugin. -""" - -# Third-party imports -from qtpy.QtCore import Slot - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.api.widgets.status import StatusBarWidget -from spyder.config.base import running_under_pytest -from spyder.plugins.statusbar.confpage import StatusBarConfigPage -from spyder.plugins.statusbar.container import StatusBarContainer - - -# Localization -_ = get_translation('spyder') - - -class StatusBarWidgetPosition: - Left = 0 - Right = -1 - - -class StatusBar(SpyderPluginV2): - """Status bar plugin.""" - - NAME = 'statusbar' - REQUIRES = [Plugins.Preferences] - CONTAINER_CLASS = StatusBarContainer - CONF_SECTION = NAME - CONF_FILE = False - CONF_WIDGET_CLASS = StatusBarConfigPage - - STATUS_WIDGETS = {} - EXTERNAL_RIGHT_WIDGETS = {} - EXTERNAL_LEFT_WIDGETS = {} - INTERNAL_WIDGETS = {} - INTERNAL_WIDGETS_IDS = { - 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', - 'eol_status', 'encoding_status', 'cursor_position_status', - 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'} - - # ---- SpyderPluginV2 API - @staticmethod - def get_name(): - return _('Status bar') - - def get_icon(self): - return self.create_icon('statusbar') - - def get_description(self): - return _('Provide Core user interface management') - - def on_initialize(self): - # --- Status widgets - self.add_status_widget(self.mem_status, StatusBarWidgetPosition.Right) - self.add_status_widget(self.cpu_status, StatusBarWidgetPosition.Right) - self.add_status_widget( - self.clock_status, StatusBarWidgetPosition.Right) - - def on_close(self, _unused): - self._statusbar.setVisible(False) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - def after_container_creation(self): - container = self.get_container() - container.sig_show_status_bar_requested.connect( - self.show_status_bar - ) - - # ---- Public API - def add_status_widget(self, widget, position=StatusBarWidgetPosition.Left): - """ - Add status widget to main application status bar. - - Parameters - ---------- - widget: StatusBarWidget - Widget to be added to the status bar. - position: int - Position where the widget will be added given the members of the - StatusBarWidgetPosition enum. - """ - # Check widget class - if not isinstance(widget, StatusBarWidget): - raise SpyderAPIError( - 'Any status widget must subclass StatusBarWidget!' - ) - - # Check ID - id_ = widget.ID - if id_ is None: - raise SpyderAPIError( - f"Status widget `{repr(widget)}` doesn't have an identifier!" - ) - - # Check it was not added before - if id_ in self.STATUS_WIDGETS and not running_under_pytest(): - raise SpyderAPIError(f'Status widget `{id_}` already added!') - - if id_ in self.INTERNAL_WIDGETS_IDS: - self.INTERNAL_WIDGETS[id_] = widget - elif position == StatusBarWidgetPosition.Right: - self.EXTERNAL_RIGHT_WIDGETS[id_] = widget - else: - self.EXTERNAL_LEFT_WIDGETS[id_] = widget - - self.STATUS_WIDGETS[id_] = widget - self._statusbar.setStyleSheet('QStatusBar::item {border: None;}') - - if position == StatusBarWidgetPosition.Right: - self._statusbar.addPermanentWidget(widget) - else: - self._statusbar.insertPermanentWidget( - StatusBarWidgetPosition.Left, widget) - self._statusbar.layout().setContentsMargins(0, 0, 0, 0) - self._statusbar.layout().setSpacing(0) - - def remove_status_widget(self, id_): - """ - Remove widget from status bar. - - Parameters - ---------- - id_: str - String identifier for the widget. - """ - try: - widget = self.get_status_widget(id_) - self.STATUS_WIDGETS.pop(id_) - self._statusbar.removeWidget(widget) - except RuntimeError: - # This can happen if the widget was already removed (tests fail - # without this). - pass - - def get_status_widget(self, id_): - """ - Return an application status widget by name. - - Parameters - ---------- - id_: str - String identifier for the widget. - """ - if id_ in self.STATUS_WIDGETS: - return self.STATUS_WIDGETS[id_] - else: - raise SpyderAPIError(f'Status widget "{id_}" not found!') - - def get_status_widgets(self): - """Return all status widgets.""" - return list(self.STATUS_WIDGETS.keys()) - - def remove_status_widgets(self): - """Remove all status widgets.""" - for w in self.get_status_widgets(): - self.remove_status_widget(w) - - @Slot(bool) - def show_status_bar(self, value): - """ - Show/hide status bar. - - Parameters - ---------- - value: bool - Decide whether to show or hide the status bar. - """ - self._statusbar.setVisible(value) - - # ---- Default status widgets - @property - def mem_status(self): - return self.get_container().mem_status - - @property - def cpu_status(self): - return self.get_container().cpu_status - - @property - def clock_status(self): - return self.get_container().clock_status - - # ---- Private API - @property - def _statusbar(self): - """Reference to main window status bar.""" - return self._main.statusBar() - - def _organize_status_widgets(self): - """ - Organize the status bar widgets once the application is loaded. - """ - # Desired organization - internal_layout = [ - 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', - 'eol_status', 'encoding_status', 'cursor_position_status', - 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'] - external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) - - # Remove all widgets from the statusbar, except the external right - for id_ in self.INTERNAL_WIDGETS: - self._statusbar.removeWidget(self.INTERNAL_WIDGETS[id_]) - - for id_ in self.EXTERNAL_LEFT_WIDGETS: - self._statusbar.removeWidget(self.EXTERNAL_LEFT_WIDGETS[id_]) - - # Add the internal widgets in the desired layout - for id_ in internal_layout: - # This is needed in the case kite is installed but not enabled - if id_ in self.INTERNAL_WIDGETS: - self._statusbar.insertPermanentWidget( - StatusBarWidgetPosition.Left, self.INTERNAL_WIDGETS[id_]) - self.INTERNAL_WIDGETS[id_].setVisible(True) - - # Add the external left widgets - for id_ in external_left: - self._statusbar.insertPermanentWidget( - StatusBarWidgetPosition.Left, self.EXTERNAL_LEFT_WIDGETS[id_]) - self.EXTERNAL_LEFT_WIDGETS[id_].setVisible(True) - - def before_mainwindow_visible(self): - """Perform actions before the mainwindow is visible""" - # Organize widgets in the expected order - self._statusbar.setVisible(False) - self._organize_status_widgets() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Status bar plugin. +""" + +# Third-party imports +from qtpy.QtCore import Slot + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.api.widgets.status import StatusBarWidget +from spyder.config.base import running_under_pytest +from spyder.plugins.statusbar.confpage import StatusBarConfigPage +from spyder.plugins.statusbar.container import StatusBarContainer + + +# Localization +_ = get_translation('spyder') + + +class StatusBarWidgetPosition: + Left = 0 + Right = -1 + + +class StatusBar(SpyderPluginV2): + """Status bar plugin.""" + + NAME = 'statusbar' + REQUIRES = [Plugins.Preferences] + CONTAINER_CLASS = StatusBarContainer + CONF_SECTION = NAME + CONF_FILE = False + CONF_WIDGET_CLASS = StatusBarConfigPage + + STATUS_WIDGETS = {} + EXTERNAL_RIGHT_WIDGETS = {} + EXTERNAL_LEFT_WIDGETS = {} + INTERNAL_WIDGETS = {} + INTERNAL_WIDGETS_IDS = { + 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', + 'eol_status', 'encoding_status', 'cursor_position_status', + 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'} + + # ---- SpyderPluginV2 API + @staticmethod + def get_name(): + return _('Status bar') + + def get_icon(self): + return self.create_icon('statusbar') + + def get_description(self): + return _('Provide Core user interface management') + + def on_initialize(self): + # --- Status widgets + self.add_status_widget(self.mem_status, StatusBarWidgetPosition.Right) + self.add_status_widget(self.cpu_status, StatusBarWidgetPosition.Right) + self.add_status_widget( + self.clock_status, StatusBarWidgetPosition.Right) + + def on_close(self, _unused): + self._statusbar.setVisible(False) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + def after_container_creation(self): + container = self.get_container() + container.sig_show_status_bar_requested.connect( + self.show_status_bar + ) + + # ---- Public API + def add_status_widget(self, widget, position=StatusBarWidgetPosition.Left): + """ + Add status widget to main application status bar. + + Parameters + ---------- + widget: StatusBarWidget + Widget to be added to the status bar. + position: int + Position where the widget will be added given the members of the + StatusBarWidgetPosition enum. + """ + # Check widget class + if not isinstance(widget, StatusBarWidget): + raise SpyderAPIError( + 'Any status widget must subclass StatusBarWidget!' + ) + + # Check ID + id_ = widget.ID + if id_ is None: + raise SpyderAPIError( + f"Status widget `{repr(widget)}` doesn't have an identifier!" + ) + + # Check it was not added before + if id_ in self.STATUS_WIDGETS and not running_under_pytest(): + raise SpyderAPIError(f'Status widget `{id_}` already added!') + + if id_ in self.INTERNAL_WIDGETS_IDS: + self.INTERNAL_WIDGETS[id_] = widget + elif position == StatusBarWidgetPosition.Right: + self.EXTERNAL_RIGHT_WIDGETS[id_] = widget + else: + self.EXTERNAL_LEFT_WIDGETS[id_] = widget + + self.STATUS_WIDGETS[id_] = widget + self._statusbar.setStyleSheet('QStatusBar::item {border: None;}') + + if position == StatusBarWidgetPosition.Right: + self._statusbar.addPermanentWidget(widget) + else: + self._statusbar.insertPermanentWidget( + StatusBarWidgetPosition.Left, widget) + self._statusbar.layout().setContentsMargins(0, 0, 0, 0) + self._statusbar.layout().setSpacing(0) + + def remove_status_widget(self, id_): + """ + Remove widget from status bar. + + Parameters + ---------- + id_: str + String identifier for the widget. + """ + try: + widget = self.get_status_widget(id_) + self.STATUS_WIDGETS.pop(id_) + self._statusbar.removeWidget(widget) + except RuntimeError: + # This can happen if the widget was already removed (tests fail + # without this). + pass + + def get_status_widget(self, id_): + """ + Return an application status widget by name. + + Parameters + ---------- + id_: str + String identifier for the widget. + """ + if id_ in self.STATUS_WIDGETS: + return self.STATUS_WIDGETS[id_] + else: + raise SpyderAPIError(f'Status widget "{id_}" not found!') + + def get_status_widgets(self): + """Return all status widgets.""" + return list(self.STATUS_WIDGETS.keys()) + + def remove_status_widgets(self): + """Remove all status widgets.""" + for w in self.get_status_widgets(): + self.remove_status_widget(w) + + @Slot(bool) + def show_status_bar(self, value): + """ + Show/hide status bar. + + Parameters + ---------- + value: bool + Decide whether to show or hide the status bar. + """ + self._statusbar.setVisible(value) + + # ---- Default status widgets + @property + def mem_status(self): + return self.get_container().mem_status + + @property + def cpu_status(self): + return self.get_container().cpu_status + + @property + def clock_status(self): + return self.get_container().clock_status + + # ---- Private API + @property + def _statusbar(self): + """Reference to main window status bar.""" + return self._main.statusBar() + + def _organize_status_widgets(self): + """ + Organize the status bar widgets once the application is loaded. + """ + # Desired organization + internal_layout = [ + 'clock_status', 'cpu_status', 'memory_status', 'read_write_status', + 'eol_status', 'encoding_status', 'cursor_position_status', + 'vcs_status', 'lsp_status', 'kite_status', 'completion_status'] + external_left = list(self.EXTERNAL_LEFT_WIDGETS.keys()) + + # Remove all widgets from the statusbar, except the external right + for id_ in self.INTERNAL_WIDGETS: + self._statusbar.removeWidget(self.INTERNAL_WIDGETS[id_]) + + for id_ in self.EXTERNAL_LEFT_WIDGETS: + self._statusbar.removeWidget(self.EXTERNAL_LEFT_WIDGETS[id_]) + + # Add the internal widgets in the desired layout + for id_ in internal_layout: + # This is needed in the case kite is installed but not enabled + if id_ in self.INTERNAL_WIDGETS: + self._statusbar.insertPermanentWidget( + StatusBarWidgetPosition.Left, self.INTERNAL_WIDGETS[id_]) + self.INTERNAL_WIDGETS[id_].setVisible(True) + + # Add the external left widgets + for id_ in external_left: + self._statusbar.insertPermanentWidget( + StatusBarWidgetPosition.Left, self.EXTERNAL_LEFT_WIDGETS[id_]) + self.EXTERNAL_LEFT_WIDGETS[id_].setVisible(True) + + def before_mainwindow_visible(self): + """Perform actions before the mainwindow is visible""" + # Organize widgets in the expected order + self._statusbar.setVisible(False) + self._organize_status_widgets() diff --git a/spyder/plugins/toolbar/container.py b/spyder/plugins/toolbar/container.py index 0dd73cf0c28..c94a7e9a41b 100644 --- a/spyder/plugins/toolbar/container.py +++ b/spyder/plugins/toolbar/container.py @@ -1,396 +1,396 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Toolbar Container. -""" - -# Standard library imports -from collections import OrderedDict -from spyder.utils.qthelpers import SpyderAction -from typing import Optional, Union, Tuple, Dict, List - -# Third party imports -from qtpy.QtCore import QSize, Slot -from qtpy.QtWidgets import QAction, QWidget -from qtpy import PYSIDE2 - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.api.utils import get_class_values -from spyder.api.widgets.toolbars import ApplicationToolbar -from spyder.plugins.toolbar.api import ApplicationToolbars -from spyder.utils.registries import TOOLBAR_REGISTRY - - -# Localization -_ = get_translation('spyder') - -# Type annotations -ToolbarItem = Union[SpyderAction, QWidget] -ItemInfo = Tuple[ToolbarItem, Optional[str], Optional[str], Optional[str]] - - -class ToolbarMenus: - ToolbarsMenu = "toolbars_menu" - - -class ToolbarsMenuSections: - Main = "main_section" - Secondary = "secondary_section" - - -class ToolbarActions: - ShowToolbars = "show toolbars" - - -class QActionID(QAction): - """Wrapper class around QAction that allows to set/get an identifier.""" - @property - def action_id(self): - return self._action_id - - @action_id.setter - def action_id(self, act): - self._action_id = act - - -class ToolbarContainer(PluginMainContainer): - def __init__(self, name, plugin, parent=None): - super().__init__(name, plugin, parent=parent) - - self._APPLICATION_TOOLBARS = OrderedDict() - self._ADDED_TOOLBARS = OrderedDict() - self._toolbarslist = [] - self._visible_toolbars = [] - self._ITEMS_QUEUE = {} # type: Dict[str, List[ItemInfo]] - - # ---- Private Methods - # ------------------------------------------------------------------------ - def _save_visible_toolbars(self): - """Save the name of the visible toolbars in the options.""" - toolbars = [] - for toolbar in self._visible_toolbars: - toolbars.append(toolbar.objectName()) - - self.set_conf('last_visible_toolbars', toolbars) - - def _get_visible_toolbars(self): - """Collect the visible toolbars.""" - toolbars = [] - for toolbar in self._toolbarslist: - if (toolbar.toggleViewAction().isChecked() - and toolbar not in toolbars): - toolbars.append(toolbar) - - self._visible_toolbars = toolbars - - @Slot() - def _show_toolbars(self): - """Show/Hide toolbars.""" - value = not self.get_conf("toolbars_visible") - self.set_conf("toolbars_visible", value) - if value: - self._save_visible_toolbars() - else: - self._get_visible_toolbars() - - for toolbar in self._visible_toolbars: - toolbar.setVisible(value) - - self.update_actions() - - def _add_missing_toolbar_elements(self, toolbar, toolbar_id): - if toolbar_id in self._ITEMS_QUEUE: - pending_items = self._ITEMS_QUEUE.pop(toolbar_id) - for item, section, before, before_section in pending_items: - toolbar.add_item(item, section=section, before=before, - before_section=before_section) - - # ---- PluginMainContainer API - # ------------------------------------------------------------------------ - def setup(self): - self.show_toolbars_action = self.create_action( - ToolbarActions.ShowToolbars, - text=_("Show toolbars"), - triggered=self._show_toolbars - ) - - self.toolbars_menu = self.create_menu( - ToolbarMenus.ToolbarsMenu, - _("Toolbars"), - ) - self.toolbars_menu.setObjectName('checkbox-padding') - - def update_actions(self): - visible_toolbars = self.get_conf("toolbars_visible") - if visible_toolbars: - text = _("Hide toolbars") - tip = _("Hide toolbars") - else: - text = _("Show toolbars") - tip = _("Show toolbars") - - self.show_toolbars_action.setText(text) - self.show_toolbars_action.setToolTip(tip) - self.toolbars_menu.setEnabled(visible_toolbars) - - # ---- Public API - # ------------------------------------------------------------------------ - def create_application_toolbar( - self, toolbar_id: str, title: str) -> ApplicationToolbar: - """ - Create an application toolbar and add it to the main window. - - Parameters - ---------- - toolbar_id: str - The toolbar unique identifier string. - title: str - The localized toolbar title to be displayed. - - Returns - ------- - spyder.api.widgets.toolbar.ApplicationToolbar - The created application toolbar. - """ - if toolbar_id in self._APPLICATION_TOOLBARS: - raise SpyderAPIError( - 'Toolbar with ID "{}" already added!'.format(toolbar_id)) - - toolbar = ApplicationToolbar(self, title) - toolbar.ID = toolbar_id - toolbar.setObjectName(toolbar_id) - - TOOLBAR_REGISTRY.register_reference( - toolbar, toolbar_id, self.PLUGIN_NAME, self.CONTEXT_NAME) - self._APPLICATION_TOOLBARS[toolbar_id] = toolbar - - self._add_missing_toolbar_elements(toolbar, toolbar_id) - return toolbar - - def add_application_toolbar(self, toolbar, mainwindow=None): - """ - Add toolbar to application toolbars. - - Parameters - ---------- - toolbar: spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar to add to the `mainwindow`. - mainwindow: QMainWindow - The main application window. - """ - # Check toolbar class - if not isinstance(toolbar, ApplicationToolbar): - raise SpyderAPIError( - 'Any toolbar must subclass ApplicationToolbar!' - ) - - # Check ID - toolbar_id = toolbar.ID - if toolbar_id is None: - raise SpyderAPIError( - f"Toolbar `{repr(toolbar)}` doesn't have an identifier!" - ) - - if toolbar_id in self._ADDED_TOOLBARS: - raise SpyderAPIError( - 'Toolbar with ID "{}" already added!'.format(toolbar_id)) - - # TODO: Make the icon size adjustable in Preferences later on. - iconsize = 24 - toolbar.setIconSize(QSize(iconsize, iconsize)) - toolbar.setObjectName(toolbar_id) - - self._ADDED_TOOLBARS[toolbar_id] = toolbar - self._toolbarslist.append(toolbar) - - if mainwindow: - mainwindow.addToolBar(toolbar) - - self._add_missing_toolbar_elements(toolbar, toolbar_id) - - def remove_application_toolbar(self, toolbar_id: str, mainwindow=None): - """ - Remove toolbar from application toolbars. - - Parameters - ---------- - toolbar: str - The application toolbar to remove from the `mainwindow`. - mainwindow: QMainWindow - The main application window. - """ - - if toolbar_id not in self._ADDED_TOOLBARS: - raise SpyderAPIError( - 'Toolbar with ID "{}" is not in the main window'.format( - toolbar_id)) - - toolbar = self._ADDED_TOOLBARS.pop(toolbar_id) - self._toolbarslist.remove(toolbar) - - if mainwindow: - mainwindow.removeToolBar(toolbar) - - def add_item_to_application_toolbar(self, - item: ToolbarItem, - toolbar_id: Optional[str] = None, - section: Optional[str] = None, - before: Optional[str] = None, - before_section: Optional[str] = None, - omit_id: bool = False): - """ - Add action or widget `item` to given application toolbar `section`. - - Parameters - ---------- - item: SpyderAction or QWidget - The item to add to the `toolbar`. - toolbar_id: str or None - The application toolbar unique string identifier. - section: str or None - The section id in which to insert the `item` on the `toolbar`. - before: str or None - Make the item appear before another given item. - before_section: str or None - Make the item defined section appear before another given section - (the section must be already defined). - omit_id: bool - If True, then the toolbar will check if the item to add declares an - id, False otherwise. This flag exists only for items added on - Spyder 4 plugins. Default: False - """ - if toolbar_id not in self._APPLICATION_TOOLBARS: - pending_items = self._ITEMS_QUEUE.get(toolbar_id, []) - pending_items.append((item, section, before, before_section)) - self._ITEMS_QUEUE[toolbar_id] = pending_items - else: - toolbar = self.get_application_toolbar(toolbar_id) - toolbar.add_item(item, section=section, before=before, - before_section=before_section, omit_id=omit_id) - - def remove_item_from_application_toolbar(self, item_id: str, - toolbar_id: Optional[str] = None): - """ - Remove action or widget from given application toolbar by id. - - Parameters - ---------- - item: str - The item to remove from the `toolbar`. - toolbar_id: str or None - The application toolbar unique string identifier. - """ - if toolbar_id not in self._APPLICATION_TOOLBARS: - raise SpyderAPIError( - '{} is not a valid toolbar_id'.format(toolbar_id)) - - toolbar = self.get_application_toolbar(toolbar_id) - toolbar.remove_item(item_id) - - def get_application_toolbar(self, toolbar_id: str) -> ApplicationToolbar: - """ - Return an application toolbar by toolbar_id. - - Parameters - ---------- - toolbar_id: str - The toolbar unique string identifier. - - Returns - ------- - spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar. - """ - if toolbar_id not in self._APPLICATION_TOOLBARS: - raise SpyderAPIError( - 'Application toolbar "{0}" not found! ' - 'Available toolbars are: {1}'.format( - toolbar_id, - list(self._APPLICATION_TOOLBARS.keys()) - ) - ) - - return self._APPLICATION_TOOLBARS[toolbar_id] - - def get_application_toolbars(self): - """ - Return all created application toolbars. - - Returns - ------- - list - The list of all the added application toolbars. - """ - return self._toolbarslist - - def save_last_visible_toolbars(self): - """Save the last visible toolbars state in our preferences.""" - if self.get_conf("toolbars_visible"): - self._get_visible_toolbars() - self._save_visible_toolbars() - - def load_last_visible_toolbars(self): - """Load the last visible toolbars from our preferences.""" - toolbars_names = self.get_conf('last_visible_toolbars') - toolbars_visible = self.get_conf("toolbars_visible") - - if toolbars_names: - toolbars_dict = {} - for toolbar in self._toolbarslist: - toolbars_dict[toolbar.objectName()] = toolbar - - toolbars = [] - for name in toolbars_names: - if name in toolbars_dict: - toolbars.append(toolbars_dict[name]) - - self._visible_toolbars = toolbars - else: - self._get_visible_toolbars() - - for toolbar in self._visible_toolbars: - toolbar.setVisible(toolbars_visible) - - self.update_actions() - - def create_toolbars_menu(self): - """ - Populate the toolbars menu inside the view application menu. - """ - main_section = ToolbarsMenuSections.Main - secondary_section = ToolbarsMenuSections.Secondary - default_toolbars = get_class_values(ApplicationToolbars) - - for toolbar_id, toolbar in self._ADDED_TOOLBARS.items(): - if toolbar: - action = toolbar.toggleViewAction() - if not PYSIDE2: - # Modifying __class__ of a QObject created by C++ [1] seems - # to invalidate the corresponding Python object when PySide - # is used (changing __class__ of a QObject created in - # Python seems to work). - # - # [1] There are Qt functions such as - # QToolBar.toggleViewAction(), QToolBar.addAction(QString) - # and QMainWindow.addToolbar(QString), which return a - # pointer to an already existing QObject. - action.__class__ = QActionID - action.action_id = f'toolbar_{toolbar_id}' - section = ( - main_section - if toolbar_id in default_toolbars - else secondary_section - ) - - self.add_item_to_menu( - action, - menu=self.toolbars_menu, - section=section, - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Toolbar Container. +""" + +# Standard library imports +from collections import OrderedDict +from spyder.utils.qthelpers import SpyderAction +from typing import Optional, Union, Tuple, Dict, List + +# Third party imports +from qtpy.QtCore import QSize, Slot +from qtpy.QtWidgets import QAction, QWidget +from qtpy import PYSIDE2 + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.api.utils import get_class_values +from spyder.api.widgets.toolbars import ApplicationToolbar +from spyder.plugins.toolbar.api import ApplicationToolbars +from spyder.utils.registries import TOOLBAR_REGISTRY + + +# Localization +_ = get_translation('spyder') + +# Type annotations +ToolbarItem = Union[SpyderAction, QWidget] +ItemInfo = Tuple[ToolbarItem, Optional[str], Optional[str], Optional[str]] + + +class ToolbarMenus: + ToolbarsMenu = "toolbars_menu" + + +class ToolbarsMenuSections: + Main = "main_section" + Secondary = "secondary_section" + + +class ToolbarActions: + ShowToolbars = "show toolbars" + + +class QActionID(QAction): + """Wrapper class around QAction that allows to set/get an identifier.""" + @property + def action_id(self): + return self._action_id + + @action_id.setter + def action_id(self, act): + self._action_id = act + + +class ToolbarContainer(PluginMainContainer): + def __init__(self, name, plugin, parent=None): + super().__init__(name, plugin, parent=parent) + + self._APPLICATION_TOOLBARS = OrderedDict() + self._ADDED_TOOLBARS = OrderedDict() + self._toolbarslist = [] + self._visible_toolbars = [] + self._ITEMS_QUEUE = {} # type: Dict[str, List[ItemInfo]] + + # ---- Private Methods + # ------------------------------------------------------------------------ + def _save_visible_toolbars(self): + """Save the name of the visible toolbars in the options.""" + toolbars = [] + for toolbar in self._visible_toolbars: + toolbars.append(toolbar.objectName()) + + self.set_conf('last_visible_toolbars', toolbars) + + def _get_visible_toolbars(self): + """Collect the visible toolbars.""" + toolbars = [] + for toolbar in self._toolbarslist: + if (toolbar.toggleViewAction().isChecked() + and toolbar not in toolbars): + toolbars.append(toolbar) + + self._visible_toolbars = toolbars + + @Slot() + def _show_toolbars(self): + """Show/Hide toolbars.""" + value = not self.get_conf("toolbars_visible") + self.set_conf("toolbars_visible", value) + if value: + self._save_visible_toolbars() + else: + self._get_visible_toolbars() + + for toolbar in self._visible_toolbars: + toolbar.setVisible(value) + + self.update_actions() + + def _add_missing_toolbar_elements(self, toolbar, toolbar_id): + if toolbar_id in self._ITEMS_QUEUE: + pending_items = self._ITEMS_QUEUE.pop(toolbar_id) + for item, section, before, before_section in pending_items: + toolbar.add_item(item, section=section, before=before, + before_section=before_section) + + # ---- PluginMainContainer API + # ------------------------------------------------------------------------ + def setup(self): + self.show_toolbars_action = self.create_action( + ToolbarActions.ShowToolbars, + text=_("Show toolbars"), + triggered=self._show_toolbars + ) + + self.toolbars_menu = self.create_menu( + ToolbarMenus.ToolbarsMenu, + _("Toolbars"), + ) + self.toolbars_menu.setObjectName('checkbox-padding') + + def update_actions(self): + visible_toolbars = self.get_conf("toolbars_visible") + if visible_toolbars: + text = _("Hide toolbars") + tip = _("Hide toolbars") + else: + text = _("Show toolbars") + tip = _("Show toolbars") + + self.show_toolbars_action.setText(text) + self.show_toolbars_action.setToolTip(tip) + self.toolbars_menu.setEnabled(visible_toolbars) + + # ---- Public API + # ------------------------------------------------------------------------ + def create_application_toolbar( + self, toolbar_id: str, title: str) -> ApplicationToolbar: + """ + Create an application toolbar and add it to the main window. + + Parameters + ---------- + toolbar_id: str + The toolbar unique identifier string. + title: str + The localized toolbar title to be displayed. + + Returns + ------- + spyder.api.widgets.toolbar.ApplicationToolbar + The created application toolbar. + """ + if toolbar_id in self._APPLICATION_TOOLBARS: + raise SpyderAPIError( + 'Toolbar with ID "{}" already added!'.format(toolbar_id)) + + toolbar = ApplicationToolbar(self, title) + toolbar.ID = toolbar_id + toolbar.setObjectName(toolbar_id) + + TOOLBAR_REGISTRY.register_reference( + toolbar, toolbar_id, self.PLUGIN_NAME, self.CONTEXT_NAME) + self._APPLICATION_TOOLBARS[toolbar_id] = toolbar + + self._add_missing_toolbar_elements(toolbar, toolbar_id) + return toolbar + + def add_application_toolbar(self, toolbar, mainwindow=None): + """ + Add toolbar to application toolbars. + + Parameters + ---------- + toolbar: spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar to add to the `mainwindow`. + mainwindow: QMainWindow + The main application window. + """ + # Check toolbar class + if not isinstance(toolbar, ApplicationToolbar): + raise SpyderAPIError( + 'Any toolbar must subclass ApplicationToolbar!' + ) + + # Check ID + toolbar_id = toolbar.ID + if toolbar_id is None: + raise SpyderAPIError( + f"Toolbar `{repr(toolbar)}` doesn't have an identifier!" + ) + + if toolbar_id in self._ADDED_TOOLBARS: + raise SpyderAPIError( + 'Toolbar with ID "{}" already added!'.format(toolbar_id)) + + # TODO: Make the icon size adjustable in Preferences later on. + iconsize = 24 + toolbar.setIconSize(QSize(iconsize, iconsize)) + toolbar.setObjectName(toolbar_id) + + self._ADDED_TOOLBARS[toolbar_id] = toolbar + self._toolbarslist.append(toolbar) + + if mainwindow: + mainwindow.addToolBar(toolbar) + + self._add_missing_toolbar_elements(toolbar, toolbar_id) + + def remove_application_toolbar(self, toolbar_id: str, mainwindow=None): + """ + Remove toolbar from application toolbars. + + Parameters + ---------- + toolbar: str + The application toolbar to remove from the `mainwindow`. + mainwindow: QMainWindow + The main application window. + """ + + if toolbar_id not in self._ADDED_TOOLBARS: + raise SpyderAPIError( + 'Toolbar with ID "{}" is not in the main window'.format( + toolbar_id)) + + toolbar = self._ADDED_TOOLBARS.pop(toolbar_id) + self._toolbarslist.remove(toolbar) + + if mainwindow: + mainwindow.removeToolBar(toolbar) + + def add_item_to_application_toolbar(self, + item: ToolbarItem, + toolbar_id: Optional[str] = None, + section: Optional[str] = None, + before: Optional[str] = None, + before_section: Optional[str] = None, + omit_id: bool = False): + """ + Add action or widget `item` to given application toolbar `section`. + + Parameters + ---------- + item: SpyderAction or QWidget + The item to add to the `toolbar`. + toolbar_id: str or None + The application toolbar unique string identifier. + section: str or None + The section id in which to insert the `item` on the `toolbar`. + before: str or None + Make the item appear before another given item. + before_section: str or None + Make the item defined section appear before another given section + (the section must be already defined). + omit_id: bool + If True, then the toolbar will check if the item to add declares an + id, False otherwise. This flag exists only for items added on + Spyder 4 plugins. Default: False + """ + if toolbar_id not in self._APPLICATION_TOOLBARS: + pending_items = self._ITEMS_QUEUE.get(toolbar_id, []) + pending_items.append((item, section, before, before_section)) + self._ITEMS_QUEUE[toolbar_id] = pending_items + else: + toolbar = self.get_application_toolbar(toolbar_id) + toolbar.add_item(item, section=section, before=before, + before_section=before_section, omit_id=omit_id) + + def remove_item_from_application_toolbar(self, item_id: str, + toolbar_id: Optional[str] = None): + """ + Remove action or widget from given application toolbar by id. + + Parameters + ---------- + item: str + The item to remove from the `toolbar`. + toolbar_id: str or None + The application toolbar unique string identifier. + """ + if toolbar_id not in self._APPLICATION_TOOLBARS: + raise SpyderAPIError( + '{} is not a valid toolbar_id'.format(toolbar_id)) + + toolbar = self.get_application_toolbar(toolbar_id) + toolbar.remove_item(item_id) + + def get_application_toolbar(self, toolbar_id: str) -> ApplicationToolbar: + """ + Return an application toolbar by toolbar_id. + + Parameters + ---------- + toolbar_id: str + The toolbar unique string identifier. + + Returns + ------- + spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar. + """ + if toolbar_id not in self._APPLICATION_TOOLBARS: + raise SpyderAPIError( + 'Application toolbar "{0}" not found! ' + 'Available toolbars are: {1}'.format( + toolbar_id, + list(self._APPLICATION_TOOLBARS.keys()) + ) + ) + + return self._APPLICATION_TOOLBARS[toolbar_id] + + def get_application_toolbars(self): + """ + Return all created application toolbars. + + Returns + ------- + list + The list of all the added application toolbars. + """ + return self._toolbarslist + + def save_last_visible_toolbars(self): + """Save the last visible toolbars state in our preferences.""" + if self.get_conf("toolbars_visible"): + self._get_visible_toolbars() + self._save_visible_toolbars() + + def load_last_visible_toolbars(self): + """Load the last visible toolbars from our preferences.""" + toolbars_names = self.get_conf('last_visible_toolbars') + toolbars_visible = self.get_conf("toolbars_visible") + + if toolbars_names: + toolbars_dict = {} + for toolbar in self._toolbarslist: + toolbars_dict[toolbar.objectName()] = toolbar + + toolbars = [] + for name in toolbars_names: + if name in toolbars_dict: + toolbars.append(toolbars_dict[name]) + + self._visible_toolbars = toolbars + else: + self._get_visible_toolbars() + + for toolbar in self._visible_toolbars: + toolbar.setVisible(toolbars_visible) + + self.update_actions() + + def create_toolbars_menu(self): + """ + Populate the toolbars menu inside the view application menu. + """ + main_section = ToolbarsMenuSections.Main + secondary_section = ToolbarsMenuSections.Secondary + default_toolbars = get_class_values(ApplicationToolbars) + + for toolbar_id, toolbar in self._ADDED_TOOLBARS.items(): + if toolbar: + action = toolbar.toggleViewAction() + if not PYSIDE2: + # Modifying __class__ of a QObject created by C++ [1] seems + # to invalidate the corresponding Python object when PySide + # is used (changing __class__ of a QObject created in + # Python seems to work). + # + # [1] There are Qt functions such as + # QToolBar.toggleViewAction(), QToolBar.addAction(QString) + # and QMainWindow.addToolbar(QString), which return a + # pointer to an already existing QObject. + action.__class__ = QActionID + action.action_id = f'toolbar_{toolbar_id}' + section = ( + main_section + if toolbar_id in default_toolbars + else secondary_section + ) + + self.add_item_to_menu( + action, + menu=self.toolbars_menu, + section=section, + ) diff --git a/spyder/plugins/toolbar/plugin.py b/spyder/plugins/toolbar/plugin.py index 94eae2d41aa..b804e244c55 100644 --- a/spyder/plugins/toolbar/plugin.py +++ b/spyder/plugins/toolbar/plugin.py @@ -1,266 +1,266 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Toolbar Plugin. -""" - -# Standard library imports -from spyder.utils.qthelpers import SpyderAction -from typing import Union, Optional - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.plugins import SpyderPluginV2, Plugins -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections -from spyder.plugins.toolbar.api import ApplicationToolbars -from spyder.plugins.toolbar.container import ( - ToolbarContainer, ToolbarMenus, ToolbarActions) - -# Third-party imports -from qtpy.QtWidgets import QWidget - -# Localization -_ = get_translation('spyder') - - -class Toolbar(SpyderPluginV2): - """ - Docstrings viewer widget. - """ - NAME = 'toolbar' - OPTIONAL = [Plugins.MainMenu] - CONF_SECTION = NAME - CONF_FILE = False - CONTAINER_CLASS = ToolbarContainer - CAN_BE_DISABLED = False - - # --- SpyderDocakblePlugin API - # ----------------------------------------------------------------------- - @staticmethod - def get_name(): - return _('Toolbar') - - def get_description(self): - return _('Application toolbars management.') - - def get_icon(self): - return self.create_icon('help') - - def on_initialize(self): - create_app_toolbar = self.create_application_toolbar - create_app_toolbar(ApplicationToolbars.File, _("File toolbar")) - create_app_toolbar(ApplicationToolbars.Run, _("Run toolbar")) - create_app_toolbar(ApplicationToolbars.Debug, _("Debug toolbar")) - create_app_toolbar(ApplicationToolbars.Main, _("Main toolbar")) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - # View menu Toolbar section - mainmenu.add_item_to_application_menu( - self.toolbars_menu, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Toolbar, - before_section=ViewMenuSections.Layout) - mainmenu.add_item_to_application_menu( - self.show_toolbars_action, - menu_id=ApplicationMenus.View, - section=ViewMenuSections.Toolbar, - before_section=ViewMenuSections.Layout) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - # View menu Toolbar section - mainmenu.remove_item_from_application_menu( - ToolbarMenus.ToolbarsMenu, - menu_id=ApplicationMenus.View) - mainmenu.remove_item_from_application_menu( - ToolbarActions.ShowToolbars, - menu_id=ApplicationMenus.View) - - def on_mainwindow_visible(self): - container = self.get_container() - - # TODO: Until all core plugins are migrated, this is needed. - ACTION_MAP = { - ApplicationToolbars.File: self._main.file_toolbar_actions, - ApplicationToolbars.Debug: self._main.debug_toolbar_actions, - ApplicationToolbars.Run: self._main.run_toolbar_actions, - } - for toolbar in container.get_application_toolbars(): - toolbar_id = toolbar.ID - if toolbar_id in ACTION_MAP: - section = 0 - for item in ACTION_MAP[toolbar_id]: - if item is None: - section += 1 - continue - - self.add_item_to_application_toolbar( - item, - toolbar_id=toolbar_id, - section=str(section), - omit_id=True - ) - - toolbar._render() - - container.create_toolbars_menu() - container.load_last_visible_toolbars() - - def on_close(self, _unused): - container = self.get_container() - container.save_last_visible_toolbars() - for toolbar in container._visible_toolbars: - toolbar.setVisible(False) - - # --- Public API - # ------------------------------------------------------------------------ - def create_application_toolbar(self, toolbar_id, title): - """ - Create a Spyder application toolbar. - - Parameters - ---------- - toolbar_id: str - The toolbar unique identifier string. - title: str - The localized toolbar title to be displayed. - - Returns - ------- - spyder.api.widgets.toolbar.ApplicationToolbar - The created application toolbar. - """ - toolbar = self.get_container().create_application_toolbar( - toolbar_id, title) - self.add_application_toolbar(toolbar) - return toolbar - - def add_application_toolbar(self, toolbar): - """ - Add toolbar to application toolbars. - - This can be used to add a custom toolbar. The `WorkingDirectory` - plugin is an example of this. - - Parameters - ---------- - toolbar: spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar to add to the main window. - """ - self.get_container().add_application_toolbar(toolbar, self._main) - - def remove_application_toolbar(self, toolbar_id: str): - """ - Remove toolbar from the application toolbars. - - This can be used to remove a custom toolbar. The `WorkingDirectory` - plugin is an example of this. - - Parameters - ---------- - toolbar: str - The application toolbar to remove from the main window. - """ - self.get_container().remove_application_toolbar(toolbar_id, self._main) - - def add_item_to_application_toolbar(self, - item: Union[SpyderAction, QWidget], - toolbar_id: Optional[str] = None, - section: Optional[str] = None, - before: Optional[str] = None, - before_section: Optional[str] = None, - omit_id: bool = False): - """ - Add action or widget `item` to given application menu `section`. - - Parameters - ---------- - item: SpyderAction or QWidget - The item to add to the `toolbar`. - toolbar_id: str or None - The application toolbar unique string identifier. - section: str or None - The section id in which to insert the `item` on the `toolbar`. - before: str or None - Make the item appear before another given item. - before_section: str or None - Make the item defined section appear before another given section - (must be already defined). - omit_id: bool - If True, then the toolbar will check if the item to add declares an - id, False otherwise. This flag exists only for items added on - Spyder 4 plugins. Default: False - """ - if before is not None: - if not isinstance(before, str): - raise ValueError('before argument must be a str') - - return self.get_container().add_item_to_application_toolbar( - item, - toolbar_id=toolbar_id, - section=section, - before=before, - before_section=before_section, - omit_id=omit_id - ) - - def remove_item_from_application_toolbar(self, item_id: str, - toolbar_id: Optional[str] = None): - """ - Remove action or widget `item` from given application menu by id. - - Parameters - ---------- - item_id: str - The item to remove from the toolbar. - toolbar_id: str or None - The application toolbar unique string identifier. - """ - self.get_container().remove_item_from_application_toolbar( - item_id, - toolbar_id=toolbar_id - ) - - def get_application_toolbar(self, toolbar_id): - """ - Return an application toolbar by toolbar_id. - - Parameters - ---------- - toolbar_id: str - The toolbar unique string identifier. - - Returns - ------- - spyder.api.widgets.toolbars.ApplicationToolbar - The application toolbar. - """ - return self.get_container().get_application_toolbar(toolbar_id) - - def toggle_lock(self, value=None): - """Lock/Unlock toolbars.""" - for toolbar in self.toolbarslist: - toolbar.setMovable(not value) - - # --- Convenience properties, while all plugins migrate. - @property - def toolbars_menu(self): - return self.get_container().get_menu("toolbars_menu") - - @property - def show_toolbars_action(self): - return self.get_action("show toolbars") - - @property - def toolbarslist(self): - return self.get_container()._toolbarslist +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Toolbar Plugin. +""" + +# Standard library imports +from spyder.utils.qthelpers import SpyderAction +from typing import Union, Optional + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.plugins import SpyderPluginV2, Plugins +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.plugins.mainmenu.api import ApplicationMenus, ViewMenuSections +from spyder.plugins.toolbar.api import ApplicationToolbars +from spyder.plugins.toolbar.container import ( + ToolbarContainer, ToolbarMenus, ToolbarActions) + +# Third-party imports +from qtpy.QtWidgets import QWidget + +# Localization +_ = get_translation('spyder') + + +class Toolbar(SpyderPluginV2): + """ + Docstrings viewer widget. + """ + NAME = 'toolbar' + OPTIONAL = [Plugins.MainMenu] + CONF_SECTION = NAME + CONF_FILE = False + CONTAINER_CLASS = ToolbarContainer + CAN_BE_DISABLED = False + + # --- SpyderDocakblePlugin API + # ----------------------------------------------------------------------- + @staticmethod + def get_name(): + return _('Toolbar') + + def get_description(self): + return _('Application toolbars management.') + + def get_icon(self): + return self.create_icon('help') + + def on_initialize(self): + create_app_toolbar = self.create_application_toolbar + create_app_toolbar(ApplicationToolbars.File, _("File toolbar")) + create_app_toolbar(ApplicationToolbars.Run, _("Run toolbar")) + create_app_toolbar(ApplicationToolbars.Debug, _("Debug toolbar")) + create_app_toolbar(ApplicationToolbars.Main, _("Main toolbar")) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + # View menu Toolbar section + mainmenu.add_item_to_application_menu( + self.toolbars_menu, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Toolbar, + before_section=ViewMenuSections.Layout) + mainmenu.add_item_to_application_menu( + self.show_toolbars_action, + menu_id=ApplicationMenus.View, + section=ViewMenuSections.Toolbar, + before_section=ViewMenuSections.Layout) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + # View menu Toolbar section + mainmenu.remove_item_from_application_menu( + ToolbarMenus.ToolbarsMenu, + menu_id=ApplicationMenus.View) + mainmenu.remove_item_from_application_menu( + ToolbarActions.ShowToolbars, + menu_id=ApplicationMenus.View) + + def on_mainwindow_visible(self): + container = self.get_container() + + # TODO: Until all core plugins are migrated, this is needed. + ACTION_MAP = { + ApplicationToolbars.File: self._main.file_toolbar_actions, + ApplicationToolbars.Debug: self._main.debug_toolbar_actions, + ApplicationToolbars.Run: self._main.run_toolbar_actions, + } + for toolbar in container.get_application_toolbars(): + toolbar_id = toolbar.ID + if toolbar_id in ACTION_MAP: + section = 0 + for item in ACTION_MAP[toolbar_id]: + if item is None: + section += 1 + continue + + self.add_item_to_application_toolbar( + item, + toolbar_id=toolbar_id, + section=str(section), + omit_id=True + ) + + toolbar._render() + + container.create_toolbars_menu() + container.load_last_visible_toolbars() + + def on_close(self, _unused): + container = self.get_container() + container.save_last_visible_toolbars() + for toolbar in container._visible_toolbars: + toolbar.setVisible(False) + + # --- Public API + # ------------------------------------------------------------------------ + def create_application_toolbar(self, toolbar_id, title): + """ + Create a Spyder application toolbar. + + Parameters + ---------- + toolbar_id: str + The toolbar unique identifier string. + title: str + The localized toolbar title to be displayed. + + Returns + ------- + spyder.api.widgets.toolbar.ApplicationToolbar + The created application toolbar. + """ + toolbar = self.get_container().create_application_toolbar( + toolbar_id, title) + self.add_application_toolbar(toolbar) + return toolbar + + def add_application_toolbar(self, toolbar): + """ + Add toolbar to application toolbars. + + This can be used to add a custom toolbar. The `WorkingDirectory` + plugin is an example of this. + + Parameters + ---------- + toolbar: spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar to add to the main window. + """ + self.get_container().add_application_toolbar(toolbar, self._main) + + def remove_application_toolbar(self, toolbar_id: str): + """ + Remove toolbar from the application toolbars. + + This can be used to remove a custom toolbar. The `WorkingDirectory` + plugin is an example of this. + + Parameters + ---------- + toolbar: str + The application toolbar to remove from the main window. + """ + self.get_container().remove_application_toolbar(toolbar_id, self._main) + + def add_item_to_application_toolbar(self, + item: Union[SpyderAction, QWidget], + toolbar_id: Optional[str] = None, + section: Optional[str] = None, + before: Optional[str] = None, + before_section: Optional[str] = None, + omit_id: bool = False): + """ + Add action or widget `item` to given application menu `section`. + + Parameters + ---------- + item: SpyderAction or QWidget + The item to add to the `toolbar`. + toolbar_id: str or None + The application toolbar unique string identifier. + section: str or None + The section id in which to insert the `item` on the `toolbar`. + before: str or None + Make the item appear before another given item. + before_section: str or None + Make the item defined section appear before another given section + (must be already defined). + omit_id: bool + If True, then the toolbar will check if the item to add declares an + id, False otherwise. This flag exists only for items added on + Spyder 4 plugins. Default: False + """ + if before is not None: + if not isinstance(before, str): + raise ValueError('before argument must be a str') + + return self.get_container().add_item_to_application_toolbar( + item, + toolbar_id=toolbar_id, + section=section, + before=before, + before_section=before_section, + omit_id=omit_id + ) + + def remove_item_from_application_toolbar(self, item_id: str, + toolbar_id: Optional[str] = None): + """ + Remove action or widget `item` from given application menu by id. + + Parameters + ---------- + item_id: str + The item to remove from the toolbar. + toolbar_id: str or None + The application toolbar unique string identifier. + """ + self.get_container().remove_item_from_application_toolbar( + item_id, + toolbar_id=toolbar_id + ) + + def get_application_toolbar(self, toolbar_id): + """ + Return an application toolbar by toolbar_id. + + Parameters + ---------- + toolbar_id: str + The toolbar unique string identifier. + + Returns + ------- + spyder.api.widgets.toolbars.ApplicationToolbar + The application toolbar. + """ + return self.get_container().get_application_toolbar(toolbar_id) + + def toggle_lock(self, value=None): + """Lock/Unlock toolbars.""" + for toolbar in self.toolbarslist: + toolbar.setMovable(not value) + + # --- Convenience properties, while all plugins migrate. + @property + def toolbars_menu(self): + return self.get_container().get_menu("toolbars_menu") + + @property + def show_toolbars_action(self): + return self.get_action("show toolbars") + + @property + def toolbarslist(self): + return self.get_container()._toolbarslist diff --git a/spyder/plugins/tours/container.py b/spyder/plugins/tours/container.py index eb730f5ce3a..5458336379d 100644 --- a/spyder/plugins/tours/container.py +++ b/spyder/plugins/tours/container.py @@ -1,114 +1,114 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Tours Container. -""" - -from collections import OrderedDict - -# Local imports -from spyder.api.exceptions import SpyderAPIError -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.plugins.tours.tours import TourIdentifiers -from spyder.plugins.tours.widgets import AnimatedTour, OpenTourDialog - -# Localization -_ = get_translation('spyder') - -# Set the index for the default tour -DEFAULT_TOUR = TourIdentifiers.IntroductionTour - - -class TourActions: - """ - Tours actions. - """ - ShowTour = "show tour" - - -class ToursContainer(PluginMainContainer): - """ - Tours container. - """ - - def __init__(self, name, plugin, parent=None): - super().__init__(name, plugin, parent=parent) - - self._main = plugin.main - self._tours = OrderedDict() - self._tour_titles = OrderedDict() - self._tour_widget = AnimatedTour(self._main) - self._tour_dialog = OpenTourDialog( - self, lambda: self.show_tour(DEFAULT_TOUR)) - self.tour_action = self.create_action( - TourActions.ShowTour, - text=_("Show tour"), - icon=self.create_icon('tour'), - triggered=lambda: self.show_tour(DEFAULT_TOUR) - ) - - # --- PluginMainContainer API - # ------------------------------------------------------------------------ - def setup(self): - self.tours_menu = self.create_menu( - "tours_menu", _("Interactive tours")) - - def update_actions(self): - pass - - # --- Public API - # ------------------------------------------------------------------------ - def register_tour(self, tour_id, title, tour_data): - """ - Register a new interactive tour on spyder. - - Parameters - ---------- - tour_id: str - Unique tour string identifier. - title: str - Localized tour name. - tour_data: dict - The tour steps. - """ - if tour_id in self._tours: - raise SpyderAPIError( - "Tour with id '{}' has already been registered!".format( - tour_id)) - - self._tours[tour_id] = tour_data - self._tour_titles[tour_id] = title - action = self.create_action( - tour_id, - text=title, - triggered=lambda: self.show_tour(tour_id), - ) - self.add_item_to_menu(action, menu=self.tours_menu) - - def show_tour(self, tour_id): - """ - Show interactive tour. - - Parameters - ---------- - tour_id: str - Unique tour string identifier. - """ - tour_data = self._tours[tour_id] - dic = {'last': 0, 'tour': tour_data} - self._tour_widget.set_tour(tour_id, dic, self._main) - self._tour_widget.start_tour() - - def show_tour_message(self): - """ - Show message about starting the tour the first time Spyder starts. - """ - self._tour_dialog.show() - self._tour_dialog.raise_() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Tours Container. +""" + +from collections import OrderedDict + +# Local imports +from spyder.api.exceptions import SpyderAPIError +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.tours.tours import TourIdentifiers +from spyder.plugins.tours.widgets import AnimatedTour, OpenTourDialog + +# Localization +_ = get_translation('spyder') + +# Set the index for the default tour +DEFAULT_TOUR = TourIdentifiers.IntroductionTour + + +class TourActions: + """ + Tours actions. + """ + ShowTour = "show tour" + + +class ToursContainer(PluginMainContainer): + """ + Tours container. + """ + + def __init__(self, name, plugin, parent=None): + super().__init__(name, plugin, parent=parent) + + self._main = plugin.main + self._tours = OrderedDict() + self._tour_titles = OrderedDict() + self._tour_widget = AnimatedTour(self._main) + self._tour_dialog = OpenTourDialog( + self, lambda: self.show_tour(DEFAULT_TOUR)) + self.tour_action = self.create_action( + TourActions.ShowTour, + text=_("Show tour"), + icon=self.create_icon('tour'), + triggered=lambda: self.show_tour(DEFAULT_TOUR) + ) + + # --- PluginMainContainer API + # ------------------------------------------------------------------------ + def setup(self): + self.tours_menu = self.create_menu( + "tours_menu", _("Interactive tours")) + + def update_actions(self): + pass + + # --- Public API + # ------------------------------------------------------------------------ + def register_tour(self, tour_id, title, tour_data): + """ + Register a new interactive tour on spyder. + + Parameters + ---------- + tour_id: str + Unique tour string identifier. + title: str + Localized tour name. + tour_data: dict + The tour steps. + """ + if tour_id in self._tours: + raise SpyderAPIError( + "Tour with id '{}' has already been registered!".format( + tour_id)) + + self._tours[tour_id] = tour_data + self._tour_titles[tour_id] = title + action = self.create_action( + tour_id, + text=title, + triggered=lambda: self.show_tour(tour_id), + ) + self.add_item_to_menu(action, menu=self.tours_menu) + + def show_tour(self, tour_id): + """ + Show interactive tour. + + Parameters + ---------- + tour_id: str + Unique tour string identifier. + """ + tour_data = self._tours[tour_id] + dic = {'last': 0, 'tour': tour_data} + self._tour_widget.set_tour(tour_id, dic, self._main) + self._tour_widget.start_tour() + + def show_tour_message(self): + """ + Show message about starting the tour the first time Spyder starts. + """ + self._tour_dialog.show() + self._tour_dialog.raise_() diff --git a/spyder/plugins/tours/plugin.py b/spyder/plugins/tours/plugin.py index 59fbfe68927..e7cd2770d10 100644 --- a/spyder/plugins/tours/plugin.py +++ b/spyder/plugins/tours/plugin.py @@ -1,121 +1,121 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ----------------------------------------------------------------------------- - -""" -Tours Plugin. -""" - -# Local imports -from spyder.api.plugins import Plugins, SpyderPluginV2 -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_safe_mode, running_under_pytest -from spyder.plugins.application.api import ApplicationActions -from spyder.plugins.tours.container import TourActions, ToursContainer -from spyder.plugins.tours.tours import INTRO_TOUR, TourIdentifiers -from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections - -# Localization -_ = get_translation('spyder') - - -# --- Plugin -# ---------------------------------------------------------------------------- -class Tours(SpyderPluginV2): - """ - Tours Plugin. - """ - NAME = 'tours' - CONF_SECTION = NAME - OPTIONAL = [Plugins.MainMenu] - CONF_FILE = False - CONTAINER_CLASS = ToursContainer - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _("Interactive tours") - - def get_description(self): - return _("Provide interactive tours.") - - def get_icon(self): - return self.create_icon('tour') - - def on_initialize(self): - self.register_tour( - TourIdentifiers.IntroductionTour, - _("Introduction to Spyder"), - INTRO_TOUR, - ) - - @on_plugin_available(plugin=Plugins.MainMenu) - def on_main_menu_available(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - - mainmenu.add_item_to_application_menu( - self.get_container().tour_action, - menu_id=ApplicationMenus.Help, - section=HelpMenuSections.Documentation, - before=ApplicationActions.SpyderDocumentationAction) - - @on_plugin_teardown(plugin=Plugins.MainMenu) - def on_main_menu_teardown(self): - mainmenu = self.get_plugin(Plugins.MainMenu) - mainmenu.remove_item_from_application_menu( - TourActions.ShowTour, - menu_id=ApplicationMenus.Help) - - def on_mainwindow_visible(self): - self.show_tour_message() - - # --- Public API - # ------------------------------------------------------------------------ - def register_tour(self, tour_id, title, tour_data): - """ - Register a new interactive tour on spyder. - - Parameters - ---------- - tour_id: str - Unique tour string identifier. - title: str - Localized tour name. - tour_data: dict - The tour steps. - """ - self.get_container().register_tour(tour_id, title, tour_data) - - def show_tour(self, index): - """ - Show interactive tour. - - Parameters - ---------- - index: int - The tour index to display. - """ - self.main.maximize_dockwidget(restore=True) - self.get_container().show_tour(index) - - def show_tour_message(self, force=False): - """ - Show message about starting the tour the first time Spyder starts. - - Parameters - ---------- - force: bool - Force the display of the tour message. - """ - should_show_tour = self.get_conf('show_tour_message') - if force or (should_show_tour and not running_under_pytest() - and not get_safe_mode()): - self.set_conf('show_tour_message', False) - self.get_container().show_tour_message() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ----------------------------------------------------------------------------- + +""" +Tours Plugin. +""" + +# Local imports +from spyder.api.plugins import Plugins, SpyderPluginV2 +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_safe_mode, running_under_pytest +from spyder.plugins.application.api import ApplicationActions +from spyder.plugins.tours.container import TourActions, ToursContainer +from spyder.plugins.tours.tours import INTRO_TOUR, TourIdentifiers +from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections + +# Localization +_ = get_translation('spyder') + + +# --- Plugin +# ---------------------------------------------------------------------------- +class Tours(SpyderPluginV2): + """ + Tours Plugin. + """ + NAME = 'tours' + CONF_SECTION = NAME + OPTIONAL = [Plugins.MainMenu] + CONF_FILE = False + CONTAINER_CLASS = ToursContainer + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _("Interactive tours") + + def get_description(self): + return _("Provide interactive tours.") + + def get_icon(self): + return self.create_icon('tour') + + def on_initialize(self): + self.register_tour( + TourIdentifiers.IntroductionTour, + _("Introduction to Spyder"), + INTRO_TOUR, + ) + + @on_plugin_available(plugin=Plugins.MainMenu) + def on_main_menu_available(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + + mainmenu.add_item_to_application_menu( + self.get_container().tour_action, + menu_id=ApplicationMenus.Help, + section=HelpMenuSections.Documentation, + before=ApplicationActions.SpyderDocumentationAction) + + @on_plugin_teardown(plugin=Plugins.MainMenu) + def on_main_menu_teardown(self): + mainmenu = self.get_plugin(Plugins.MainMenu) + mainmenu.remove_item_from_application_menu( + TourActions.ShowTour, + menu_id=ApplicationMenus.Help) + + def on_mainwindow_visible(self): + self.show_tour_message() + + # --- Public API + # ------------------------------------------------------------------------ + def register_tour(self, tour_id, title, tour_data): + """ + Register a new interactive tour on spyder. + + Parameters + ---------- + tour_id: str + Unique tour string identifier. + title: str + Localized tour name. + tour_data: dict + The tour steps. + """ + self.get_container().register_tour(tour_id, title, tour_data) + + def show_tour(self, index): + """ + Show interactive tour. + + Parameters + ---------- + index: int + The tour index to display. + """ + self.main.maximize_dockwidget(restore=True) + self.get_container().show_tour(index) + + def show_tour_message(self, force=False): + """ + Show message about starting the tour the first time Spyder starts. + + Parameters + ---------- + force: bool + Force the display of the tour message. + """ + should_show_tour = self.get_conf('show_tour_message') + if force or (should_show_tour and not running_under_pytest() + and not get_safe_mode()): + self.set_conf('show_tour_message', False) + self.get_container().show_tour_message() diff --git a/spyder/plugins/tours/tours.py b/spyder/plugins/tours/tours.py index 632ffc32c97..1885dbffd9f 100644 --- a/spyder/plugins/tours/tours.py +++ b/spyder/plugins/tours/tours.py @@ -1,234 +1,234 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Default tours.""" - -# Standard library imports -import sys - -# Local imports -from spyder.api.translations import get_translation -from spyder.plugins.tours.api import SpyderWidgets as sw -from spyder import __docs_url__ - -# Localization -_ = get_translation('spyder') - -# Constants -QTCONSOLE_LINK = "https://qtconsole.readthedocs.io/en/stable/index.html" -BUTTON_TEXT = "" -if sys.platform != "darwin": - BUTTON_TEXT = ("Please click on the button below to run some simple " - "code in this console. This will be useful to show " - "you other important features.") - -# This test should serve as example of keys to use in the tour frame dicts -TEST_TOUR = [ - { - 'title': "Welcome to Spyder introduction tour", - 'content': "Spyder is an interactive development " - "environment. This tip panel supports rich text.
    " - "
    it also supports image insertion to the right so far", - 'image': 'spyder_about', - }, - { - 'title': "Widget display", - 'content': ("This shows how a widget is displayed. The tip panel " - "is adjusted based on the first widget in the list"), - 'widgets': ['button1'], - 'decoration': ['button2'], - 'interact': True, - }, - { - 'title': "Widget display", - 'content': ("This shows how a widget is displayed. The tip panel " - "is adjusted based on the first widget in the list"), - 'widgets': ['button1'], - 'decoration': ['button1'], - 'interact': True, - }, - { - 'title': "Widget display", - 'content': ("This shows how a widget is displayed. The tip panel " - "is adjusted based on the first widget in the list"), - 'widgets': ['button1'], - 'interact': True, - }, - { - 'title': "Widget display and highlight", - 'content': "This shows how a highlighted widget looks", - 'widgets': ['button'], - 'decoration': ['button'], - 'interact': False, - }, -] - - -INTRO_TOUR = [ - { - 'title': _("Welcome to the introduction tour!"), - 'content': _("Spyder is a powerful Interactive " - "Development Environment (or IDE) for the Python " - "programming language.

    " - "Here, we are going to guide you through its most " - "important features.

    " - "Please use the arrow keys or click on the buttons " - "below to move along the tour."), - 'image': 'spyder_about', - }, - { - 'title': _("Editor"), - 'content': _("This is where you write Python code before " - "evaluating it. You can get automatic " - "completions while typing, along with calltips " - "when calling a function and help when hovering " - "over an object." - "

    The Editor comes " - "with a line number area (highlighted here in red) " - "where Spyder shows warnings and syntax errors. " - "They can help you to detect potential problems " - "before running your code.

    " - "You can also set debug breakpoints in the line " - "number area by clicking next to " - "any non-empty line."), - 'widgets': [sw.editor], - 'decoration': [sw.editor_line_number_area], - }, - { - 'title': _("IPython Console"), - 'content': _("This is where you can run Python code, either " - "from the Editor or interactively. To run the " - "current file, press F5 by default, " - "or press F9 to execute the current " - "line or selection.

    " - "The IPython Console comes with many " - "useful features that greatly improve your " - "programming workflow, like syntax highlighting, " - "autocompletion, plotting and 'magic' commands. " - "To learn more, check out the " - "documentation." - "

    {1}").format(QTCONSOLE_LINK, BUTTON_TEXT), - 'widgets': [sw.ipython_console], - 'run': [ - "test_list_tour = [1, 2, 3, 4, 5]", - "test_dict_tour = {'a': 1, 'b': 2}", - ], - }, - { - 'title': _("Variable Explorer"), - 'content': _("In this pane you can view and edit the variables " - "generated during the execution of a program, or " - "those entered directly in the " - "IPython Console.

    " - "If you ran the code in the previous step, " - "the Variable Explorer will show " - "the list and dictionary objects it generated. " - "By double-clicking any variable, " - "a new window will be opened where you " - "can inspect and modify their contents."), - 'widgets': [sw.variable_explorer], - 'interact': True, - }, - { - 'title': _("Help"), - 'content': _("This pane displays documentation of the " - "functions, classes, methods or modules you are " - "currently using in the Editor or the " - "IPython Console." - "

    To use it, press Ctrl+I " - "(Cmd-I on macOS) with the text cursor " - "in or next to the object you want help on."), - 'widgets': [sw.help_plugin], - 'interact': True, - }, - { - 'title': _("Plots"), - 'content': _("This pane shows the figures and images created " - "during your code execution. It allows you to browse, " - "zoom, copy, and save the generated plots."), - 'widgets': [sw.plots_plugin], - 'interact': True, - }, - { - 'title': _("Files"), - 'content': _("This pane lets you browse the files and " - "directories on your computer.

    " - "You can open any file in its " - "corresponding application by double-clicking it, " - "and supported file types will be opened right " - "inside of Spyder.

    " - "The Files pane also allows you to copy one or " - "many absolute or relative paths, automatically " - "formatted as Python strings or lists, and perform " - "a variety of other file operations."), - 'widgets': [sw.file_explorer], - 'interact': True, - }, - { - 'title': _("History Log"), - 'content': _("This pane records all the commands and code run " - "in any IPython console, allowing you to easily " - "retrace your steps for reproducible research."), - 'widgets': [sw.history_log], - 'interact': True, - }, - { - 'title': _("Find"), - 'content': _("The Find pane allows you to search for text in a " - "given directory and navigate through all the found " - "occurrences."), - 'widgets': [sw.find_plugin], - 'interact': True, - }, - { - 'title': _("Profiler"), - 'content': _("The Profiler helps you optimize your code by " - "determining the run time and number of calls for " - "every function and method used in a file. It also " - "allows you to save and compare your results between " - "runs."), - 'widgets': [sw.profiler], - 'interact': True, - }, - { - 'title': _("Code Analysis"), - 'content': _("The Code Analysis helps you improve the quality of " - "your programs by detecting style issues, bad practices " - "and potential bugs."), - 'widgets': [sw.code_analysis], - 'interact': True - }, - { - 'title': _("The end"), - 'content': _('You have reached the end of our tour and are ' - 'ready to start using Spyder! For more ' - 'information, check out our ' - 'documentation.' - '

    ').format(__docs_url__), - 'image': 'spyder_about' - }, -] - - -FEAT30 = [ - { - 'title': _("New features in Spyder 3.0"), - 'content': _("Spyder is an interactive development " - "environment based on bla"), - 'image': 'spyder_about', - }, - { - 'title': _("Welcome to Spyder introduction tour"), - 'content': _("Spyder is an interactive development environment " - "based on bla"), - 'widgets': ['variableexplorer'], - }, -] - - -class TourIdentifiers: - IntroductionTour = "introduction_tour" - TestTour = "test_tour" +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Default tours.""" + +# Standard library imports +import sys + +# Local imports +from spyder.api.translations import get_translation +from spyder.plugins.tours.api import SpyderWidgets as sw +from spyder import __docs_url__ + +# Localization +_ = get_translation('spyder') + +# Constants +QTCONSOLE_LINK = "https://qtconsole.readthedocs.io/en/stable/index.html" +BUTTON_TEXT = "" +if sys.platform != "darwin": + BUTTON_TEXT = ("Please click on the button below to run some simple " + "code in this console. This will be useful to show " + "you other important features.") + +# This test should serve as example of keys to use in the tour frame dicts +TEST_TOUR = [ + { + 'title': "Welcome to Spyder introduction tour", + 'content': "Spyder is an interactive development " + "environment. This tip panel supports rich text.
    " + "
    it also supports image insertion to the right so far", + 'image': 'spyder_about', + }, + { + 'title': "Widget display", + 'content': ("This shows how a widget is displayed. The tip panel " + "is adjusted based on the first widget in the list"), + 'widgets': ['button1'], + 'decoration': ['button2'], + 'interact': True, + }, + { + 'title': "Widget display", + 'content': ("This shows how a widget is displayed. The tip panel " + "is adjusted based on the first widget in the list"), + 'widgets': ['button1'], + 'decoration': ['button1'], + 'interact': True, + }, + { + 'title': "Widget display", + 'content': ("This shows how a widget is displayed. The tip panel " + "is adjusted based on the first widget in the list"), + 'widgets': ['button1'], + 'interact': True, + }, + { + 'title': "Widget display and highlight", + 'content': "This shows how a highlighted widget looks", + 'widgets': ['button'], + 'decoration': ['button'], + 'interact': False, + }, +] + + +INTRO_TOUR = [ + { + 'title': _("Welcome to the introduction tour!"), + 'content': _("Spyder is a powerful Interactive " + "Development Environment (or IDE) for the Python " + "programming language.

    " + "Here, we are going to guide you through its most " + "important features.

    " + "Please use the arrow keys or click on the buttons " + "below to move along the tour."), + 'image': 'spyder_about', + }, + { + 'title': _("Editor"), + 'content': _("This is where you write Python code before " + "evaluating it. You can get automatic " + "completions while typing, along with calltips " + "when calling a function and help when hovering " + "over an object." + "

    The Editor comes " + "with a line number area (highlighted here in red) " + "where Spyder shows warnings and syntax errors. " + "They can help you to detect potential problems " + "before running your code.

    " + "You can also set debug breakpoints in the line " + "number area by clicking next to " + "any non-empty line."), + 'widgets': [sw.editor], + 'decoration': [sw.editor_line_number_area], + }, + { + 'title': _("IPython Console"), + 'content': _("This is where you can run Python code, either " + "from the Editor or interactively. To run the " + "current file, press F5 by default, " + "or press F9 to execute the current " + "line or selection.

    " + "The IPython Console comes with many " + "useful features that greatly improve your " + "programming workflow, like syntax highlighting, " + "autocompletion, plotting and 'magic' commands. " + "To learn more, check out the " + "documentation." + "

    {1}").format(QTCONSOLE_LINK, BUTTON_TEXT), + 'widgets': [sw.ipython_console], + 'run': [ + "test_list_tour = [1, 2, 3, 4, 5]", + "test_dict_tour = {'a': 1, 'b': 2}", + ], + }, + { + 'title': _("Variable Explorer"), + 'content': _("In this pane you can view and edit the variables " + "generated during the execution of a program, or " + "those entered directly in the " + "IPython Console.

    " + "If you ran the code in the previous step, " + "the Variable Explorer will show " + "the list and dictionary objects it generated. " + "By double-clicking any variable, " + "a new window will be opened where you " + "can inspect and modify their contents."), + 'widgets': [sw.variable_explorer], + 'interact': True, + }, + { + 'title': _("Help"), + 'content': _("This pane displays documentation of the " + "functions, classes, methods or modules you are " + "currently using in the Editor or the " + "IPython Console." + "

    To use it, press Ctrl+I " + "(Cmd-I on macOS) with the text cursor " + "in or next to the object you want help on."), + 'widgets': [sw.help_plugin], + 'interact': True, + }, + { + 'title': _("Plots"), + 'content': _("This pane shows the figures and images created " + "during your code execution. It allows you to browse, " + "zoom, copy, and save the generated plots."), + 'widgets': [sw.plots_plugin], + 'interact': True, + }, + { + 'title': _("Files"), + 'content': _("This pane lets you browse the files and " + "directories on your computer.

    " + "You can open any file in its " + "corresponding application by double-clicking it, " + "and supported file types will be opened right " + "inside of Spyder.

    " + "The Files pane also allows you to copy one or " + "many absolute or relative paths, automatically " + "formatted as Python strings or lists, and perform " + "a variety of other file operations."), + 'widgets': [sw.file_explorer], + 'interact': True, + }, + { + 'title': _("History Log"), + 'content': _("This pane records all the commands and code run " + "in any IPython console, allowing you to easily " + "retrace your steps for reproducible research."), + 'widgets': [sw.history_log], + 'interact': True, + }, + { + 'title': _("Find"), + 'content': _("The Find pane allows you to search for text in a " + "given directory and navigate through all the found " + "occurrences."), + 'widgets': [sw.find_plugin], + 'interact': True, + }, + { + 'title': _("Profiler"), + 'content': _("The Profiler helps you optimize your code by " + "determining the run time and number of calls for " + "every function and method used in a file. It also " + "allows you to save and compare your results between " + "runs."), + 'widgets': [sw.profiler], + 'interact': True, + }, + { + 'title': _("Code Analysis"), + 'content': _("The Code Analysis helps you improve the quality of " + "your programs by detecting style issues, bad practices " + "and potential bugs."), + 'widgets': [sw.code_analysis], + 'interact': True + }, + { + 'title': _("The end"), + 'content': _('You have reached the end of our tour and are ' + 'ready to start using Spyder! For more ' + 'information, check out our ' + 'documentation.' + '

    ').format(__docs_url__), + 'image': 'spyder_about' + }, +] + + +FEAT30 = [ + { + 'title': _("New features in Spyder 3.0"), + 'content': _("Spyder is an interactive development " + "environment based on bla"), + 'image': 'spyder_about', + }, + { + 'title': _("Welcome to Spyder introduction tour"), + 'content': _("Spyder is an interactive development environment " + "based on bla"), + 'widgets': ['variableexplorer'], + }, +] + + +class TourIdentifiers: + IntroductionTour = "introduction_tour" + TestTour = "test_tour" diff --git a/spyder/plugins/tours/widgets.py b/spyder/plugins/tours/widgets.py index eb23ffcaadf..dfb82d6e898 100644 --- a/spyder/plugins/tours/widgets.py +++ b/spyder/plugins/tours/widgets.py @@ -1,1286 +1,1286 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Spyder interactive tours""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from math import ceil -import sys - -# Third party imports -from qtpy.QtCore import (QEasingCurve, QPoint, QPropertyAnimation, QRectF, Qt, - Signal) -from qtpy.QtGui import (QBrush, QColor, QIcon, QPainter, QPainterPath, QPen, - QPixmap, QRegion) -from qtpy.QtWidgets import (QAction, QApplication, QComboBox, QDialog, - QGraphicsOpacityEffect, QHBoxLayout, QLabel, - QLayout, QMainWindow, QMenu, QMessageBox, - QPushButton, QSpacerItem, QToolButton, QVBoxLayout, - QWidget) - -# Local imports -from spyder import __docs_url__ -from spyder.api.panel import Panel -from spyder.api.translations import get_translation -from spyder.config.base import _ -from spyder.plugins.layout.layouts import DefaultLayouts -from spyder.py3compat import to_binary_string -from spyder.utils.icon_manager import ima -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.palette import QStylePalette, SpyderPalette -from spyder.utils.qthelpers import add_actions, create_action -from spyder.utils.stylesheet import DialogStyle - -MAIN_TOP_COLOR = MAIN_BG_COLOR = QColor(QStylePalette.COLOR_BACKGROUND_1) - -# Localization -_ = get_translation('spyder') - -MAC = sys.platform == 'darwin' - - -class FadingDialog(QDialog): - """A general fade in/fade out QDialog with some builtin functions""" - sig_key_pressed = Signal() - - def __init__(self, parent, opacity, duration, easing_curve): - super(FadingDialog, self).__init__(parent) - - self.parent = parent - self.opacity_min = min(opacity) - self.opacity_max = max(opacity) - self.duration_fadein = duration[0] - self.duration_fadeout = duration[-1] - self.easing_curve_in = easing_curve[0] - self.easing_curve_out = easing_curve[-1] - self.effect = None - self.anim = None - - self._fade_running = False - self._funcs_before_fade_in = [] - self._funcs_after_fade_in = [] - self._funcs_before_fade_out = [] - self._funcs_after_fade_out = [] - - self.setModal(False) - - def _run(self, funcs): - for func in funcs: - func() - - def _run_before_fade_in(self): - self._run(self._funcs_before_fade_in) - - def _run_after_fade_in(self): - self._run(self._funcs_after_fade_in) - - def _run_before_fade_out(self): - self._run(self._funcs_before_fade_out) - - def _run_after_fade_out(self): - self._run(self._funcs_after_fade_out) - - def _set_fade_finished(self): - self._fade_running = False - - def _fade_setup(self): - self._fade_running = True - self.effect = QGraphicsOpacityEffect(self) - self.setGraphicsEffect(self.effect) - self.anim = QPropertyAnimation( - self.effect, to_binary_string("opacity")) - - # --- public api - def fade_in(self, on_finished_connect): - self._run_before_fade_in() - self._fade_setup() - self.show() - self.raise_() - self.anim.setEasingCurve(self.easing_curve_in) - self.anim.setStartValue(self.opacity_min) - self.anim.setEndValue(self.opacity_max) - self.anim.setDuration(self.duration_fadein) - self.anim.finished.connect(on_finished_connect) - self.anim.finished.connect(self._set_fade_finished) - self.anim.finished.connect(self._run_after_fade_in) - self.anim.start() - - def fade_out(self, on_finished_connect): - self._run_before_fade_out() - self._fade_setup() - self.anim.setEasingCurve(self.easing_curve_out) - self.anim.setStartValue(self.opacity_max) - self.anim.setEndValue(self.opacity_min) - self.anim.setDuration(self.duration_fadeout) - self.anim.finished.connect(on_finished_connect) - self.anim.finished.connect(self._set_fade_finished) - self.anim.finished.connect(self._run_after_fade_out) - self.anim.start() - - def is_fade_running(self): - return self._fade_running - - def set_funcs_before_fade_in(self, funcs): - self._funcs_before_fade_in = funcs - - def set_funcs_after_fade_in(self, funcs): - self._funcs_after_fade_in = funcs - - def set_funcs_before_fade_out(self, funcs): - self._funcs_before_fade_out = funcs - - def set_funcs_after_fade_out(self, funcs): - self._funcs_after_fade_out = funcs - - -class FadingCanvas(FadingDialog): - """The black semi transparent canvas that covers the application""" - def __init__(self, parent, opacity, duration, easing_curve, color, - tour=None): - """Create a black semi transparent canvas that covers the app.""" - super(FadingCanvas, self).__init__(parent, opacity, duration, - easing_curve) - self.parent = parent - self.tour = tour - - # Canvas color - self.color = color - # Decoration color - self.color_decoration = QColor(SpyderPalette.COLOR_ERROR_2) - # Width in pixels for decoration - self.stroke_decoration = 2 - - self.region_mask = None - self.region_subtract = None - self.region_decoration = None - - self.widgets = None # The widget to uncover - self.decoration = None # The widget to draw decoration - self.interaction_on = False - - self.path_current = None - self.path_subtract = None - self.path_full = None - self.path_decoration = None - - # widget setup - self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) - self.setAttribute(Qt.WA_TranslucentBackground) - self.setAttribute(Qt.WA_TransparentForMouseEvents) - self.setModal(False) - self.setFocusPolicy(Qt.NoFocus) - - self.set_funcs_before_fade_in([self.update_canvas]) - self.set_funcs_after_fade_out([lambda: self.update_widgets(None), - lambda: self.update_decoration(None)]) - - def set_interaction(self, value): - self.interaction_on = value - - def update_canvas(self): - w, h = self.parent.size().width(), self.parent.size().height() - - self.path_full = QPainterPath() - self.path_subtract = QPainterPath() - self.path_decoration = QPainterPath() - self.region_mask = QRegion(0, 0, w, h) - - self.path_full.addRect(0, 0, w, h) - # Add the path - if self.widgets is not None: - for widget in self.widgets: - temp_path = QPainterPath() - # if widget is not found... find more general way to handle - if widget is not None: - widget.raise_() - widget.show() - geo = widget.frameGeometry() - width, height = geo.width(), geo.height() - point = widget.mapTo(self.parent, QPoint(0, 0)) - x, y = point.x(), point.y() - - temp_path.addRect(QRectF(x, y, width, height)) - - temp_region = QRegion(x, y, width, height) - - if self.interaction_on: - self.region_mask = self.region_mask.subtracted(temp_region) - self.path_subtract = self.path_subtract.united(temp_path) - - self.path_current = self.path_full.subtracted(self.path_subtract) - else: - self.path_current = self.path_full - if self.decoration is not None: - for widgets in self.decoration: - if isinstance(widgets, QWidget): - widgets = [widgets] - geoms = [] - for widget in widgets: - widget.raise_() - widget.show() - geo = widget.frameGeometry() - width, height = geo.width(), geo.height() - point = widget.mapTo(self.parent, QPoint(0, 0)) - x, y = point.x(), point.y() - geoms.append((x, y, width, height)) - x = min([geom[0] for geom in geoms]) - y = min([geom[1] for geom in geoms]) - width = max([ - geom[0] + geom[2] for geom in geoms]) - x - height = max([ - geom[1] + geom[3] for geom in geoms]) - y - temp_path = QPainterPath() - temp_path.addRect(QRectF(x, y, width, height)) - - temp_region_1 = QRegion(x-1, y-1, width+2, height+2) - temp_region_2 = QRegion(x+1, y+1, width-2, height-2) - temp_region = temp_region_1.subtracted(temp_region_2) - - if self.interaction_on: - self.region_mask = self.region_mask.united(temp_region) - - self.path_decoration = self.path_decoration.united(temp_path) - else: - self.path_decoration.addRect(0, 0, 0, 0) - - # Add a decoration stroke around widget - self.setMask(self.region_mask) - self.update() - self.repaint() - - def update_widgets(self, widgets): - self.widgets = widgets - - def update_decoration(self, widgets): - self.decoration = widgets - - def paintEvent(self, event): - """Override Qt method""" - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - # Decoration - painter.fillPath(self.path_current, QBrush(self.color)) - painter.strokePath(self.path_decoration, QPen(self.color_decoration, - self.stroke_decoration)) -# decoration_fill = QColor(self.color_decoration) -# decoration_fill.setAlphaF(0.25) -# painter.fillPath(self.path_decoration, decoration_fill) - - def reject(self): - """Override Qt method""" - if not self.is_fade_running(): - key = Qt.Key_Escape - self.key_pressed = key - self.sig_key_pressed.emit() - - def mousePressEvent(self, event): - """Override Qt method""" - pass - - def focusInEvent(self, event): - """Override Qt method.""" - # To be used so tips do not appear outside spyder - if self.hasFocus(): - self.tour.gain_focus() - - def focusOutEvent(self, event): - """Override Qt method.""" - # To be used so tips do not appear outside spyder - if self.tour.step_current != 0: - self.tour.lost_focus() - - -class FadingTipBox(FadingDialog): - """Dialog that contains the text for each frame in the tour.""" - def __init__(self, parent, opacity, duration, easing_curve, tour=None, - color_top=None, color_back=None, combobox_background=None): - super(FadingTipBox, self).__init__(parent, opacity, duration, - easing_curve) - self.holder = self.anim # needed for qt to work - self.parent = parent - self.tour = tour - - self.frames = None - self.offset_shadow = 0 - self.fixed_width = 300 - - self.key_pressed = None - - self.setAttribute(Qt.WA_TranslucentBackground) - self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint) - self.setModal(False) - - # Widgets - def toolbutton(icon): - bt = QToolButton() - bt.setAutoRaise(True) - bt.setIcon(icon) - return bt - - self.button_close = toolbutton(ima.icon("tour.close")) - self.button_home = toolbutton(ima.icon("tour.home")) - self.button_previous = toolbutton(ima.icon("tour.previous")) - self.button_end = toolbutton(ima.icon("tour.end")) - self.button_next = toolbutton(ima.icon("tour.next")) - self.button_run = QPushButton(_('Run code')) - self.button_disable = None - self.button_current = QToolButton() - self.label_image = QLabel() - - self.label_title = QLabel() - self.combo_title = QComboBox() - self.label_current = QLabel() - self.label_content = QLabel() - - self.label_content.setOpenExternalLinks(True) - self.label_content.setMinimumWidth(self.fixed_width) - self.label_content.setMaximumWidth(self.fixed_width) - - self.label_current.setAlignment(Qt.AlignCenter) - - self.label_content.setWordWrap(True) - - self.widgets = [self.label_content, self.label_title, - self.label_current, self.combo_title, - self.button_close, self.button_run, self.button_next, - self.button_previous, self.button_end, - self.button_home, self.button_current] - - arrow = get_image_path('hide') - - self.color_top = color_top - self.color_back = color_back - self.combobox_background = combobox_background - self.stylesheet = '''QComboBox {{ - padding-left: 5px; - background-color: {} - border-width: 0px; - border-radius: 0px; - min-height:20px; - max-height:20px; - }} - - QComboBox::drop-down {{ - subcontrol-origin: padding; - subcontrol-position: top left; - border-width: 0px; - }} - - QComboBox::down-arrow {{ - image: url({}); - }} - '''.format(self.combobox_background.name(), arrow) - # Windows fix, slashes should be always in unix-style - self.stylesheet = self.stylesheet.replace('\\', '/') - - self.setFocusPolicy(Qt.StrongFocus) - for widget in self.widgets: - widget.setFocusPolicy(Qt.NoFocus) - widget.setStyleSheet(self.stylesheet) - - layout_top = QHBoxLayout() - layout_top.addWidget(self.combo_title) - layout_top.addStretch() - layout_top.addWidget(self.button_close) - layout_top.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout_content = QHBoxLayout() - layout_content.addWidget(self.label_content) - layout_content.addWidget(self.label_image) - layout_content.addSpacerItem(QSpacerItem(5, 5)) - - layout_run = QHBoxLayout() - layout_run.addStretch() - layout_run.addWidget(self.button_run) - layout_run.addStretch() - layout_run.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout_navigation = QHBoxLayout() - layout_navigation.addWidget(self.button_home) - layout_navigation.addWidget(self.button_previous) - layout_navigation.addStretch() - layout_navigation.addWidget(self.label_current) - layout_navigation.addStretch() - layout_navigation.addWidget(self.button_next) - layout_navigation.addWidget(self.button_end) - layout_navigation.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout = QVBoxLayout() - layout.addLayout(layout_top) - layout.addStretch() - layout.addSpacerItem(QSpacerItem(15, 15)) - layout.addLayout(layout_content) - layout.addLayout(layout_run) - layout.addStretch() - layout.addSpacerItem(QSpacerItem(15, 15)) - layout.addLayout(layout_navigation) - layout.addSpacerItem(QSpacerItem(self.offset_shadow, - self.offset_shadow)) - - layout.setSizeConstraint(QLayout.SetFixedSize) - - self.setLayout(layout) - - self.set_funcs_before_fade_in([self._disable_widgets]) - self.set_funcs_after_fade_in([self._enable_widgets, self.setFocus]) - self.set_funcs_before_fade_out([self._disable_widgets]) - - self.setContextMenuPolicy(Qt.CustomContextMenu) - - # signals and slots - # These are defined every time by the AnimatedTour Class - - def _disable_widgets(self): - for widget in self.widgets: - widget.setDisabled(True) - - def _enable_widgets(self): - self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | - Qt.WindowStaysOnTopHint) - for widget in self.widgets: - widget.setDisabled(False) - - if self.button_disable == 'previous': - self.button_previous.setDisabled(True) - self.button_home.setDisabled(True) - elif self.button_disable == 'next': - self.button_next.setDisabled(True) - self.button_end.setDisabled(True) - self.button_run.setDisabled(sys.platform == "darwin") - - def set_data(self, title, content, current, image, run, frames=None, - step=None): - self.label_title.setText(title) - self.combo_title.clear() - self.combo_title.addItems(frames) - self.combo_title.setCurrentIndex(step) -# min_content_len = max([len(f) for f in frames]) -# self.combo_title.setMinimumContentsLength(min_content_len) - - # Fix and try to see how it looks with a combo box - self.label_current.setText(current) - self.button_current.setText(current) - self.label_content.setText(content) - self.image = image - - if image is None: - self.label_image.setFixedHeight(1) - self.label_image.setFixedWidth(1) - else: - extension = image.split('.')[-1] - self.image = QPixmap(get_image_path(image), extension) - self.label_image.setPixmap(self.image) - self.label_image.setFixedSize(self.image.size()) - - if run is None: - self.button_run.setVisible(False) - else: - self.button_run.setVisible(True) - if sys.platform == "darwin": - self.button_run.setToolTip("Not available on macOS") - - # Refresh layout - self.layout().activate() - - def set_pos(self, x, y): - self.x = ceil(x) - self.y = ceil(y) - self.move(QPoint(self.x, self.y)) - - def build_paths(self): - geo = self.geometry() - radius = 0 - shadow = self.offset_shadow - x0, y0 = geo.x(), geo.y() - width, height = geo.width() - shadow, geo.height() - shadow - - left, top = 0, 0 - right, bottom = width, height - - self.round_rect_path = QPainterPath() - self.round_rect_path.moveTo(right, top + radius) - self.round_rect_path.arcTo(right-radius, top, radius, radius, 0.0, - 90.0) - self.round_rect_path.lineTo(left+radius, top) - self.round_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) - self.round_rect_path.lineTo(left, bottom-radius) - self.round_rect_path.arcTo(left, bottom-radius, radius, radius, 180.0, - 90.0) - self.round_rect_path.lineTo(right-radius, bottom) - self.round_rect_path.arcTo(right-radius, bottom-radius, radius, radius, - 270.0, 90.0) - self.round_rect_path.closeSubpath() - - # Top path - header = 36 - offset = 2 - left, top = offset, offset - right = width - (offset) - self.top_rect_path = QPainterPath() - self.top_rect_path.lineTo(right, top + radius) - self.top_rect_path.moveTo(right, top + radius) - self.top_rect_path.arcTo(right-radius, top, radius, radius, 0.0, 90.0) - self.top_rect_path.lineTo(left+radius, top) - self.top_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) - self.top_rect_path.lineTo(left, top + header) - self.top_rect_path.lineTo(right, top + header) - - def paintEvent(self, event): - """Override Qt method.""" - self.build_paths() - - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - - painter.fillPath(self.round_rect_path, self.color_back) - painter.fillPath(self.top_rect_path, self.color_top) - painter.strokePath(self.round_rect_path, QPen(Qt.gray, 1)) - - # TODO: Build the pointing arrow? - - def keyReleaseEvent(self, event): - """Override Qt method.""" - key = event.key() - self.key_pressed = key - - keys = [Qt.Key_Right, Qt.Key_Left, Qt.Key_Down, Qt.Key_Up, - Qt.Key_Escape, Qt.Key_PageUp, Qt.Key_PageDown, - Qt.Key_Home, Qt.Key_End, Qt.Key_Menu] - - if key in keys: - if not self.is_fade_running(): - self.sig_key_pressed.emit() - - def mousePressEvent(self, event): - """Override Qt method.""" - # Raise the main application window on click - self.parent.raise_() - self.raise_() - - if event.button() == Qt.RightButton: - pass -# clicked_widget = self.childAt(event.x(), event.y()) -# if clicked_widget == self.label_current: -# self.context_menu_requested(event) - - def focusOutEvent(self, event): - """Override Qt method.""" - # To be used so tips do not appear outside spyder - self.tour.lost_focus() - - def context_menu_requested(self, event): - pos = QPoint(event.x(), event.y()) - menu = QMenu(self) - - actions = [] - action_title = create_action(self, _('Go to step: '), icon=QIcon()) - action_title.setDisabled(True) - actions.append(action_title) -# actions.append(create_action(self, _(': '), icon=QIcon())) - - add_actions(menu, actions) - - menu.popup(self.mapToGlobal(pos)) - - def reject(self): - """Qt method to handle escape key event""" - if not self.is_fade_running(): - key = Qt.Key_Escape - self.key_pressed = key - self.sig_key_pressed.emit() - - -class AnimatedTour(QWidget): - """Widget to display an interactive tour.""" - - def __init__(self, parent): - QWidget.__init__(self, parent) - - self.parent = parent - - # Variables to adjust - self.duration_canvas = [666, 666] - self.duration_tips = [333, 333] - self.opacity_canvas = [0.0, 0.7] - self.opacity_tips = [0.0, 1.0] - self.color = Qt.black - self.easing_curve = [QEasingCurve.Linear] - - self.current_step = 0 - self.step_current = 0 - self.steps = 0 - self.canvas = None - self.tips = None - self.frames = None - self.spy_window = None - self.initial_fullscreen_state = None - - self.widgets = None - self.dockwidgets = None - self.decoration = None - self.run = None - - self.is_tour_set = False - self.is_running = False - - # Widgets - self.canvas = FadingCanvas(self.parent, self.opacity_canvas, - self.duration_canvas, self.easing_curve, - self.color, tour=self) - self.tips = FadingTipBox(self.parent, self.opacity_tips, - self.duration_tips, self.easing_curve, - tour=self, color_top=MAIN_TOP_COLOR, - color_back=MAIN_BG_COLOR, - combobox_background=MAIN_TOP_COLOR) - - # Widgets setup - # Needed to fix spyder-ide/spyder#2204. - self.setAttribute(Qt.WA_TransparentForMouseEvents) - - # Signals and slots - self.tips.button_next.clicked.connect(self.next_step) - self.tips.button_previous.clicked.connect(self.previous_step) - self.tips.button_close.clicked.connect(self.close_tour) - self.tips.button_run.clicked.connect(self.run_code) - self.tips.button_home.clicked.connect(self.first_step) - self.tips.button_end.clicked.connect(self.last_step) - self.tips.button_run.clicked.connect( - lambda: self.tips.button_run.setDisabled(True)) - self.tips.combo_title.currentIndexChanged.connect(self.go_to_step) - - # Main window move or resize - self.parent.sig_resized.connect(self._resized) - self.parent.sig_moved.connect(self._moved) - - # To capture the arrow keys that allow moving the tour - self.tips.sig_key_pressed.connect(self._key_pressed) - - # To control the focus of tour - self.setting_data = False - self.hidden = False - - def _resized(self, event): - if self.is_running: - geom = self.parent.geometry() - self.canvas.setFixedSize(geom.width(), geom.height()) - self.canvas.update_canvas() - - if self.is_tour_set: - self._set_data() - - def _moved(self, event): - if self.is_running: - geom = self.parent.geometry() - self.canvas.move(geom.x(), geom.y()) - - if self.is_tour_set: - self._set_data() - - def _close_canvas(self): - self.tips.hide() - self.canvas.fade_out(self.canvas.hide) - - def _clear_canvas(self): - # TODO: Add option to also make it white... might be useful? - # Make canvas black before transitions - self.canvas.update_widgets(None) - self.canvas.update_decoration(None) - self.canvas.update_canvas() - - def _move_step(self): - self._set_data() - - # Show/raise the widget so it is located first! - widgets = self.dockwidgets - if widgets is not None: - widget = widgets[0] - if widget is not None: - widget.show() - widget.raise_() - - self._locate_tip_box() - - # Change in canvas only after fadein finishes, for visual aesthetics - self.tips.fade_in(self.canvas.update_canvas) - self.tips.raise_() - - def _set_modal(self, value, widgets): - platform = sys.platform.lower() - - if 'linux' in platform: - pass - elif 'win' in platform: - for widget in widgets: - widget.setModal(value) - widget.hide() - widget.show() - elif 'darwin' in platform: - pass - else: - pass - - def _process_widgets(self, names, spy_window): - widgets = [] - dockwidgets = [] - - for name in names: - try: - base = name.split('.')[0] - try: - temp = getattr(spy_window, name) - except AttributeError: - temp = None - # Check if it is the current editor - if 'get_current_editor()' in name: - temp = temp.get_current_editor() - temp = getattr(temp, name.split('.')[-1]) - if temp is None: - raise - except AttributeError: - temp = eval(f"spy_window.{name}") - - widgets.append(temp) - - # Check if it is a dockwidget and make the widget a dockwidget - # If not return the same widget - temp = getattr(temp, 'dockwidget', temp) - dockwidgets.append(temp) - - return widgets, dockwidgets - - def _set_data(self): - """Set data that is displayed in each step of the tour.""" - self.setting_data = True - step, steps, frames = self.step_current, self.steps, self.frames - current = '{0}/{1}'.format(step + 1, steps) - frame = frames[step] - - combobox_frames = [u"{0}. {1}".format(i+1, f['title']) - for i, f in enumerate(frames)] - - title, content, image = '', '', None - widgets, dockwidgets, decoration = None, None, None - run = None - - # Check if entry exists in dic and act accordingly - if 'title' in frame: - title = frame['title'] - - if 'content' in frame: - content = frame['content'] - - if 'widgets' in frame: - widget_names = frames[step]['widgets'] - # Get the widgets based on their name - widgets, dockwidgets = self._process_widgets(widget_names, - self.spy_window) - self.widgets = widgets - self.dockwidgets = dockwidgets - - if 'decoration' in frame: - widget_names = frames[step]['decoration'] - deco, decoration = self._process_widgets(widget_names, - self.spy_window) - self.decoration = decoration - - if 'image' in frame: - image = frames[step]['image'] - - if 'interact' in frame: - self.canvas.set_interaction(frame['interact']) - if frame['interact']: - self._set_modal(False, [self.tips]) - else: - self._set_modal(True, [self.tips]) - else: - self.canvas.set_interaction(False) - self._set_modal(True, [self.tips]) - - if 'run' in frame: - # Assume that the first widget is the console - run = frame['run'] - self.run = run - - self.tips.set_data(title, content, current, image, run, - frames=combobox_frames, step=step) - self._check_buttons() - - # Make canvas black when starting a new place of decoration - self.canvas.update_widgets(dockwidgets) - self.canvas.update_decoration(decoration) - self.setting_data = False - - def _locate_tip_box(self): - dockwidgets = self.dockwidgets - - # Store the dimensions of the main window - geo = self.parent.frameGeometry() - x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() - self.width_main = width - self.height_main = height - self.x_main = x - self.y_main = y - - delta = 20 - offset = 10 - - # Here is the tricky part to define the best position for the - # tip widget - if dockwidgets is not None: - if dockwidgets[0] is not None: - geo = dockwidgets[0].geometry() - x, y, width, height = (geo.x(), geo.y(), - geo.width(), geo.height()) - - point = dockwidgets[0].mapToGlobal(QPoint(0, 0)) - x_glob, y_glob = point.x(), point.y() - - # Put tip to the opposite side of the pane - if x < self.tips.width(): - x = x_glob + width + delta - y = y_glob + height/2 - self.tips.height()/2 - else: - x = x_glob - self.tips.width() - delta - y = y_glob + height/2 - self.tips.height()/2 - - if (y + self.tips.height()) > (self.y_main + self.height_main): - y = ( - y - - (y + self.tips.height() - ( - self.y_main + self.height_main)) - offset - ) - else: - # Center on parent - x = self.x_main + self.width_main/2 - self.tips.width()/2 - y = self.y_main + self.height_main/2 - self.tips.height()/2 - - self.tips.set_pos(x, y) - - def _check_buttons(self): - step, steps = self.step_current, self.steps - self.tips.button_disable = None - - if step == 0: - self.tips.button_disable = 'previous' - - if step == steps - 1: - self.tips.button_disable = 'next' - - def _key_pressed(self): - key = self.tips.key_pressed - - if ((key == Qt.Key_Right or key == Qt.Key_Down or - key == Qt.Key_PageDown) and self.step_current != self.steps - 1): - self.next_step() - elif ((key == Qt.Key_Left or key == Qt.Key_Up or - key == Qt.Key_PageUp) and self.step_current != 0): - self.previous_step() - elif key == Qt.Key_Escape: - self.close_tour() - elif key == Qt.Key_Home and self.step_current != 0: - self.first_step() - elif key == Qt.Key_End and self.step_current != self.steps - 1: - self.last_step() - elif key == Qt.Key_Menu: - pos = self.tips.label_current.pos() - self.tips.context_menu_requested(pos) - - def _hiding(self): - self.hidden = True - self.tips.hide() - - # --- public api - def run_code(self): - codelines = self.run - console = self.widgets[0] - for codeline in codelines: - console.execute_code(codeline) - - def set_tour(self, index, frames, spy_window): - self.spy_window = spy_window - self.active_tour_index = index - self.last_frame_active = frames['last'] - self.frames = frames['tour'] - self.steps = len(self.frames) - - self.is_tour_set = True - - def _handle_fullscreen(self): - if (self.spy_window.isFullScreen() or - self.spy_window.layouts._fullscreen_flag): - if sys.platform == 'darwin': - self.spy_window.setUpdatesEnabled(True) - msg_title = _("Request") - msg = _("To run the tour, please press the green button on " - "the left of the Spyder window's title bar to take " - "it out of fullscreen mode.") - QMessageBox.information(self, msg_title, msg, - QMessageBox.Ok) - return True - if self.spy_window.layouts._fullscreen_flag: - self.spy_window.layouts.toggle_fullscreen() - else: - self.spy_window.setWindowState( - self.spy_window.windowState() - & (~ Qt.WindowFullScreen)) - return False - - def start_tour(self): - self.spy_window.setUpdatesEnabled(False) - if self._handle_fullscreen(): - return - self.spy_window.layouts.save_current_window_settings( - 'layout_current_temp/', - section="quick_layouts", - ) - self.spy_window.layouts.quick_layout_switch( - DefaultLayouts.SpyderLayout) - geo = self.parent.geometry() - x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() -# self.parent_x = x -# self.parent_y = y -# self.parent_w = width -# self.parent_h = height - - # FIXME: reset step to last used value - # Reset step to beginning - self.step_current = self.last_frame_active - - # Adjust the canvas size to match the main window size - self.canvas.setFixedSize(width, height) - self.canvas.move(QPoint(x, y)) - self.spy_window.setUpdatesEnabled(True) - self.canvas.fade_in(self._move_step) - self._clear_canvas() - - self.is_running = True - - def close_tour(self): - self.tips.fade_out(self._close_canvas) - self.spy_window.setUpdatesEnabled(False) - self.canvas.set_interaction(False) - self._set_modal(True, [self.tips]) - self.canvas.hide() - - try: - # set the last played frame by updating the available tours in - # parent. This info will be lost on restart. - self.parent.tours_available[self.active_tour_index]['last'] =\ - self.step_current - except Exception: - pass - - self.is_running = False - self.spy_window.layouts.quick_layout_switch('current_temp') - self.spy_window.setUpdatesEnabled(True) - - def hide_tips(self): - """Hide tips dialog when the main window loses focus.""" - self._clear_canvas() - self.tips.fade_out(self._hiding) - - def unhide_tips(self): - """Unhide tips dialog when the main window loses focus.""" - self._clear_canvas() - self._move_step() - self.hidden = False - - def next_step(self): - self._clear_canvas() - self.step_current += 1 - self.tips.fade_out(self._move_step) - - def previous_step(self): - self._clear_canvas() - self.step_current -= 1 - self.tips.fade_out(self._move_step) - - def go_to_step(self, number, id_=None): - self._clear_canvas() - self.step_current = number - self.tips.fade_out(self._move_step) - - def last_step(self): - self.go_to_step(self.steps - 1) - - def first_step(self): - self.go_to_step(0) - - def lost_focus(self): - """Confirm if the tour loses focus and hides the tips.""" - if (self.is_running and - not self.setting_data and not self.hidden): - if sys.platform == 'darwin': - if not self.tour_has_focus(): - self.hide_tips() - if not self.any_has_focus(): - self.close_tour() - else: - if not self.any_has_focus(): - self.hide_tips() - - def gain_focus(self): - """Confirm if the tour regains focus and unhides the tips.""" - if (self.is_running and self.any_has_focus() and - not self.setting_data and self.hidden): - self.unhide_tips() - - def any_has_focus(self): - """Returns True if tour or main window has focus.""" - f = (self.hasFocus() or self.parent.hasFocus() or - self.tour_has_focus() or self.isActiveWindow()) - return f - - def tour_has_focus(self): - """Returns true if tour or any of its components has focus.""" - f = (self.tips.hasFocus() or self.canvas.hasFocus() or - self.tips.isActiveWindow()) - return f - - -class OpenTourDialog(QDialog): - """Initial widget with tour.""" - - def __init__(self, parent, tour_function): - super().__init__(parent) - if MAC: - flags = (self.windowFlags() | Qt.WindowStaysOnTopHint - & ~Qt.WindowContextHelpButtonHint) - else: - flags = self.windowFlags() & ~Qt.WindowContextHelpButtonHint - self.setWindowFlags(flags) - self.tour_function = tour_function - - # Image - images_layout = QHBoxLayout() - icon_filename = 'tour-spyder-logo' - image_path = get_image_path(icon_filename) - image = QPixmap(image_path) - image_label = QLabel() - image_height = int(image.height() * DialogStyle.IconScaleFactor) - image_width = int(image.width() * DialogStyle.IconScaleFactor) - image = image.scaled(image_width, image_height, Qt.KeepAspectRatio, - Qt.SmoothTransformation) - image_label.setPixmap(image) - - images_layout.addStretch() - images_layout.addWidget(image_label) - images_layout.addStretch() - if MAC: - images_layout.setContentsMargins(0, -5, 20, 0) - else: - images_layout.setContentsMargins(0, -8, 35, 0) - - # Label - tour_label_title = QLabel(_("Welcome to Spyder!")) - tour_label_title.setStyleSheet(f"font-size: {DialogStyle.TitleFontSize}") - tour_label_title.setWordWrap(True) - tour_label = QLabel( - _("Check out our interactive tour to " - "explore some of Spyder's panes and features.")) - tour_label.setStyleSheet(f"font-size: {DialogStyle.ContentFontSize}") - tour_label.setWordWrap(True) - tour_label.setFixedWidth(340) - - # Buttons - buttons_layout = QHBoxLayout() - dialog_tour_color = QStylePalette.COLOR_BACKGROUND_2 - start_tour_color = QStylePalette.COLOR_ACCENT_2 - start_tour_hover = QStylePalette.COLOR_ACCENT_3 - start_tour_pressed = QStylePalette.COLOR_ACCENT_4 - dismiss_tour_color = QStylePalette.COLOR_BACKGROUND_4 - dismiss_tour_hover = QStylePalette.COLOR_BACKGROUND_5 - dismiss_tour_pressed = QStylePalette.COLOR_BACKGROUND_6 - font_color = QStylePalette.COLOR_TEXT_1 - self.launch_tour_button = QPushButton(_('Start tour')) - self.launch_tour_button.setStyleSheet(( - "QPushButton {{ " - "background-color: {background_color};" - "border-color: {border_color};" - "font-size: {font_size};" - "color: {font_color};" - "padding: {padding}}}" - "QPushButton:hover:!pressed {{ " - "background-color: {color_hover}}}" - "QPushButton:pressed {{ " - "background-color: {color_pressed}}}" - ).format(background_color=start_tour_color, - border_color=start_tour_color, - font_size=DialogStyle.ButtonsFontSize, - font_color=font_color, - padding=DialogStyle.ButtonsPadding, - color_hover=start_tour_hover, - color_pressed=start_tour_pressed)) - self.launch_tour_button.setAutoDefault(False) - self.dismiss_button = QPushButton(_('Dismiss')) - self.dismiss_button.setStyleSheet(( - "QPushButton {{ " - "background-color: {background_color};" - "border-color: {border_color};" - "font-size: {font_size};" - "color: {font_color};" - "padding: {padding}}}" - "QPushButton:hover:!pressed {{ " - "background-color: {color_hover}}}" - "QPushButton:pressed {{ " - "background-color: {color_pressed}}}" - ).format(background_color=dismiss_tour_color, - border_color=dismiss_tour_color, - font_size=DialogStyle.ButtonsFontSize, - font_color=font_color, - padding=DialogStyle.ButtonsPadding, - color_hover=dismiss_tour_hover, - color_pressed=dismiss_tour_pressed)) - self.dismiss_button.setAutoDefault(False) - - buttons_layout.addStretch() - buttons_layout.addWidget(self.launch_tour_button) - if not MAC: - buttons_layout.addSpacing(10) - buttons_layout.addWidget(self.dismiss_button) - - layout = QHBoxLayout() - layout.addLayout(images_layout) - - label_layout = QVBoxLayout() - label_layout.addWidget(tour_label_title) - if not MAC: - label_layout.addSpacing(3) - label_layout.addWidget(tour_label) - else: - label_layout.addWidget(tour_label) - label_layout.addSpacing(10) - - vertical_layout = QVBoxLayout() - if not MAC: - vertical_layout.addStretch() - vertical_layout.addLayout(label_layout) - vertical_layout.addSpacing(20) - vertical_layout.addLayout(buttons_layout) - vertical_layout.addStretch() - else: - vertical_layout.addLayout(label_layout) - vertical_layout.addLayout(buttons_layout) - - general_layout = QHBoxLayout() - if not MAC: - general_layout.addStretch() - general_layout.addLayout(layout) - general_layout.addSpacing(1) - general_layout.addLayout(vertical_layout) - general_layout.addStretch() - else: - general_layout.addLayout(layout) - general_layout.addLayout(vertical_layout) - - self.setLayout(general_layout) - - self.launch_tour_button.clicked.connect(self._start_tour) - self.dismiss_button.clicked.connect(self.close) - self.setStyleSheet(f"background-color:{dialog_tour_color}") - self.setContentsMargins(18, 40, 18, 40) - if not MAC: - self.setFixedSize(640, 280) - - def _start_tour(self): - self.close() - self.tour_function() - - -# ---------------------------------------------------------------------------- -# Used for testing the functionality -# ---------------------------------------------------------------------------- - -class TourTestWindow(QMainWindow): - """ """ - sig_resized = Signal("QResizeEvent") - sig_moved = Signal("QMoveEvent") - - def __init__(self): - super(TourTestWindow, self).__init__() - self.setGeometry(300, 100, 400, 600) - self.setWindowTitle('Exploring QMainWindow') - - self.exit = QAction('Exit', self) - self.exit.setStatusTip('Exit program') - - # create the menu bar - menubar = self.menuBar() - file_ = menubar.addMenu('&File') - file_.addAction(self.exit) - - # create the status bar - self.statusBar() - - # QWidget or its instance needed for box layout - self.widget = QWidget(self) - - self.button = QPushButton('test') - self.button1 = QPushButton('1') - self.button2 = QPushButton('2') - - effect = QGraphicsOpacityEffect(self.button2) - self.button2.setGraphicsEffect(effect) - self.anim = QPropertyAnimation(effect, to_binary_string("opacity")) - self.anim.setStartValue(0.01) - self.anim.setEndValue(1.0) - self.anim.setDuration(500) - - lay = QVBoxLayout() - lay.addWidget(self.button) - lay.addStretch() - lay.addWidget(self.button1) - lay.addWidget(self.button2) - - self.widget.setLayout(lay) - - self.setCentralWidget(self.widget) - self.button.clicked.connect(self.action1) - self.button1.clicked.connect(self.action2) - - self.tour = AnimatedTour(self) - - def action1(self): - frames = get_tour('test') - index = 0 - dic = {'last': 0, 'tour': frames} - self.tour.set_tour(index, dic, self) - self.tour.start_tour() - - def action2(self): - self.anim.start() - - def resizeEvent(self, event): - """Reimplement Qt method""" - QMainWindow.resizeEvent(self, event) - self.sig_resized.emit(event) - - def moveEvent(self, event): - """Reimplement Qt method""" - QMainWindow.moveEvent(self, event) - self.sig_moved.emit(event) - - -def local_test(): - from spyder.utils.qthelpers import qapplication - - app = QApplication([]) - win = TourTestWindow() - win.show() - app.exec_() - - -if __name__ == '__main__': - local_test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Spyder interactive tours""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from math import ceil +import sys + +# Third party imports +from qtpy.QtCore import (QEasingCurve, QPoint, QPropertyAnimation, QRectF, Qt, + Signal) +from qtpy.QtGui import (QBrush, QColor, QIcon, QPainter, QPainterPath, QPen, + QPixmap, QRegion) +from qtpy.QtWidgets import (QAction, QApplication, QComboBox, QDialog, + QGraphicsOpacityEffect, QHBoxLayout, QLabel, + QLayout, QMainWindow, QMenu, QMessageBox, + QPushButton, QSpacerItem, QToolButton, QVBoxLayout, + QWidget) + +# Local imports +from spyder import __docs_url__ +from spyder.api.panel import Panel +from spyder.api.translations import get_translation +from spyder.config.base import _ +from spyder.plugins.layout.layouts import DefaultLayouts +from spyder.py3compat import to_binary_string +from spyder.utils.icon_manager import ima +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.palette import QStylePalette, SpyderPalette +from spyder.utils.qthelpers import add_actions, create_action +from spyder.utils.stylesheet import DialogStyle + +MAIN_TOP_COLOR = MAIN_BG_COLOR = QColor(QStylePalette.COLOR_BACKGROUND_1) + +# Localization +_ = get_translation('spyder') + +MAC = sys.platform == 'darwin' + + +class FadingDialog(QDialog): + """A general fade in/fade out QDialog with some builtin functions""" + sig_key_pressed = Signal() + + def __init__(self, parent, opacity, duration, easing_curve): + super(FadingDialog, self).__init__(parent) + + self.parent = parent + self.opacity_min = min(opacity) + self.opacity_max = max(opacity) + self.duration_fadein = duration[0] + self.duration_fadeout = duration[-1] + self.easing_curve_in = easing_curve[0] + self.easing_curve_out = easing_curve[-1] + self.effect = None + self.anim = None + + self._fade_running = False + self._funcs_before_fade_in = [] + self._funcs_after_fade_in = [] + self._funcs_before_fade_out = [] + self._funcs_after_fade_out = [] + + self.setModal(False) + + def _run(self, funcs): + for func in funcs: + func() + + def _run_before_fade_in(self): + self._run(self._funcs_before_fade_in) + + def _run_after_fade_in(self): + self._run(self._funcs_after_fade_in) + + def _run_before_fade_out(self): + self._run(self._funcs_before_fade_out) + + def _run_after_fade_out(self): + self._run(self._funcs_after_fade_out) + + def _set_fade_finished(self): + self._fade_running = False + + def _fade_setup(self): + self._fade_running = True + self.effect = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self.effect) + self.anim = QPropertyAnimation( + self.effect, to_binary_string("opacity")) + + # --- public api + def fade_in(self, on_finished_connect): + self._run_before_fade_in() + self._fade_setup() + self.show() + self.raise_() + self.anim.setEasingCurve(self.easing_curve_in) + self.anim.setStartValue(self.opacity_min) + self.anim.setEndValue(self.opacity_max) + self.anim.setDuration(self.duration_fadein) + self.anim.finished.connect(on_finished_connect) + self.anim.finished.connect(self._set_fade_finished) + self.anim.finished.connect(self._run_after_fade_in) + self.anim.start() + + def fade_out(self, on_finished_connect): + self._run_before_fade_out() + self._fade_setup() + self.anim.setEasingCurve(self.easing_curve_out) + self.anim.setStartValue(self.opacity_max) + self.anim.setEndValue(self.opacity_min) + self.anim.setDuration(self.duration_fadeout) + self.anim.finished.connect(on_finished_connect) + self.anim.finished.connect(self._set_fade_finished) + self.anim.finished.connect(self._run_after_fade_out) + self.anim.start() + + def is_fade_running(self): + return self._fade_running + + def set_funcs_before_fade_in(self, funcs): + self._funcs_before_fade_in = funcs + + def set_funcs_after_fade_in(self, funcs): + self._funcs_after_fade_in = funcs + + def set_funcs_before_fade_out(self, funcs): + self._funcs_before_fade_out = funcs + + def set_funcs_after_fade_out(self, funcs): + self._funcs_after_fade_out = funcs + + +class FadingCanvas(FadingDialog): + """The black semi transparent canvas that covers the application""" + def __init__(self, parent, opacity, duration, easing_curve, color, + tour=None): + """Create a black semi transparent canvas that covers the app.""" + super(FadingCanvas, self).__init__(parent, opacity, duration, + easing_curve) + self.parent = parent + self.tour = tour + + # Canvas color + self.color = color + # Decoration color + self.color_decoration = QColor(SpyderPalette.COLOR_ERROR_2) + # Width in pixels for decoration + self.stroke_decoration = 2 + + self.region_mask = None + self.region_subtract = None + self.region_decoration = None + + self.widgets = None # The widget to uncover + self.decoration = None # The widget to draw decoration + self.interaction_on = False + + self.path_current = None + self.path_subtract = None + self.path_full = None + self.path_decoration = None + + # widget setup + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WA_TransparentForMouseEvents) + self.setModal(False) + self.setFocusPolicy(Qt.NoFocus) + + self.set_funcs_before_fade_in([self.update_canvas]) + self.set_funcs_after_fade_out([lambda: self.update_widgets(None), + lambda: self.update_decoration(None)]) + + def set_interaction(self, value): + self.interaction_on = value + + def update_canvas(self): + w, h = self.parent.size().width(), self.parent.size().height() + + self.path_full = QPainterPath() + self.path_subtract = QPainterPath() + self.path_decoration = QPainterPath() + self.region_mask = QRegion(0, 0, w, h) + + self.path_full.addRect(0, 0, w, h) + # Add the path + if self.widgets is not None: + for widget in self.widgets: + temp_path = QPainterPath() + # if widget is not found... find more general way to handle + if widget is not None: + widget.raise_() + widget.show() + geo = widget.frameGeometry() + width, height = geo.width(), geo.height() + point = widget.mapTo(self.parent, QPoint(0, 0)) + x, y = point.x(), point.y() + + temp_path.addRect(QRectF(x, y, width, height)) + + temp_region = QRegion(x, y, width, height) + + if self.interaction_on: + self.region_mask = self.region_mask.subtracted(temp_region) + self.path_subtract = self.path_subtract.united(temp_path) + + self.path_current = self.path_full.subtracted(self.path_subtract) + else: + self.path_current = self.path_full + if self.decoration is not None: + for widgets in self.decoration: + if isinstance(widgets, QWidget): + widgets = [widgets] + geoms = [] + for widget in widgets: + widget.raise_() + widget.show() + geo = widget.frameGeometry() + width, height = geo.width(), geo.height() + point = widget.mapTo(self.parent, QPoint(0, 0)) + x, y = point.x(), point.y() + geoms.append((x, y, width, height)) + x = min([geom[0] for geom in geoms]) + y = min([geom[1] for geom in geoms]) + width = max([ + geom[0] + geom[2] for geom in geoms]) - x + height = max([ + geom[1] + geom[3] for geom in geoms]) - y + temp_path = QPainterPath() + temp_path.addRect(QRectF(x, y, width, height)) + + temp_region_1 = QRegion(x-1, y-1, width+2, height+2) + temp_region_2 = QRegion(x+1, y+1, width-2, height-2) + temp_region = temp_region_1.subtracted(temp_region_2) + + if self.interaction_on: + self.region_mask = self.region_mask.united(temp_region) + + self.path_decoration = self.path_decoration.united(temp_path) + else: + self.path_decoration.addRect(0, 0, 0, 0) + + # Add a decoration stroke around widget + self.setMask(self.region_mask) + self.update() + self.repaint() + + def update_widgets(self, widgets): + self.widgets = widgets + + def update_decoration(self, widgets): + self.decoration = widgets + + def paintEvent(self, event): + """Override Qt method""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + # Decoration + painter.fillPath(self.path_current, QBrush(self.color)) + painter.strokePath(self.path_decoration, QPen(self.color_decoration, + self.stroke_decoration)) +# decoration_fill = QColor(self.color_decoration) +# decoration_fill.setAlphaF(0.25) +# painter.fillPath(self.path_decoration, decoration_fill) + + def reject(self): + """Override Qt method""" + if not self.is_fade_running(): + key = Qt.Key_Escape + self.key_pressed = key + self.sig_key_pressed.emit() + + def mousePressEvent(self, event): + """Override Qt method""" + pass + + def focusInEvent(self, event): + """Override Qt method.""" + # To be used so tips do not appear outside spyder + if self.hasFocus(): + self.tour.gain_focus() + + def focusOutEvent(self, event): + """Override Qt method.""" + # To be used so tips do not appear outside spyder + if self.tour.step_current != 0: + self.tour.lost_focus() + + +class FadingTipBox(FadingDialog): + """Dialog that contains the text for each frame in the tour.""" + def __init__(self, parent, opacity, duration, easing_curve, tour=None, + color_top=None, color_back=None, combobox_background=None): + super(FadingTipBox, self).__init__(parent, opacity, duration, + easing_curve) + self.holder = self.anim # needed for qt to work + self.parent = parent + self.tour = tour + + self.frames = None + self.offset_shadow = 0 + self.fixed_width = 300 + + self.key_pressed = None + + self.setAttribute(Qt.WA_TranslucentBackground) + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint) + self.setModal(False) + + # Widgets + def toolbutton(icon): + bt = QToolButton() + bt.setAutoRaise(True) + bt.setIcon(icon) + return bt + + self.button_close = toolbutton(ima.icon("tour.close")) + self.button_home = toolbutton(ima.icon("tour.home")) + self.button_previous = toolbutton(ima.icon("tour.previous")) + self.button_end = toolbutton(ima.icon("tour.end")) + self.button_next = toolbutton(ima.icon("tour.next")) + self.button_run = QPushButton(_('Run code')) + self.button_disable = None + self.button_current = QToolButton() + self.label_image = QLabel() + + self.label_title = QLabel() + self.combo_title = QComboBox() + self.label_current = QLabel() + self.label_content = QLabel() + + self.label_content.setOpenExternalLinks(True) + self.label_content.setMinimumWidth(self.fixed_width) + self.label_content.setMaximumWidth(self.fixed_width) + + self.label_current.setAlignment(Qt.AlignCenter) + + self.label_content.setWordWrap(True) + + self.widgets = [self.label_content, self.label_title, + self.label_current, self.combo_title, + self.button_close, self.button_run, self.button_next, + self.button_previous, self.button_end, + self.button_home, self.button_current] + + arrow = get_image_path('hide') + + self.color_top = color_top + self.color_back = color_back + self.combobox_background = combobox_background + self.stylesheet = '''QComboBox {{ + padding-left: 5px; + background-color: {} + border-width: 0px; + border-radius: 0px; + min-height:20px; + max-height:20px; + }} + + QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: top left; + border-width: 0px; + }} + + QComboBox::down-arrow {{ + image: url({}); + }} + '''.format(self.combobox_background.name(), arrow) + # Windows fix, slashes should be always in unix-style + self.stylesheet = self.stylesheet.replace('\\', '/') + + self.setFocusPolicy(Qt.StrongFocus) + for widget in self.widgets: + widget.setFocusPolicy(Qt.NoFocus) + widget.setStyleSheet(self.stylesheet) + + layout_top = QHBoxLayout() + layout_top.addWidget(self.combo_title) + layout_top.addStretch() + layout_top.addWidget(self.button_close) + layout_top.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout_content = QHBoxLayout() + layout_content.addWidget(self.label_content) + layout_content.addWidget(self.label_image) + layout_content.addSpacerItem(QSpacerItem(5, 5)) + + layout_run = QHBoxLayout() + layout_run.addStretch() + layout_run.addWidget(self.button_run) + layout_run.addStretch() + layout_run.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout_navigation = QHBoxLayout() + layout_navigation.addWidget(self.button_home) + layout_navigation.addWidget(self.button_previous) + layout_navigation.addStretch() + layout_navigation.addWidget(self.label_current) + layout_navigation.addStretch() + layout_navigation.addWidget(self.button_next) + layout_navigation.addWidget(self.button_end) + layout_navigation.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout = QVBoxLayout() + layout.addLayout(layout_top) + layout.addStretch() + layout.addSpacerItem(QSpacerItem(15, 15)) + layout.addLayout(layout_content) + layout.addLayout(layout_run) + layout.addStretch() + layout.addSpacerItem(QSpacerItem(15, 15)) + layout.addLayout(layout_navigation) + layout.addSpacerItem(QSpacerItem(self.offset_shadow, + self.offset_shadow)) + + layout.setSizeConstraint(QLayout.SetFixedSize) + + self.setLayout(layout) + + self.set_funcs_before_fade_in([self._disable_widgets]) + self.set_funcs_after_fade_in([self._enable_widgets, self.setFocus]) + self.set_funcs_before_fade_out([self._disable_widgets]) + + self.setContextMenuPolicy(Qt.CustomContextMenu) + + # signals and slots + # These are defined every time by the AnimatedTour Class + + def _disable_widgets(self): + for widget in self.widgets: + widget.setDisabled(True) + + def _enable_widgets(self): + self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint) + for widget in self.widgets: + widget.setDisabled(False) + + if self.button_disable == 'previous': + self.button_previous.setDisabled(True) + self.button_home.setDisabled(True) + elif self.button_disable == 'next': + self.button_next.setDisabled(True) + self.button_end.setDisabled(True) + self.button_run.setDisabled(sys.platform == "darwin") + + def set_data(self, title, content, current, image, run, frames=None, + step=None): + self.label_title.setText(title) + self.combo_title.clear() + self.combo_title.addItems(frames) + self.combo_title.setCurrentIndex(step) +# min_content_len = max([len(f) for f in frames]) +# self.combo_title.setMinimumContentsLength(min_content_len) + + # Fix and try to see how it looks with a combo box + self.label_current.setText(current) + self.button_current.setText(current) + self.label_content.setText(content) + self.image = image + + if image is None: + self.label_image.setFixedHeight(1) + self.label_image.setFixedWidth(1) + else: + extension = image.split('.')[-1] + self.image = QPixmap(get_image_path(image), extension) + self.label_image.setPixmap(self.image) + self.label_image.setFixedSize(self.image.size()) + + if run is None: + self.button_run.setVisible(False) + else: + self.button_run.setVisible(True) + if sys.platform == "darwin": + self.button_run.setToolTip("Not available on macOS") + + # Refresh layout + self.layout().activate() + + def set_pos(self, x, y): + self.x = ceil(x) + self.y = ceil(y) + self.move(QPoint(self.x, self.y)) + + def build_paths(self): + geo = self.geometry() + radius = 0 + shadow = self.offset_shadow + x0, y0 = geo.x(), geo.y() + width, height = geo.width() - shadow, geo.height() - shadow + + left, top = 0, 0 + right, bottom = width, height + + self.round_rect_path = QPainterPath() + self.round_rect_path.moveTo(right, top + radius) + self.round_rect_path.arcTo(right-radius, top, radius, radius, 0.0, + 90.0) + self.round_rect_path.lineTo(left+radius, top) + self.round_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) + self.round_rect_path.lineTo(left, bottom-radius) + self.round_rect_path.arcTo(left, bottom-radius, radius, radius, 180.0, + 90.0) + self.round_rect_path.lineTo(right-radius, bottom) + self.round_rect_path.arcTo(right-radius, bottom-radius, radius, radius, + 270.0, 90.0) + self.round_rect_path.closeSubpath() + + # Top path + header = 36 + offset = 2 + left, top = offset, offset + right = width - (offset) + self.top_rect_path = QPainterPath() + self.top_rect_path.lineTo(right, top + radius) + self.top_rect_path.moveTo(right, top + radius) + self.top_rect_path.arcTo(right-radius, top, radius, radius, 0.0, 90.0) + self.top_rect_path.lineTo(left+radius, top) + self.top_rect_path.arcTo(left, top, radius, radius, 90.0, 90.0) + self.top_rect_path.lineTo(left, top + header) + self.top_rect_path.lineTo(right, top + header) + + def paintEvent(self, event): + """Override Qt method.""" + self.build_paths() + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + painter.fillPath(self.round_rect_path, self.color_back) + painter.fillPath(self.top_rect_path, self.color_top) + painter.strokePath(self.round_rect_path, QPen(Qt.gray, 1)) + + # TODO: Build the pointing arrow? + + def keyReleaseEvent(self, event): + """Override Qt method.""" + key = event.key() + self.key_pressed = key + + keys = [Qt.Key_Right, Qt.Key_Left, Qt.Key_Down, Qt.Key_Up, + Qt.Key_Escape, Qt.Key_PageUp, Qt.Key_PageDown, + Qt.Key_Home, Qt.Key_End, Qt.Key_Menu] + + if key in keys: + if not self.is_fade_running(): + self.sig_key_pressed.emit() + + def mousePressEvent(self, event): + """Override Qt method.""" + # Raise the main application window on click + self.parent.raise_() + self.raise_() + + if event.button() == Qt.RightButton: + pass +# clicked_widget = self.childAt(event.x(), event.y()) +# if clicked_widget == self.label_current: +# self.context_menu_requested(event) + + def focusOutEvent(self, event): + """Override Qt method.""" + # To be used so tips do not appear outside spyder + self.tour.lost_focus() + + def context_menu_requested(self, event): + pos = QPoint(event.x(), event.y()) + menu = QMenu(self) + + actions = [] + action_title = create_action(self, _('Go to step: '), icon=QIcon()) + action_title.setDisabled(True) + actions.append(action_title) +# actions.append(create_action(self, _(': '), icon=QIcon())) + + add_actions(menu, actions) + + menu.popup(self.mapToGlobal(pos)) + + def reject(self): + """Qt method to handle escape key event""" + if not self.is_fade_running(): + key = Qt.Key_Escape + self.key_pressed = key + self.sig_key_pressed.emit() + + +class AnimatedTour(QWidget): + """Widget to display an interactive tour.""" + + def __init__(self, parent): + QWidget.__init__(self, parent) + + self.parent = parent + + # Variables to adjust + self.duration_canvas = [666, 666] + self.duration_tips = [333, 333] + self.opacity_canvas = [0.0, 0.7] + self.opacity_tips = [0.0, 1.0] + self.color = Qt.black + self.easing_curve = [QEasingCurve.Linear] + + self.current_step = 0 + self.step_current = 0 + self.steps = 0 + self.canvas = None + self.tips = None + self.frames = None + self.spy_window = None + self.initial_fullscreen_state = None + + self.widgets = None + self.dockwidgets = None + self.decoration = None + self.run = None + + self.is_tour_set = False + self.is_running = False + + # Widgets + self.canvas = FadingCanvas(self.parent, self.opacity_canvas, + self.duration_canvas, self.easing_curve, + self.color, tour=self) + self.tips = FadingTipBox(self.parent, self.opacity_tips, + self.duration_tips, self.easing_curve, + tour=self, color_top=MAIN_TOP_COLOR, + color_back=MAIN_BG_COLOR, + combobox_background=MAIN_TOP_COLOR) + + # Widgets setup + # Needed to fix spyder-ide/spyder#2204. + self.setAttribute(Qt.WA_TransparentForMouseEvents) + + # Signals and slots + self.tips.button_next.clicked.connect(self.next_step) + self.tips.button_previous.clicked.connect(self.previous_step) + self.tips.button_close.clicked.connect(self.close_tour) + self.tips.button_run.clicked.connect(self.run_code) + self.tips.button_home.clicked.connect(self.first_step) + self.tips.button_end.clicked.connect(self.last_step) + self.tips.button_run.clicked.connect( + lambda: self.tips.button_run.setDisabled(True)) + self.tips.combo_title.currentIndexChanged.connect(self.go_to_step) + + # Main window move or resize + self.parent.sig_resized.connect(self._resized) + self.parent.sig_moved.connect(self._moved) + + # To capture the arrow keys that allow moving the tour + self.tips.sig_key_pressed.connect(self._key_pressed) + + # To control the focus of tour + self.setting_data = False + self.hidden = False + + def _resized(self, event): + if self.is_running: + geom = self.parent.geometry() + self.canvas.setFixedSize(geom.width(), geom.height()) + self.canvas.update_canvas() + + if self.is_tour_set: + self._set_data() + + def _moved(self, event): + if self.is_running: + geom = self.parent.geometry() + self.canvas.move(geom.x(), geom.y()) + + if self.is_tour_set: + self._set_data() + + def _close_canvas(self): + self.tips.hide() + self.canvas.fade_out(self.canvas.hide) + + def _clear_canvas(self): + # TODO: Add option to also make it white... might be useful? + # Make canvas black before transitions + self.canvas.update_widgets(None) + self.canvas.update_decoration(None) + self.canvas.update_canvas() + + def _move_step(self): + self._set_data() + + # Show/raise the widget so it is located first! + widgets = self.dockwidgets + if widgets is not None: + widget = widgets[0] + if widget is not None: + widget.show() + widget.raise_() + + self._locate_tip_box() + + # Change in canvas only after fadein finishes, for visual aesthetics + self.tips.fade_in(self.canvas.update_canvas) + self.tips.raise_() + + def _set_modal(self, value, widgets): + platform = sys.platform.lower() + + if 'linux' in platform: + pass + elif 'win' in platform: + for widget in widgets: + widget.setModal(value) + widget.hide() + widget.show() + elif 'darwin' in platform: + pass + else: + pass + + def _process_widgets(self, names, spy_window): + widgets = [] + dockwidgets = [] + + for name in names: + try: + base = name.split('.')[0] + try: + temp = getattr(spy_window, name) + except AttributeError: + temp = None + # Check if it is the current editor + if 'get_current_editor()' in name: + temp = temp.get_current_editor() + temp = getattr(temp, name.split('.')[-1]) + if temp is None: + raise + except AttributeError: + temp = eval(f"spy_window.{name}") + + widgets.append(temp) + + # Check if it is a dockwidget and make the widget a dockwidget + # If not return the same widget + temp = getattr(temp, 'dockwidget', temp) + dockwidgets.append(temp) + + return widgets, dockwidgets + + def _set_data(self): + """Set data that is displayed in each step of the tour.""" + self.setting_data = True + step, steps, frames = self.step_current, self.steps, self.frames + current = '{0}/{1}'.format(step + 1, steps) + frame = frames[step] + + combobox_frames = [u"{0}. {1}".format(i+1, f['title']) + for i, f in enumerate(frames)] + + title, content, image = '', '', None + widgets, dockwidgets, decoration = None, None, None + run = None + + # Check if entry exists in dic and act accordingly + if 'title' in frame: + title = frame['title'] + + if 'content' in frame: + content = frame['content'] + + if 'widgets' in frame: + widget_names = frames[step]['widgets'] + # Get the widgets based on their name + widgets, dockwidgets = self._process_widgets(widget_names, + self.spy_window) + self.widgets = widgets + self.dockwidgets = dockwidgets + + if 'decoration' in frame: + widget_names = frames[step]['decoration'] + deco, decoration = self._process_widgets(widget_names, + self.spy_window) + self.decoration = decoration + + if 'image' in frame: + image = frames[step]['image'] + + if 'interact' in frame: + self.canvas.set_interaction(frame['interact']) + if frame['interact']: + self._set_modal(False, [self.tips]) + else: + self._set_modal(True, [self.tips]) + else: + self.canvas.set_interaction(False) + self._set_modal(True, [self.tips]) + + if 'run' in frame: + # Assume that the first widget is the console + run = frame['run'] + self.run = run + + self.tips.set_data(title, content, current, image, run, + frames=combobox_frames, step=step) + self._check_buttons() + + # Make canvas black when starting a new place of decoration + self.canvas.update_widgets(dockwidgets) + self.canvas.update_decoration(decoration) + self.setting_data = False + + def _locate_tip_box(self): + dockwidgets = self.dockwidgets + + # Store the dimensions of the main window + geo = self.parent.frameGeometry() + x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() + self.width_main = width + self.height_main = height + self.x_main = x + self.y_main = y + + delta = 20 + offset = 10 + + # Here is the tricky part to define the best position for the + # tip widget + if dockwidgets is not None: + if dockwidgets[0] is not None: + geo = dockwidgets[0].geometry() + x, y, width, height = (geo.x(), geo.y(), + geo.width(), geo.height()) + + point = dockwidgets[0].mapToGlobal(QPoint(0, 0)) + x_glob, y_glob = point.x(), point.y() + + # Put tip to the opposite side of the pane + if x < self.tips.width(): + x = x_glob + width + delta + y = y_glob + height/2 - self.tips.height()/2 + else: + x = x_glob - self.tips.width() - delta + y = y_glob + height/2 - self.tips.height()/2 + + if (y + self.tips.height()) > (self.y_main + self.height_main): + y = ( + y + - (y + self.tips.height() - ( + self.y_main + self.height_main)) - offset + ) + else: + # Center on parent + x = self.x_main + self.width_main/2 - self.tips.width()/2 + y = self.y_main + self.height_main/2 - self.tips.height()/2 + + self.tips.set_pos(x, y) + + def _check_buttons(self): + step, steps = self.step_current, self.steps + self.tips.button_disable = None + + if step == 0: + self.tips.button_disable = 'previous' + + if step == steps - 1: + self.tips.button_disable = 'next' + + def _key_pressed(self): + key = self.tips.key_pressed + + if ((key == Qt.Key_Right or key == Qt.Key_Down or + key == Qt.Key_PageDown) and self.step_current != self.steps - 1): + self.next_step() + elif ((key == Qt.Key_Left or key == Qt.Key_Up or + key == Qt.Key_PageUp) and self.step_current != 0): + self.previous_step() + elif key == Qt.Key_Escape: + self.close_tour() + elif key == Qt.Key_Home and self.step_current != 0: + self.first_step() + elif key == Qt.Key_End and self.step_current != self.steps - 1: + self.last_step() + elif key == Qt.Key_Menu: + pos = self.tips.label_current.pos() + self.tips.context_menu_requested(pos) + + def _hiding(self): + self.hidden = True + self.tips.hide() + + # --- public api + def run_code(self): + codelines = self.run + console = self.widgets[0] + for codeline in codelines: + console.execute_code(codeline) + + def set_tour(self, index, frames, spy_window): + self.spy_window = spy_window + self.active_tour_index = index + self.last_frame_active = frames['last'] + self.frames = frames['tour'] + self.steps = len(self.frames) + + self.is_tour_set = True + + def _handle_fullscreen(self): + if (self.spy_window.isFullScreen() or + self.spy_window.layouts._fullscreen_flag): + if sys.platform == 'darwin': + self.spy_window.setUpdatesEnabled(True) + msg_title = _("Request") + msg = _("To run the tour, please press the green button on " + "the left of the Spyder window's title bar to take " + "it out of fullscreen mode.") + QMessageBox.information(self, msg_title, msg, + QMessageBox.Ok) + return True + if self.spy_window.layouts._fullscreen_flag: + self.spy_window.layouts.toggle_fullscreen() + else: + self.spy_window.setWindowState( + self.spy_window.windowState() + & (~ Qt.WindowFullScreen)) + return False + + def start_tour(self): + self.spy_window.setUpdatesEnabled(False) + if self._handle_fullscreen(): + return + self.spy_window.layouts.save_current_window_settings( + 'layout_current_temp/', + section="quick_layouts", + ) + self.spy_window.layouts.quick_layout_switch( + DefaultLayouts.SpyderLayout) + geo = self.parent.geometry() + x, y, width, height = geo.x(), geo.y(), geo.width(), geo.height() +# self.parent_x = x +# self.parent_y = y +# self.parent_w = width +# self.parent_h = height + + # FIXME: reset step to last used value + # Reset step to beginning + self.step_current = self.last_frame_active + + # Adjust the canvas size to match the main window size + self.canvas.setFixedSize(width, height) + self.canvas.move(QPoint(x, y)) + self.spy_window.setUpdatesEnabled(True) + self.canvas.fade_in(self._move_step) + self._clear_canvas() + + self.is_running = True + + def close_tour(self): + self.tips.fade_out(self._close_canvas) + self.spy_window.setUpdatesEnabled(False) + self.canvas.set_interaction(False) + self._set_modal(True, [self.tips]) + self.canvas.hide() + + try: + # set the last played frame by updating the available tours in + # parent. This info will be lost on restart. + self.parent.tours_available[self.active_tour_index]['last'] =\ + self.step_current + except Exception: + pass + + self.is_running = False + self.spy_window.layouts.quick_layout_switch('current_temp') + self.spy_window.setUpdatesEnabled(True) + + def hide_tips(self): + """Hide tips dialog when the main window loses focus.""" + self._clear_canvas() + self.tips.fade_out(self._hiding) + + def unhide_tips(self): + """Unhide tips dialog when the main window loses focus.""" + self._clear_canvas() + self._move_step() + self.hidden = False + + def next_step(self): + self._clear_canvas() + self.step_current += 1 + self.tips.fade_out(self._move_step) + + def previous_step(self): + self._clear_canvas() + self.step_current -= 1 + self.tips.fade_out(self._move_step) + + def go_to_step(self, number, id_=None): + self._clear_canvas() + self.step_current = number + self.tips.fade_out(self._move_step) + + def last_step(self): + self.go_to_step(self.steps - 1) + + def first_step(self): + self.go_to_step(0) + + def lost_focus(self): + """Confirm if the tour loses focus and hides the tips.""" + if (self.is_running and + not self.setting_data and not self.hidden): + if sys.platform == 'darwin': + if not self.tour_has_focus(): + self.hide_tips() + if not self.any_has_focus(): + self.close_tour() + else: + if not self.any_has_focus(): + self.hide_tips() + + def gain_focus(self): + """Confirm if the tour regains focus and unhides the tips.""" + if (self.is_running and self.any_has_focus() and + not self.setting_data and self.hidden): + self.unhide_tips() + + def any_has_focus(self): + """Returns True if tour or main window has focus.""" + f = (self.hasFocus() or self.parent.hasFocus() or + self.tour_has_focus() or self.isActiveWindow()) + return f + + def tour_has_focus(self): + """Returns true if tour or any of its components has focus.""" + f = (self.tips.hasFocus() or self.canvas.hasFocus() or + self.tips.isActiveWindow()) + return f + + +class OpenTourDialog(QDialog): + """Initial widget with tour.""" + + def __init__(self, parent, tour_function): + super().__init__(parent) + if MAC: + flags = (self.windowFlags() | Qt.WindowStaysOnTopHint + & ~Qt.WindowContextHelpButtonHint) + else: + flags = self.windowFlags() & ~Qt.WindowContextHelpButtonHint + self.setWindowFlags(flags) + self.tour_function = tour_function + + # Image + images_layout = QHBoxLayout() + icon_filename = 'tour-spyder-logo' + image_path = get_image_path(icon_filename) + image = QPixmap(image_path) + image_label = QLabel() + image_height = int(image.height() * DialogStyle.IconScaleFactor) + image_width = int(image.width() * DialogStyle.IconScaleFactor) + image = image.scaled(image_width, image_height, Qt.KeepAspectRatio, + Qt.SmoothTransformation) + image_label.setPixmap(image) + + images_layout.addStretch() + images_layout.addWidget(image_label) + images_layout.addStretch() + if MAC: + images_layout.setContentsMargins(0, -5, 20, 0) + else: + images_layout.setContentsMargins(0, -8, 35, 0) + + # Label + tour_label_title = QLabel(_("Welcome to Spyder!")) + tour_label_title.setStyleSheet(f"font-size: {DialogStyle.TitleFontSize}") + tour_label_title.setWordWrap(True) + tour_label = QLabel( + _("Check out our interactive tour to " + "explore some of Spyder's panes and features.")) + tour_label.setStyleSheet(f"font-size: {DialogStyle.ContentFontSize}") + tour_label.setWordWrap(True) + tour_label.setFixedWidth(340) + + # Buttons + buttons_layout = QHBoxLayout() + dialog_tour_color = QStylePalette.COLOR_BACKGROUND_2 + start_tour_color = QStylePalette.COLOR_ACCENT_2 + start_tour_hover = QStylePalette.COLOR_ACCENT_3 + start_tour_pressed = QStylePalette.COLOR_ACCENT_4 + dismiss_tour_color = QStylePalette.COLOR_BACKGROUND_4 + dismiss_tour_hover = QStylePalette.COLOR_BACKGROUND_5 + dismiss_tour_pressed = QStylePalette.COLOR_BACKGROUND_6 + font_color = QStylePalette.COLOR_TEXT_1 + self.launch_tour_button = QPushButton(_('Start tour')) + self.launch_tour_button.setStyleSheet(( + "QPushButton {{ " + "background-color: {background_color};" + "border-color: {border_color};" + "font-size: {font_size};" + "color: {font_color};" + "padding: {padding}}}" + "QPushButton:hover:!pressed {{ " + "background-color: {color_hover}}}" + "QPushButton:pressed {{ " + "background-color: {color_pressed}}}" + ).format(background_color=start_tour_color, + border_color=start_tour_color, + font_size=DialogStyle.ButtonsFontSize, + font_color=font_color, + padding=DialogStyle.ButtonsPadding, + color_hover=start_tour_hover, + color_pressed=start_tour_pressed)) + self.launch_tour_button.setAutoDefault(False) + self.dismiss_button = QPushButton(_('Dismiss')) + self.dismiss_button.setStyleSheet(( + "QPushButton {{ " + "background-color: {background_color};" + "border-color: {border_color};" + "font-size: {font_size};" + "color: {font_color};" + "padding: {padding}}}" + "QPushButton:hover:!pressed {{ " + "background-color: {color_hover}}}" + "QPushButton:pressed {{ " + "background-color: {color_pressed}}}" + ).format(background_color=dismiss_tour_color, + border_color=dismiss_tour_color, + font_size=DialogStyle.ButtonsFontSize, + font_color=font_color, + padding=DialogStyle.ButtonsPadding, + color_hover=dismiss_tour_hover, + color_pressed=dismiss_tour_pressed)) + self.dismiss_button.setAutoDefault(False) + + buttons_layout.addStretch() + buttons_layout.addWidget(self.launch_tour_button) + if not MAC: + buttons_layout.addSpacing(10) + buttons_layout.addWidget(self.dismiss_button) + + layout = QHBoxLayout() + layout.addLayout(images_layout) + + label_layout = QVBoxLayout() + label_layout.addWidget(tour_label_title) + if not MAC: + label_layout.addSpacing(3) + label_layout.addWidget(tour_label) + else: + label_layout.addWidget(tour_label) + label_layout.addSpacing(10) + + vertical_layout = QVBoxLayout() + if not MAC: + vertical_layout.addStretch() + vertical_layout.addLayout(label_layout) + vertical_layout.addSpacing(20) + vertical_layout.addLayout(buttons_layout) + vertical_layout.addStretch() + else: + vertical_layout.addLayout(label_layout) + vertical_layout.addLayout(buttons_layout) + + general_layout = QHBoxLayout() + if not MAC: + general_layout.addStretch() + general_layout.addLayout(layout) + general_layout.addSpacing(1) + general_layout.addLayout(vertical_layout) + general_layout.addStretch() + else: + general_layout.addLayout(layout) + general_layout.addLayout(vertical_layout) + + self.setLayout(general_layout) + + self.launch_tour_button.clicked.connect(self._start_tour) + self.dismiss_button.clicked.connect(self.close) + self.setStyleSheet(f"background-color:{dialog_tour_color}") + self.setContentsMargins(18, 40, 18, 40) + if not MAC: + self.setFixedSize(640, 280) + + def _start_tour(self): + self.close() + self.tour_function() + + +# ---------------------------------------------------------------------------- +# Used for testing the functionality +# ---------------------------------------------------------------------------- + +class TourTestWindow(QMainWindow): + """ """ + sig_resized = Signal("QResizeEvent") + sig_moved = Signal("QMoveEvent") + + def __init__(self): + super(TourTestWindow, self).__init__() + self.setGeometry(300, 100, 400, 600) + self.setWindowTitle('Exploring QMainWindow') + + self.exit = QAction('Exit', self) + self.exit.setStatusTip('Exit program') + + # create the menu bar + menubar = self.menuBar() + file_ = menubar.addMenu('&File') + file_.addAction(self.exit) + + # create the status bar + self.statusBar() + + # QWidget or its instance needed for box layout + self.widget = QWidget(self) + + self.button = QPushButton('test') + self.button1 = QPushButton('1') + self.button2 = QPushButton('2') + + effect = QGraphicsOpacityEffect(self.button2) + self.button2.setGraphicsEffect(effect) + self.anim = QPropertyAnimation(effect, to_binary_string("opacity")) + self.anim.setStartValue(0.01) + self.anim.setEndValue(1.0) + self.anim.setDuration(500) + + lay = QVBoxLayout() + lay.addWidget(self.button) + lay.addStretch() + lay.addWidget(self.button1) + lay.addWidget(self.button2) + + self.widget.setLayout(lay) + + self.setCentralWidget(self.widget) + self.button.clicked.connect(self.action1) + self.button1.clicked.connect(self.action2) + + self.tour = AnimatedTour(self) + + def action1(self): + frames = get_tour('test') + index = 0 + dic = {'last': 0, 'tour': frames} + self.tour.set_tour(index, dic, self) + self.tour.start_tour() + + def action2(self): + self.anim.start() + + def resizeEvent(self, event): + """Reimplement Qt method""" + QMainWindow.resizeEvent(self, event) + self.sig_resized.emit(event) + + def moveEvent(self, event): + """Reimplement Qt method""" + QMainWindow.moveEvent(self, event) + self.sig_moved.emit(event) + + +def local_test(): + from spyder.utils.qthelpers import qapplication + + app = QApplication([]) + win = TourTestWindow() + win.show() + app.exec_() + + +if __name__ == '__main__': + local_test() diff --git a/spyder/plugins/variableexplorer/api.py b/spyder/plugins/variableexplorer/api.py index 0eeaef662dd..1fdda8880d2 100644 --- a/spyder/plugins/variableexplorer/api.py +++ b/spyder/plugins/variableexplorer/api.py @@ -1,14 +1,14 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Variable Explorer Plugin API. -""" - -# Local imports -from spyder.plugins.variableexplorer.widgets.main_widget import ( - VariableExplorerWidgetActions, VariableExplorerWidgetMainToolBarSections, - VariableExplorerWidgetMenus, VariableExplorerWidgetOptionsMenuSections) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Variable Explorer Plugin API. +""" + +# Local imports +from spyder.plugins.variableexplorer.widgets.main_widget import ( + VariableExplorerWidgetActions, VariableExplorerWidgetMainToolBarSections, + VariableExplorerWidgetMenus, VariableExplorerWidgetOptionsMenuSections) diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py index f84041ef116..ff78e266b97 100644 --- a/spyder/plugins/variableexplorer/plugin.py +++ b/spyder/plugins/variableexplorer/plugin.py @@ -1,82 +1,82 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Variable Explorer Plugin. -""" - -# Local imports -from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.shellconnect.mixins import ShellConnectMixin -from spyder.api.translations import get_translation -from spyder.plugins.variableexplorer.confpage import ( - VariableExplorerConfigPage) -from spyder.plugins.variableexplorer.widgets.main_widget import ( - VariableExplorerWidget) - - -# Localization -_ = get_translation('spyder') - - -class VariableExplorer(SpyderDockablePlugin, ShellConnectMixin): - """ - Variable explorer plugin. - """ - NAME = 'variable_explorer' - REQUIRES = [Plugins.IPythonConsole, Plugins.Preferences] - TABIFY = None - WIDGET_CLASS = VariableExplorerWidget - CONF_SECTION = NAME - CONF_FILE = False - CONF_WIDGET_CLASS = VariableExplorerConfigPage - DISABLE_ACTIONS_WHEN_HIDDEN = False - - # ---- SpyderDockablePlugin API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Variable explorer') - - def get_description(self): - return _('Display, explore load and save variables in the current ' - 'namespace.') - - def get_icon(self): - return self.create_icon('dictedit') - - def on_initialize(self): - self.get_widget().sig_free_memory_requested.connect( - self.sig_free_memory_requested) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - # ---- Public API - # ------------------------------------------------------------------------ - def current_widget(self): - """ - Return the current widget displayed at the moment. - - Returns - ------- - spyder.plugins.plots.widgets.namespacebrowser.NamespaceBrowser - """ - return self.get_widget().current_widget() - - def on_connection_to_external_spyder_kernel(self, shellwidget): - """Send namespace view settings to the kernel.""" - shellwidget.set_namespace_view_settings() - shellwidget.refresh_namespacebrowser() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Variable Explorer Plugin. +""" + +# Local imports +from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.shellconnect.mixins import ShellConnectMixin +from spyder.api.translations import get_translation +from spyder.plugins.variableexplorer.confpage import ( + VariableExplorerConfigPage) +from spyder.plugins.variableexplorer.widgets.main_widget import ( + VariableExplorerWidget) + + +# Localization +_ = get_translation('spyder') + + +class VariableExplorer(SpyderDockablePlugin, ShellConnectMixin): + """ + Variable explorer plugin. + """ + NAME = 'variable_explorer' + REQUIRES = [Plugins.IPythonConsole, Plugins.Preferences] + TABIFY = None + WIDGET_CLASS = VariableExplorerWidget + CONF_SECTION = NAME + CONF_FILE = False + CONF_WIDGET_CLASS = VariableExplorerConfigPage + DISABLE_ACTIONS_WHEN_HIDDEN = False + + # ---- SpyderDockablePlugin API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Variable explorer') + + def get_description(self): + return _('Display, explore load and save variables in the current ' + 'namespace.') + + def get_icon(self): + return self.create_icon('dictedit') + + def on_initialize(self): + self.get_widget().sig_free_memory_requested.connect( + self.sig_free_memory_requested) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + # ---- Public API + # ------------------------------------------------------------------------ + def current_widget(self): + """ + Return the current widget displayed at the moment. + + Returns + ------- + spyder.plugins.plots.widgets.namespacebrowser.NamespaceBrowser + """ + return self.get_widget().current_widget() + + def on_connection_to_external_spyder_kernel(self, shellwidget): + """Send namespace view settings to the kernel.""" + shellwidget.set_namespace_view_settings() + shellwidget.refresh_namespacebrowser() diff --git a/spyder/plugins/variableexplorer/widgets/arrayeditor.py b/spyder/plugins/variableexplorer/widgets/arrayeditor.py index 0bc666819e2..40497a94ec0 100644 --- a/spyder/plugins/variableexplorer/widgets/arrayeditor.py +++ b/spyder/plugins/variableexplorer/widgets/arrayeditor.py @@ -1,939 +1,939 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -NumPy Array Editor Dialog based on Qt -""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from __future__ import print_function - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QAbstractTableModel, QItemSelection, QLocale, - QItemSelectionRange, QModelIndex, Qt, Slot) -from qtpy.QtGui import QColor, QCursor, QDoubleValidator, QKeySequence -from qtpy.QtWidgets import (QAbstractItemDelegate, QApplication, QCheckBox, - QComboBox, QDialog, QGridLayout, QHBoxLayout, - QInputDialog, QItemDelegate, QLabel, QLineEdit, - QMenu, QMessageBox, QPushButton, QSpinBox, - QStackedWidget, QTableView, QVBoxLayout, - QWidget) -from spyder_kernels.utils.nsview import value_to_display -from spyder_kernels.utils.lazymodules import numpy as np - -# Local imports -from spyder.config.base import _ -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.config.gui import get_font -from spyder.config.manager import CONF -from spyder.py3compat import (io, is_binary_string, is_string, - is_text_string, PY3, to_binary_string, - to_text_string) -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import add_actions, create_action, keybinding -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog - -# Note: string and unicode data types will be formatted with '%s' (see below) -SUPPORTED_FORMATS = { - 'single': '%.6g', - 'double': '%.6g', - 'float_': '%.6g', - 'longfloat': '%.6g', - 'float16': '%.6g', - 'float32': '%.6g', - 'float64': '%.6g', - 'float96': '%.6g', - 'float128': '%.6g', - 'csingle': '%r', - 'complex_': '%r', - 'clongfloat': '%r', - 'complex64': '%r', - 'complex128': '%r', - 'complex192': '%r', - 'complex256': '%r', - 'byte': '%d', - 'bytes8': '%s', - 'short': '%d', - 'intc': '%d', - 'int_': '%d', - 'longlong': '%d', - 'intp': '%d', - 'int8': '%d', - 'int16': '%d', - 'int32': '%d', - 'int64': '%d', - 'ubyte': '%d', - 'ushort': '%d', - 'uintc': '%d', - 'uint': '%d', - 'ulonglong': '%d', - 'uintp': '%d', - 'uint8': '%d', - 'uint16': '%d', - 'uint32': '%d', - 'uint64': '%d', - 'bool_': '%r', - 'bool8': '%r', - 'bool': '%r', -} - - -LARGE_SIZE = 5e5 -LARGE_NROWS = 1e5 -LARGE_COLS = 60 - - -#============================================================================== -# Utility functions -#============================================================================== -def is_float(dtype): - """Return True if datatype dtype is a float kind""" - return ('float' in dtype.name) or dtype.name in ['single', 'double'] - - -def is_number(dtype): - """Return True is datatype dtype is a number kind""" - return is_float(dtype) or ('int' in dtype.name) or ('long' in dtype.name) \ - or ('short' in dtype.name) - - -def get_idx_rect(index_list): - """Extract the boundaries from a list of indexes""" - rows, cols = list(zip(*[(i.row(), i.column()) for i in index_list])) - return ( min(rows), max(rows), min(cols), max(cols) ) - - -#============================================================================== -# Main classes -#============================================================================== -class ArrayModel(QAbstractTableModel): - """Array Editor Table Model""" - - ROWS_TO_LOAD = 500 - COLS_TO_LOAD = 40 - - def __init__(self, data, format="%.6g", xlabels=None, ylabels=None, - readonly=False, parent=None): - QAbstractTableModel.__init__(self) - - self.dialog = parent - self.changes = {} - self.xlabels = xlabels - self.ylabels = ylabels - self.readonly = readonly - self.test_array = np.array([0], dtype=data.dtype) - - # for complex numbers, shading will be based on absolute value - # but for all other types it will be the real part - if data.dtype in (np.complex64, np.complex128): - self.color_func = np.abs - else: - self.color_func = np.real - - # Backgroundcolor settings - huerange = [.66, .99] # Hue - self.sat = .7 # Saturation - self.val = 1. # Value - self.alp = .6 # Alpha-channel - - self._data = data - self._format = format - - self.total_rows = self._data.shape[0] - self.total_cols = self._data.shape[1] - size = self.total_rows * self.total_cols - - if not self._data.dtype.name == 'object': - try: - self.vmin = np.nanmin(self.color_func(data)) - self.vmax = np.nanmax(self.color_func(data)) - if self.vmax == self.vmin: - self.vmin -= 1 - self.hue0 = huerange[0] - self.dhue = huerange[1]-huerange[0] - self.bgcolor_enabled = True - except (AttributeError, TypeError, ValueError): - self.vmin = None - self.vmax = None - self.hue0 = None - self.dhue = None - self.bgcolor_enabled = False - - # Array with infinite values cannot display background colors and - # crashes. See: spyder-ide/spyder#8093 - self.has_inf = False - if data.dtype.kind in ['f', 'c']: - self.has_inf = np.any(np.isinf(data)) - - # Deactivate coloring for object arrays or arrays with inf values - if self._data.dtype.name == 'object' or self.has_inf: - self.bgcolor_enabled = False - - # Use paging when the total size, number of rows or number of - # columns is too large - if size > LARGE_SIZE: - self.rows_loaded = self.ROWS_TO_LOAD - self.cols_loaded = self.COLS_TO_LOAD - else: - if self.total_rows > LARGE_NROWS: - self.rows_loaded = self.ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - if self.total_cols > LARGE_COLS: - self.cols_loaded = self.COLS_TO_LOAD - else: - self.cols_loaded = self.total_cols - - def get_format(self): - """Return current format""" - # Avoid accessing the private attribute _format from outside - return self._format - - def get_data(self): - """Return data""" - return self._data - - def set_format(self, format): - """Change display format""" - self._format = format - self.reset() - - def columnCount(self, qindex=QModelIndex()): - """Array column number""" - if self.total_cols <= self.cols_loaded: - return self.total_cols - else: - return self.cols_loaded - - def rowCount(self, qindex=QModelIndex()): - """Array row number""" - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - - def can_fetch_more(self, rows=False, columns=False): - if rows: - if self.total_rows > self.rows_loaded: - return True - else: - return False - if columns: - if self.total_cols > self.cols_loaded: - return True - else: - return False - - def fetch_more(self, rows=False, columns=False): - if self.can_fetch_more(rows=rows): - reminder = self.total_rows - self.rows_loaded - items_to_fetch = min(reminder, self.ROWS_TO_LOAD) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - if self.can_fetch_more(columns=columns): - reminder = self.total_cols - self.cols_loaded - items_to_fetch = min(reminder, self.COLS_TO_LOAD) - self.beginInsertColumns(QModelIndex(), self.cols_loaded, - self.cols_loaded + items_to_fetch - 1) - self.cols_loaded += items_to_fetch - self.endInsertColumns() - - def bgcolor(self, state): - """Toggle backgroundcolor""" - self.bgcolor_enabled = state > 0 - self.reset() - - def get_value(self, index): - i = index.row() - j = index.column() - if len(self._data.shape) == 1: - value = self._data[j] - else: - value = self._data[i, j] - return self.changes.get((i, j), value) - - def data(self, index, role=Qt.DisplayRole): - """Cell content.""" - if not index.isValid(): - return to_qvariant() - value = self.get_value(index) - dtn = self._data.dtype.name - - # Tranform binary string to unicode so they are displayed - # correctly - if is_binary_string(value): - try: - value = to_text_string(value, 'utf8') - except Exception: - pass - - # Handle roles - if role == Qt.DisplayRole: - if value is np.ma.masked: - return '' - else: - if dtn == 'object': - # We don't know what's inside an object array, so - # we can't trust value repr's here. - return value_to_display(value) - else: - try: - return to_qvariant(self._format % value) - except TypeError: - self.readonly = True - return repr(value) - elif role == Qt.TextAlignmentRole: - return to_qvariant(int(Qt.AlignCenter|Qt.AlignVCenter)) - elif (role == Qt.BackgroundColorRole and self.bgcolor_enabled - and value is not np.ma.masked and not self.has_inf): - try: - hue = (self.hue0 + - self.dhue * (float(self.vmax) - self.color_func(value)) - / (float(self.vmax) - self.vmin)) - hue = float(np.abs(hue)) - color = QColor.fromHsvF(hue, self.sat, self.val, self.alp) - return to_qvariant(color) - except (TypeError, ValueError): - return to_qvariant() - elif role == Qt.FontRole: - return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - return to_qvariant() - - def setData(self, index, value, role=Qt.EditRole): - """Cell content change""" - if not index.isValid() or self.readonly: - return False - i = index.row() - j = index.column() - value = from_qvariant(value, str) - dtype = self._data.dtype.name - if dtype == "bool": - try: - val = bool(float(value)) - except ValueError: - val = value.lower() == "true" - elif dtype.startswith("string") or dtype.startswith("bytes"): - val = to_binary_string(value, 'utf8') - elif dtype.startswith("unicode") or dtype.startswith("str"): - val = to_text_string(value) - else: - if value.lower().startswith('e') or value.lower().endswith('e'): - return False - try: - val = complex(value) - if not val.imag: - val = val.real - except ValueError as e: - QMessageBox.critical(self.dialog, "Error", - "Value error: %s" % str(e)) - return False - try: - self.test_array[0] = val # will raise an Exception eventually - except OverflowError as e: - print("OverflowError: " + str(e)) # spyder: test-skip - QMessageBox.critical(self.dialog, "Error", - "Overflow error: %s" % str(e)) - return False - - # Add change to self.changes - self.changes[(i, j)] = val - self.dataChanged.emit(index, index) - - if not is_string(val): - val = self.color_func(val) - - if val > self.vmax: - self.vmax = val - - if val < self.vmin: - self.vmin = val - - return True - - def flags(self, index): - """Set editable flag""" - if not index.isValid(): - return Qt.ItemIsEnabled - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | - Qt.ItemIsEditable)) - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """Set header data""" - if role != Qt.DisplayRole: - return to_qvariant() - labels = self.xlabels if orientation == Qt.Horizontal else self.ylabels - if labels is None: - return to_qvariant(int(section)) - else: - return to_qvariant(labels[section]) - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class ArrayDelegate(QItemDelegate): - """Array Editor Item Delegate""" - def __init__(self, dtype, parent=None): - QItemDelegate.__init__(self, parent) - self.dtype = dtype - - def createEditor(self, parent, option, index): - """Create editor widget""" - model = index.model() - value = model.get_value(index) - if type(value) == np.ndarray or model.readonly: - # The editor currently cannot properly handle this case - return - elif model._data.dtype.name == "bool": - value = not value - model.setData(index, to_qvariant(value)) - return - elif value is not np.ma.masked: - editor = QLineEdit(parent) - editor.setFont(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - editor.setAlignment(Qt.AlignCenter) - if is_number(self.dtype): - validator = QDoubleValidator(editor) - validator.setLocale(QLocale('C')) - editor.setValidator(validator) - editor.returnPressed.connect(self.commitAndCloseEditor) - return editor - - def commitAndCloseEditor(self): - """Commit and close editor""" - editor = self.sender() - # Avoid a segfault with PyQt5. Variable value won't be changed - # but at least Spyder won't crash. It seems generated by a bug in sip. - try: - self.commitData.emit(editor) - except AttributeError: - pass - self.closeEditor.emit(editor, QAbstractItemDelegate.NoHint) - - def setEditorData(self, editor, index): - """Set editor widget's data""" - text = from_qvariant(index.model().data(index, Qt.DisplayRole), str) - editor.setText(text) - - -#TODO: Implement "Paste" (from clipboard) feature -class ArrayView(QTableView): - """Array view class""" - def __init__(self, parent, model, dtype, shape): - QTableView.__init__(self, parent) - - self.setModel(model) - self.setItemDelegate(ArrayDelegate(dtype, self)) - total_width = 0 - for k in range(shape[1]): - total_width += self.columnWidth(k) - self.viewport().resize(min(total_width, 1024), self.height()) - self.shape = shape - self.menu = self.setup_menu() - CONF.config_shortcut( - self.copy, - context='variable_explorer', - name='copy', - parent=self) - self.horizontalScrollBar().valueChanged.connect( - self._load_more_columns) - self.verticalScrollBar().valueChanged.connect(self._load_more_rows) - - def _load_more_columns(self, value): - """Load more columns to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, columns=True) - except NameError: - pass - - def _load_more_rows(self, value): - """Load more rows to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, rows=True) - except NameError: - pass - - def load_more_data(self, value, rows=False, columns=False): - - try: - old_selection = self.selectionModel().selection() - old_rows_loaded = old_cols_loaded = None - - if rows and value == self.verticalScrollBar().maximum(): - old_rows_loaded = self.model().rows_loaded - self.model().fetch_more(rows=rows) - - if columns and value == self.horizontalScrollBar().maximum(): - old_cols_loaded = self.model().cols_loaded - self.model().fetch_more(columns=columns) - - if old_rows_loaded is not None or old_cols_loaded is not None: - # if we've changed anything, update selection - new_selection = QItemSelection() - for part in old_selection: - top = part.top() - bottom = part.bottom() - if (old_rows_loaded is not None and - top == 0 and bottom == (old_rows_loaded-1)): - # complete column selected (so expand it to match - # updated range) - bottom = self.model().rows_loaded-1 - left = part.left() - right = part.right() - if (old_cols_loaded is not None - and left == 0 and right == (old_cols_loaded-1)): - # compete row selected (so expand it to match updated - # range) - right = self.model().cols_loaded-1 - top_left = self.model().index(top, left) - bottom_right = self.model().index(bottom, right) - part = QItemSelectionRange(top_left, bottom_right) - new_selection.append(part) - self.selectionModel().select( - new_selection, self.selectionModel().ClearAndSelect) - except NameError: - # Needed to handle a NameError while fetching data when closing - # See isue 7880 - pass - - def resize_to_contents(self): - """Resize cells to contents""" - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.resizeColumnsToContents() - self.model().fetch_more(columns=True) - self.resizeColumnsToContents() - QApplication.restoreOverrideCursor() - - def setup_menu(self): - """Setup context menu""" - self.copy_action = create_action(self, _('Copy'), - shortcut=keybinding('Copy'), - icon=ima.icon('editcopy'), - triggered=self.copy, - context=Qt.WidgetShortcut) - menu = QMenu(self) - add_actions(menu, [self.copy_action, ]) - return menu - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - self.menu.popup(event.globalPos()) - event.accept() - - def keyPressEvent(self, event): - """Reimplement Qt method""" - if event == QKeySequence.Copy: - self.copy() - else: - QTableView.keyPressEvent(self, event) - - def _sel_to_text(self, cell_range): - """Copy an array portion to a unicode string""" - if not cell_range: - return - row_min, row_max, col_min, col_max = get_idx_rect(cell_range) - if col_min == 0 and col_max == (self.model().cols_loaded-1): - # we've selected a whole column. It isn't possible to - # select only the first part of a column without loading more, - # so we can treat it as intentional and copy the whole thing - col_max = self.model().total_cols-1 - if row_min == 0 and row_max == (self.model().rows_loaded-1): - row_max = self.model().total_rows-1 - - _data = self.model().get_data() - if PY3: - output = io.BytesIO() - else: - output = io.StringIO() - try: - np.savetxt(output, _data[row_min:row_max+1, col_min:col_max+1], - delimiter='\t', fmt=self.model().get_format()) - except: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy values for " - "this array")) - return - contents = output.getvalue().decode('utf-8') - output.close() - return contents - - @Slot() - def copy(self): - """Copy text to clipboard""" - cliptxt = self._sel_to_text( self.selectedIndexes() ) - clipboard = QApplication.clipboard() - clipboard.setText(cliptxt) - - -class ArrayEditorWidget(QWidget): - - def __init__(self, parent, data, readonly=False, - xlabels=None, ylabels=None): - QWidget.__init__(self, parent) - self.data = data - self.old_data_shape = None - if len(self.data.shape) == 1: - self.old_data_shape = self.data.shape - self.data.shape = (self.data.shape[0], 1) - elif len(self.data.shape) == 0: - self.old_data_shape = self.data.shape - self.data.shape = (1, 1) - - format = SUPPORTED_FORMATS.get(data.dtype.name, '%s') - self.model = ArrayModel(self.data, format=format, xlabels=xlabels, - ylabels=ylabels, readonly=readonly, parent=self) - self.view = ArrayView(self, self.model, data.dtype, data.shape) - - layout = QVBoxLayout() - layout.addWidget(self.view) - self.setLayout(layout) - - def accept_changes(self): - """Accept changes""" - for (i, j), value in list(self.model.changes.items()): - self.data[i, j] = value - if self.old_data_shape is not None: - self.data.shape = self.old_data_shape - - def reject_changes(self): - """Reject changes""" - if self.old_data_shape is not None: - self.data.shape = self.old_data_shape - - def change_format(self): - """Change display format""" - format, valid = QInputDialog.getText(self, _( 'Format'), - _( "Float formatting"), - QLineEdit.Normal, self.model.get_format()) - if valid: - format = str(format) - try: - format % 1.1 - except: - QMessageBox.critical(self, _("Error"), - _("Format (%s) is incorrect") % format) - return - self.model.set_format(format) - - -class ArrayEditor(BaseDialog): - """Array Editor Dialog""" - def __init__(self, parent=None): - super().__init__(parent) - - # Destroying the C++ object right after closing the dialog box, - # otherwise it may be garbage-collected in another QThread - # (e.g. the editor's analysis thread in Spyder), thus leading to - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - - self.data = None - self.arraywidget = None - self.stack = None - self.layout = None - self.btn_save_and_close = None - self.btn_close = None - # Values for 3d array editor - self.dim_indexes = [{}, {}, {}] - self.last_dim = 0 # Adjust this for changing the startup dimension - - def setup_and_check(self, data, title='', readonly=False, - xlabels=None, ylabels=None): - """ - Setup ArrayEditor: - return False if data is not supported, True otherwise - """ - self.data = data - readonly = readonly or not self.data.flags.writeable - is_record_array = data.dtype.names is not None - is_masked_array = isinstance(data, np.ma.MaskedArray) - - if data.ndim > 3: - self.error(_("Arrays with more than 3 dimensions are not " - "supported")) - return False - if xlabels is not None and len(xlabels) != self.data.shape[1]: - self.error(_("The 'xlabels' argument length do no match array " - "column number")) - return False - if ylabels is not None and len(ylabels) != self.data.shape[0]: - self.error(_("The 'ylabels' argument length do no match array row " - "number")) - return False - if not is_record_array: - dtn = data.dtype.name - if dtn == 'object': - # If the array doesn't have shape, we can't display it - if data.shape == (): - self.error(_("Object arrays without shape are not " - "supported")) - return False - # We don't know what's inside these arrays, so we can't handle - # edits - self.readonly = readonly = True - elif (dtn not in SUPPORTED_FORMATS and not dtn.startswith('str') - and not dtn.startswith('unicode')): - arr = _("%s arrays") % data.dtype.name - self.error(_("%s are currently not supported") % arr) - return False - - self.layout = QGridLayout() - self.setLayout(self.layout) - if title: - title = to_text_string(title) + " - " + _("NumPy object array") - else: - title = _("Array editor") - if readonly: - title += ' (' + _('read only') + ')' - self.setWindowTitle(title) - - # ---- Stack widget - self.stack = QStackedWidget(self) - if is_record_array: - for name in data.dtype.names: - self.stack.addWidget(ArrayEditorWidget(self, data[name], - readonly, xlabels, - ylabels)) - elif is_masked_array: - self.stack.addWidget(ArrayEditorWidget(self, data, readonly, - xlabels, ylabels)) - self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly, - xlabels, ylabels)) - self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly, - xlabels, ylabels)) - elif data.ndim == 3: - # We create here the necessary widgets for current_dim_changed to - # work. The rest are created below. - # QSpinBox - self.index_spin = QSpinBox(self, keyboardTracking=False) - self.index_spin.valueChanged.connect(self.change_active_widget) - - # Labels - self.shape_label = QLabel() - self.slicing_label = QLabel() - - # Set the widget to display when launched - self.current_dim_changed(self.last_dim) - else: - self.stack.addWidget(ArrayEditorWidget(self, data, readonly, - xlabels, ylabels)) - - self.arraywidget = self.stack.currentWidget() - self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) - self.stack.currentChanged.connect(self.current_widget_changed) - self.layout.addWidget(self.stack, 1, 0) - - # ---- Top row of buttons - btn_layout_top = None - if is_record_array or is_masked_array or data.ndim == 3: - btn_layout_top = QHBoxLayout() - - if is_record_array: - btn_layout_top.addWidget(QLabel(_("Record array fields:"))) - names = [] - for name in data.dtype.names: - field = data.dtype.fields[name] - text = name - if len(field) >= 3: - title = field[2] - if not is_text_string(title): - title = repr(title) - text += ' - '+title - names.append(text) - else: - names = [_('Masked data'), _('Data'), _('Mask')] - - if data.ndim == 3: - # QComboBox - names = [str(i) for i in range(3)] - ra_combo = QComboBox(self) - ra_combo.addItems(names) - ra_combo.currentIndexChanged.connect(self.current_dim_changed) - - # Adding the widgets to layout - label = QLabel(_("Axis:")) - btn_layout_top.addWidget(label) - btn_layout_top.addWidget(ra_combo) - btn_layout_top.addWidget(self.shape_label) - - label = QLabel(_("Index:")) - btn_layout_top.addWidget(label) - btn_layout_top.addWidget(self.index_spin) - - btn_layout_top.addWidget(self.slicing_label) - else: - ra_combo = QComboBox(self) - ra_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) - ra_combo.addItems(names) - btn_layout_top.addWidget(ra_combo) - - if is_masked_array: - label = QLabel( - _("Warning: Changes are applied separately") - ) - label.setToolTip(_("For performance reasons, changes applied " - "to masked arrays won't be reflected in " - "array's data (and vice-versa).")) - btn_layout_top.addWidget(label) - - btn_layout_top.addStretch() - - # ---- Bottom row of buttons - btn_layout_bottom = QHBoxLayout() - - btn_format = QPushButton(_("Format")) - # disable format button for int type - btn_format.setEnabled(is_float(self.arraywidget.data.dtype)) - btn_layout_bottom.addWidget(btn_format) - btn_format.clicked.connect(lambda: self.arraywidget.change_format()) - - btn_resize = QPushButton(_("Resize")) - btn_layout_bottom.addWidget(btn_resize) - btn_resize.clicked.connect( - lambda: self.arraywidget.view.resize_to_contents()) - - self.bgcolor = QCheckBox(_('Background color')) - self.bgcolor.setEnabled(self.arraywidget.model.bgcolor_enabled) - self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) - self.bgcolor.stateChanged.connect( - lambda state: self.arraywidget.model.bgcolor(state)) - btn_layout_bottom.addWidget(self.bgcolor) - - btn_layout_bottom.addStretch() - - if not readonly: - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout_bottom.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout_bottom.addWidget(self.btn_close) - - # ---- Final layout - btn_layout_bottom.setContentsMargins(4, 4, 4, 4) - if btn_layout_top is not None: - btn_layout_top.setContentsMargins(4, 4, 4, 4) - self.layout.addLayout(btn_layout_top, 2, 0) - self.layout.addLayout(btn_layout_bottom, 3, 0) - else: - self.layout.addLayout(btn_layout_bottom, 2, 0) - - # Set minimum size - self.setMinimumSize(500, 300) - - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - - return True - - @Slot(QModelIndex, QModelIndex) - def save_and_close_enable(self, left_top, bottom_right): - """Handle the data change event to enable the save and close button.""" - if self.btn_save_and_close: - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def current_widget_changed(self, index): - self.arraywidget = self.stack.widget(index) - self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) - self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) - - def change_active_widget(self, index): - """ - This is implemented for handling negative values in index for - 3d arrays, to give the same behavior as slicing - """ - string_index = [':']*3 - string_index[self.last_dim] = '%i' - self.slicing_label.setText((r"Slicing: [" + ", ".join(string_index) + - "]") % index) - if index < 0: - data_index = self.data.shape[self.last_dim] + index - else: - data_index = index - slice_index = [slice(None)]*3 - slice_index[self.last_dim] = data_index - - stack_index = self.dim_indexes[self.last_dim].get(data_index) - if stack_index is None: - stack_index = self.stack.count() - try: - self.stack.addWidget(ArrayEditorWidget( - self, self.data[tuple(slice_index)])) - except IndexError: # Handle arrays of size 0 in one axis - self.stack.addWidget(ArrayEditorWidget(self, self.data)) - self.dim_indexes[self.last_dim][data_index] = stack_index - self.stack.update() - self.stack.setCurrentIndex(stack_index) - - def current_dim_changed(self, index): - """ - This change the active axis the array editor is plotting over - in 3D - """ - self.last_dim = index - string_size = ['%i']*3 - string_size[index] = '%i' - self.shape_label.setText(('Shape: (' + ', '.join(string_size) + - ') ') % self.data.shape) - if self.index_spin.value() != 0: - self.index_spin.setValue(0) - else: - # this is done since if the value is currently 0 it does not emit - # currentIndexChanged(int) - self.change_active_widget(0) - self.index_spin.setRange(-self.data.shape[index], - self.data.shape[index]-1) - - @Slot() - def accept(self): - """Reimplement Qt method.""" - try: - for index in range(self.stack.count()): - self.stack.widget(index).accept_changes() - QDialog.accept(self) - except RuntimeError: - # Sometimes under CI testing the object the following error appears - # RuntimeError: wrapped C/C++ object has been deleted - pass - - def get_value(self): - """Return modified array -- this is *not* a copy""" - # It is important to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.data - - def error(self, message): - """An error occurred, closing the dialog box""" - QMessageBox.critical(self, _("Array editor"), message) - self.setAttribute(Qt.WA_DeleteOnClose) - self.reject() - - @Slot() - def reject(self): - """Reimplement Qt method""" - if self.arraywidget is not None: - for index in range(self.stack.count()): - self.stack.widget(index).reject_changes() - QDialog.reject(self) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +NumPy Array Editor Dialog based on Qt +""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from __future__ import print_function + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QAbstractTableModel, QItemSelection, QLocale, + QItemSelectionRange, QModelIndex, Qt, Slot) +from qtpy.QtGui import QColor, QCursor, QDoubleValidator, QKeySequence +from qtpy.QtWidgets import (QAbstractItemDelegate, QApplication, QCheckBox, + QComboBox, QDialog, QGridLayout, QHBoxLayout, + QInputDialog, QItemDelegate, QLabel, QLineEdit, + QMenu, QMessageBox, QPushButton, QSpinBox, + QStackedWidget, QTableView, QVBoxLayout, + QWidget) +from spyder_kernels.utils.nsview import value_to_display +from spyder_kernels.utils.lazymodules import numpy as np + +# Local imports +from spyder.config.base import _ +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.config.gui import get_font +from spyder.config.manager import CONF +from spyder.py3compat import (io, is_binary_string, is_string, + is_text_string, PY3, to_binary_string, + to_text_string) +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import add_actions, create_action, keybinding +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog + +# Note: string and unicode data types will be formatted with '%s' (see below) +SUPPORTED_FORMATS = { + 'single': '%.6g', + 'double': '%.6g', + 'float_': '%.6g', + 'longfloat': '%.6g', + 'float16': '%.6g', + 'float32': '%.6g', + 'float64': '%.6g', + 'float96': '%.6g', + 'float128': '%.6g', + 'csingle': '%r', + 'complex_': '%r', + 'clongfloat': '%r', + 'complex64': '%r', + 'complex128': '%r', + 'complex192': '%r', + 'complex256': '%r', + 'byte': '%d', + 'bytes8': '%s', + 'short': '%d', + 'intc': '%d', + 'int_': '%d', + 'longlong': '%d', + 'intp': '%d', + 'int8': '%d', + 'int16': '%d', + 'int32': '%d', + 'int64': '%d', + 'ubyte': '%d', + 'ushort': '%d', + 'uintc': '%d', + 'uint': '%d', + 'ulonglong': '%d', + 'uintp': '%d', + 'uint8': '%d', + 'uint16': '%d', + 'uint32': '%d', + 'uint64': '%d', + 'bool_': '%r', + 'bool8': '%r', + 'bool': '%r', +} + + +LARGE_SIZE = 5e5 +LARGE_NROWS = 1e5 +LARGE_COLS = 60 + + +#============================================================================== +# Utility functions +#============================================================================== +def is_float(dtype): + """Return True if datatype dtype is a float kind""" + return ('float' in dtype.name) or dtype.name in ['single', 'double'] + + +def is_number(dtype): + """Return True is datatype dtype is a number kind""" + return is_float(dtype) or ('int' in dtype.name) or ('long' in dtype.name) \ + or ('short' in dtype.name) + + +def get_idx_rect(index_list): + """Extract the boundaries from a list of indexes""" + rows, cols = list(zip(*[(i.row(), i.column()) for i in index_list])) + return ( min(rows), max(rows), min(cols), max(cols) ) + + +#============================================================================== +# Main classes +#============================================================================== +class ArrayModel(QAbstractTableModel): + """Array Editor Table Model""" + + ROWS_TO_LOAD = 500 + COLS_TO_LOAD = 40 + + def __init__(self, data, format="%.6g", xlabels=None, ylabels=None, + readonly=False, parent=None): + QAbstractTableModel.__init__(self) + + self.dialog = parent + self.changes = {} + self.xlabels = xlabels + self.ylabels = ylabels + self.readonly = readonly + self.test_array = np.array([0], dtype=data.dtype) + + # for complex numbers, shading will be based on absolute value + # but for all other types it will be the real part + if data.dtype in (np.complex64, np.complex128): + self.color_func = np.abs + else: + self.color_func = np.real + + # Backgroundcolor settings + huerange = [.66, .99] # Hue + self.sat = .7 # Saturation + self.val = 1. # Value + self.alp = .6 # Alpha-channel + + self._data = data + self._format = format + + self.total_rows = self._data.shape[0] + self.total_cols = self._data.shape[1] + size = self.total_rows * self.total_cols + + if not self._data.dtype.name == 'object': + try: + self.vmin = np.nanmin(self.color_func(data)) + self.vmax = np.nanmax(self.color_func(data)) + if self.vmax == self.vmin: + self.vmin -= 1 + self.hue0 = huerange[0] + self.dhue = huerange[1]-huerange[0] + self.bgcolor_enabled = True + except (AttributeError, TypeError, ValueError): + self.vmin = None + self.vmax = None + self.hue0 = None + self.dhue = None + self.bgcolor_enabled = False + + # Array with infinite values cannot display background colors and + # crashes. See: spyder-ide/spyder#8093 + self.has_inf = False + if data.dtype.kind in ['f', 'c']: + self.has_inf = np.any(np.isinf(data)) + + # Deactivate coloring for object arrays or arrays with inf values + if self._data.dtype.name == 'object' or self.has_inf: + self.bgcolor_enabled = False + + # Use paging when the total size, number of rows or number of + # columns is too large + if size > LARGE_SIZE: + self.rows_loaded = self.ROWS_TO_LOAD + self.cols_loaded = self.COLS_TO_LOAD + else: + if self.total_rows > LARGE_NROWS: + self.rows_loaded = self.ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + if self.total_cols > LARGE_COLS: + self.cols_loaded = self.COLS_TO_LOAD + else: + self.cols_loaded = self.total_cols + + def get_format(self): + """Return current format""" + # Avoid accessing the private attribute _format from outside + return self._format + + def get_data(self): + """Return data""" + return self._data + + def set_format(self, format): + """Change display format""" + self._format = format + self.reset() + + def columnCount(self, qindex=QModelIndex()): + """Array column number""" + if self.total_cols <= self.cols_loaded: + return self.total_cols + else: + return self.cols_loaded + + def rowCount(self, qindex=QModelIndex()): + """Array row number""" + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + + def can_fetch_more(self, rows=False, columns=False): + if rows: + if self.total_rows > self.rows_loaded: + return True + else: + return False + if columns: + if self.total_cols > self.cols_loaded: + return True + else: + return False + + def fetch_more(self, rows=False, columns=False): + if self.can_fetch_more(rows=rows): + reminder = self.total_rows - self.rows_loaded + items_to_fetch = min(reminder, self.ROWS_TO_LOAD) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + if self.can_fetch_more(columns=columns): + reminder = self.total_cols - self.cols_loaded + items_to_fetch = min(reminder, self.COLS_TO_LOAD) + self.beginInsertColumns(QModelIndex(), self.cols_loaded, + self.cols_loaded + items_to_fetch - 1) + self.cols_loaded += items_to_fetch + self.endInsertColumns() + + def bgcolor(self, state): + """Toggle backgroundcolor""" + self.bgcolor_enabled = state > 0 + self.reset() + + def get_value(self, index): + i = index.row() + j = index.column() + if len(self._data.shape) == 1: + value = self._data[j] + else: + value = self._data[i, j] + return self.changes.get((i, j), value) + + def data(self, index, role=Qt.DisplayRole): + """Cell content.""" + if not index.isValid(): + return to_qvariant() + value = self.get_value(index) + dtn = self._data.dtype.name + + # Tranform binary string to unicode so they are displayed + # correctly + if is_binary_string(value): + try: + value = to_text_string(value, 'utf8') + except Exception: + pass + + # Handle roles + if role == Qt.DisplayRole: + if value is np.ma.masked: + return '' + else: + if dtn == 'object': + # We don't know what's inside an object array, so + # we can't trust value repr's here. + return value_to_display(value) + else: + try: + return to_qvariant(self._format % value) + except TypeError: + self.readonly = True + return repr(value) + elif role == Qt.TextAlignmentRole: + return to_qvariant(int(Qt.AlignCenter|Qt.AlignVCenter)) + elif (role == Qt.BackgroundColorRole and self.bgcolor_enabled + and value is not np.ma.masked and not self.has_inf): + try: + hue = (self.hue0 + + self.dhue * (float(self.vmax) - self.color_func(value)) + / (float(self.vmax) - self.vmin)) + hue = float(np.abs(hue)) + color = QColor.fromHsvF(hue, self.sat, self.val, self.alp) + return to_qvariant(color) + except (TypeError, ValueError): + return to_qvariant() + elif role == Qt.FontRole: + return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + return to_qvariant() + + def setData(self, index, value, role=Qt.EditRole): + """Cell content change""" + if not index.isValid() or self.readonly: + return False + i = index.row() + j = index.column() + value = from_qvariant(value, str) + dtype = self._data.dtype.name + if dtype == "bool": + try: + val = bool(float(value)) + except ValueError: + val = value.lower() == "true" + elif dtype.startswith("string") or dtype.startswith("bytes"): + val = to_binary_string(value, 'utf8') + elif dtype.startswith("unicode") or dtype.startswith("str"): + val = to_text_string(value) + else: + if value.lower().startswith('e') or value.lower().endswith('e'): + return False + try: + val = complex(value) + if not val.imag: + val = val.real + except ValueError as e: + QMessageBox.critical(self.dialog, "Error", + "Value error: %s" % str(e)) + return False + try: + self.test_array[0] = val # will raise an Exception eventually + except OverflowError as e: + print("OverflowError: " + str(e)) # spyder: test-skip + QMessageBox.critical(self.dialog, "Error", + "Overflow error: %s" % str(e)) + return False + + # Add change to self.changes + self.changes[(i, j)] = val + self.dataChanged.emit(index, index) + + if not is_string(val): + val = self.color_func(val) + + if val > self.vmax: + self.vmax = val + + if val < self.vmin: + self.vmin = val + + return True + + def flags(self, index): + """Set editable flag""" + if not index.isValid(): + return Qt.ItemIsEnabled + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | + Qt.ItemIsEditable)) + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """Set header data""" + if role != Qt.DisplayRole: + return to_qvariant() + labels = self.xlabels if orientation == Qt.Horizontal else self.ylabels + if labels is None: + return to_qvariant(int(section)) + else: + return to_qvariant(labels[section]) + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class ArrayDelegate(QItemDelegate): + """Array Editor Item Delegate""" + def __init__(self, dtype, parent=None): + QItemDelegate.__init__(self, parent) + self.dtype = dtype + + def createEditor(self, parent, option, index): + """Create editor widget""" + model = index.model() + value = model.get_value(index) + if type(value) == np.ndarray or model.readonly: + # The editor currently cannot properly handle this case + return + elif model._data.dtype.name == "bool": + value = not value + model.setData(index, to_qvariant(value)) + return + elif value is not np.ma.masked: + editor = QLineEdit(parent) + editor.setFont(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + editor.setAlignment(Qt.AlignCenter) + if is_number(self.dtype): + validator = QDoubleValidator(editor) + validator.setLocale(QLocale('C')) + editor.setValidator(validator) + editor.returnPressed.connect(self.commitAndCloseEditor) + return editor + + def commitAndCloseEditor(self): + """Commit and close editor""" + editor = self.sender() + # Avoid a segfault with PyQt5. Variable value won't be changed + # but at least Spyder won't crash. It seems generated by a bug in sip. + try: + self.commitData.emit(editor) + except AttributeError: + pass + self.closeEditor.emit(editor, QAbstractItemDelegate.NoHint) + + def setEditorData(self, editor, index): + """Set editor widget's data""" + text = from_qvariant(index.model().data(index, Qt.DisplayRole), str) + editor.setText(text) + + +#TODO: Implement "Paste" (from clipboard) feature +class ArrayView(QTableView): + """Array view class""" + def __init__(self, parent, model, dtype, shape): + QTableView.__init__(self, parent) + + self.setModel(model) + self.setItemDelegate(ArrayDelegate(dtype, self)) + total_width = 0 + for k in range(shape[1]): + total_width += self.columnWidth(k) + self.viewport().resize(min(total_width, 1024), self.height()) + self.shape = shape + self.menu = self.setup_menu() + CONF.config_shortcut( + self.copy, + context='variable_explorer', + name='copy', + parent=self) + self.horizontalScrollBar().valueChanged.connect( + self._load_more_columns) + self.verticalScrollBar().valueChanged.connect(self._load_more_rows) + + def _load_more_columns(self, value): + """Load more columns to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, columns=True) + except NameError: + pass + + def _load_more_rows(self, value): + """Load more rows to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, rows=True) + except NameError: + pass + + def load_more_data(self, value, rows=False, columns=False): + + try: + old_selection = self.selectionModel().selection() + old_rows_loaded = old_cols_loaded = None + + if rows and value == self.verticalScrollBar().maximum(): + old_rows_loaded = self.model().rows_loaded + self.model().fetch_more(rows=rows) + + if columns and value == self.horizontalScrollBar().maximum(): + old_cols_loaded = self.model().cols_loaded + self.model().fetch_more(columns=columns) + + if old_rows_loaded is not None or old_cols_loaded is not None: + # if we've changed anything, update selection + new_selection = QItemSelection() + for part in old_selection: + top = part.top() + bottom = part.bottom() + if (old_rows_loaded is not None and + top == 0 and bottom == (old_rows_loaded-1)): + # complete column selected (so expand it to match + # updated range) + bottom = self.model().rows_loaded-1 + left = part.left() + right = part.right() + if (old_cols_loaded is not None + and left == 0 and right == (old_cols_loaded-1)): + # compete row selected (so expand it to match updated + # range) + right = self.model().cols_loaded-1 + top_left = self.model().index(top, left) + bottom_right = self.model().index(bottom, right) + part = QItemSelectionRange(top_left, bottom_right) + new_selection.append(part) + self.selectionModel().select( + new_selection, self.selectionModel().ClearAndSelect) + except NameError: + # Needed to handle a NameError while fetching data when closing + # See isue 7880 + pass + + def resize_to_contents(self): + """Resize cells to contents""" + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + self.resizeColumnsToContents() + self.model().fetch_more(columns=True) + self.resizeColumnsToContents() + QApplication.restoreOverrideCursor() + + def setup_menu(self): + """Setup context menu""" + self.copy_action = create_action(self, _('Copy'), + shortcut=keybinding('Copy'), + icon=ima.icon('editcopy'), + triggered=self.copy, + context=Qt.WidgetShortcut) + menu = QMenu(self) + add_actions(menu, [self.copy_action, ]) + return menu + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + self.menu.popup(event.globalPos()) + event.accept() + + def keyPressEvent(self, event): + """Reimplement Qt method""" + if event == QKeySequence.Copy: + self.copy() + else: + QTableView.keyPressEvent(self, event) + + def _sel_to_text(self, cell_range): + """Copy an array portion to a unicode string""" + if not cell_range: + return + row_min, row_max, col_min, col_max = get_idx_rect(cell_range) + if col_min == 0 and col_max == (self.model().cols_loaded-1): + # we've selected a whole column. It isn't possible to + # select only the first part of a column without loading more, + # so we can treat it as intentional and copy the whole thing + col_max = self.model().total_cols-1 + if row_min == 0 and row_max == (self.model().rows_loaded-1): + row_max = self.model().total_rows-1 + + _data = self.model().get_data() + if PY3: + output = io.BytesIO() + else: + output = io.StringIO() + try: + np.savetxt(output, _data[row_min:row_max+1, col_min:col_max+1], + delimiter='\t', fmt=self.model().get_format()) + except: + QMessageBox.warning(self, _("Warning"), + _("It was not possible to copy values for " + "this array")) + return + contents = output.getvalue().decode('utf-8') + output.close() + return contents + + @Slot() + def copy(self): + """Copy text to clipboard""" + cliptxt = self._sel_to_text( self.selectedIndexes() ) + clipboard = QApplication.clipboard() + clipboard.setText(cliptxt) + + +class ArrayEditorWidget(QWidget): + + def __init__(self, parent, data, readonly=False, + xlabels=None, ylabels=None): + QWidget.__init__(self, parent) + self.data = data + self.old_data_shape = None + if len(self.data.shape) == 1: + self.old_data_shape = self.data.shape + self.data.shape = (self.data.shape[0], 1) + elif len(self.data.shape) == 0: + self.old_data_shape = self.data.shape + self.data.shape = (1, 1) + + format = SUPPORTED_FORMATS.get(data.dtype.name, '%s') + self.model = ArrayModel(self.data, format=format, xlabels=xlabels, + ylabels=ylabels, readonly=readonly, parent=self) + self.view = ArrayView(self, self.model, data.dtype, data.shape) + + layout = QVBoxLayout() + layout.addWidget(self.view) + self.setLayout(layout) + + def accept_changes(self): + """Accept changes""" + for (i, j), value in list(self.model.changes.items()): + self.data[i, j] = value + if self.old_data_shape is not None: + self.data.shape = self.old_data_shape + + def reject_changes(self): + """Reject changes""" + if self.old_data_shape is not None: + self.data.shape = self.old_data_shape + + def change_format(self): + """Change display format""" + format, valid = QInputDialog.getText(self, _( 'Format'), + _( "Float formatting"), + QLineEdit.Normal, self.model.get_format()) + if valid: + format = str(format) + try: + format % 1.1 + except: + QMessageBox.critical(self, _("Error"), + _("Format (%s) is incorrect") % format) + return + self.model.set_format(format) + + +class ArrayEditor(BaseDialog): + """Array Editor Dialog""" + def __init__(self, parent=None): + super().__init__(parent) + + # Destroying the C++ object right after closing the dialog box, + # otherwise it may be garbage-collected in another QThread + # (e.g. the editor's analysis thread in Spyder), thus leading to + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + + self.data = None + self.arraywidget = None + self.stack = None + self.layout = None + self.btn_save_and_close = None + self.btn_close = None + # Values for 3d array editor + self.dim_indexes = [{}, {}, {}] + self.last_dim = 0 # Adjust this for changing the startup dimension + + def setup_and_check(self, data, title='', readonly=False, + xlabels=None, ylabels=None): + """ + Setup ArrayEditor: + return False if data is not supported, True otherwise + """ + self.data = data + readonly = readonly or not self.data.flags.writeable + is_record_array = data.dtype.names is not None + is_masked_array = isinstance(data, np.ma.MaskedArray) + + if data.ndim > 3: + self.error(_("Arrays with more than 3 dimensions are not " + "supported")) + return False + if xlabels is not None and len(xlabels) != self.data.shape[1]: + self.error(_("The 'xlabels' argument length do no match array " + "column number")) + return False + if ylabels is not None and len(ylabels) != self.data.shape[0]: + self.error(_("The 'ylabels' argument length do no match array row " + "number")) + return False + if not is_record_array: + dtn = data.dtype.name + if dtn == 'object': + # If the array doesn't have shape, we can't display it + if data.shape == (): + self.error(_("Object arrays without shape are not " + "supported")) + return False + # We don't know what's inside these arrays, so we can't handle + # edits + self.readonly = readonly = True + elif (dtn not in SUPPORTED_FORMATS and not dtn.startswith('str') + and not dtn.startswith('unicode')): + arr = _("%s arrays") % data.dtype.name + self.error(_("%s are currently not supported") % arr) + return False + + self.layout = QGridLayout() + self.setLayout(self.layout) + if title: + title = to_text_string(title) + " - " + _("NumPy object array") + else: + title = _("Array editor") + if readonly: + title += ' (' + _('read only') + ')' + self.setWindowTitle(title) + + # ---- Stack widget + self.stack = QStackedWidget(self) + if is_record_array: + for name in data.dtype.names: + self.stack.addWidget(ArrayEditorWidget(self, data[name], + readonly, xlabels, + ylabels)) + elif is_masked_array: + self.stack.addWidget(ArrayEditorWidget(self, data, readonly, + xlabels, ylabels)) + self.stack.addWidget(ArrayEditorWidget(self, data.data, readonly, + xlabels, ylabels)) + self.stack.addWidget(ArrayEditorWidget(self, data.mask, readonly, + xlabels, ylabels)) + elif data.ndim == 3: + # We create here the necessary widgets for current_dim_changed to + # work. The rest are created below. + # QSpinBox + self.index_spin = QSpinBox(self, keyboardTracking=False) + self.index_spin.valueChanged.connect(self.change_active_widget) + + # Labels + self.shape_label = QLabel() + self.slicing_label = QLabel() + + # Set the widget to display when launched + self.current_dim_changed(self.last_dim) + else: + self.stack.addWidget(ArrayEditorWidget(self, data, readonly, + xlabels, ylabels)) + + self.arraywidget = self.stack.currentWidget() + self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) + self.stack.currentChanged.connect(self.current_widget_changed) + self.layout.addWidget(self.stack, 1, 0) + + # ---- Top row of buttons + btn_layout_top = None + if is_record_array or is_masked_array or data.ndim == 3: + btn_layout_top = QHBoxLayout() + + if is_record_array: + btn_layout_top.addWidget(QLabel(_("Record array fields:"))) + names = [] + for name in data.dtype.names: + field = data.dtype.fields[name] + text = name + if len(field) >= 3: + title = field[2] + if not is_text_string(title): + title = repr(title) + text += ' - '+title + names.append(text) + else: + names = [_('Masked data'), _('Data'), _('Mask')] + + if data.ndim == 3: + # QComboBox + names = [str(i) for i in range(3)] + ra_combo = QComboBox(self) + ra_combo.addItems(names) + ra_combo.currentIndexChanged.connect(self.current_dim_changed) + + # Adding the widgets to layout + label = QLabel(_("Axis:")) + btn_layout_top.addWidget(label) + btn_layout_top.addWidget(ra_combo) + btn_layout_top.addWidget(self.shape_label) + + label = QLabel(_("Index:")) + btn_layout_top.addWidget(label) + btn_layout_top.addWidget(self.index_spin) + + btn_layout_top.addWidget(self.slicing_label) + else: + ra_combo = QComboBox(self) + ra_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) + ra_combo.addItems(names) + btn_layout_top.addWidget(ra_combo) + + if is_masked_array: + label = QLabel( + _("Warning: Changes are applied separately") + ) + label.setToolTip(_("For performance reasons, changes applied " + "to masked arrays won't be reflected in " + "array's data (and vice-versa).")) + btn_layout_top.addWidget(label) + + btn_layout_top.addStretch() + + # ---- Bottom row of buttons + btn_layout_bottom = QHBoxLayout() + + btn_format = QPushButton(_("Format")) + # disable format button for int type + btn_format.setEnabled(is_float(self.arraywidget.data.dtype)) + btn_layout_bottom.addWidget(btn_format) + btn_format.clicked.connect(lambda: self.arraywidget.change_format()) + + btn_resize = QPushButton(_("Resize")) + btn_layout_bottom.addWidget(btn_resize) + btn_resize.clicked.connect( + lambda: self.arraywidget.view.resize_to_contents()) + + self.bgcolor = QCheckBox(_('Background color')) + self.bgcolor.setEnabled(self.arraywidget.model.bgcolor_enabled) + self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) + self.bgcolor.stateChanged.connect( + lambda state: self.arraywidget.model.bgcolor(state)) + btn_layout_bottom.addWidget(self.bgcolor) + + btn_layout_bottom.addStretch() + + if not readonly: + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout_bottom.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout_bottom.addWidget(self.btn_close) + + # ---- Final layout + btn_layout_bottom.setContentsMargins(4, 4, 4, 4) + if btn_layout_top is not None: + btn_layout_top.setContentsMargins(4, 4, 4, 4) + self.layout.addLayout(btn_layout_top, 2, 0) + self.layout.addLayout(btn_layout_bottom, 3, 0) + else: + self.layout.addLayout(btn_layout_bottom, 2, 0) + + # Set minimum size + self.setMinimumSize(500, 300) + + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + + return True + + @Slot(QModelIndex, QModelIndex) + def save_and_close_enable(self, left_top, bottom_right): + """Handle the data change event to enable the save and close button.""" + if self.btn_save_and_close: + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def current_widget_changed(self, index): + self.arraywidget = self.stack.widget(index) + self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) + self.bgcolor.setChecked(self.arraywidget.model.bgcolor_enabled) + + def change_active_widget(self, index): + """ + This is implemented for handling negative values in index for + 3d arrays, to give the same behavior as slicing + """ + string_index = [':']*3 + string_index[self.last_dim] = '%i' + self.slicing_label.setText((r"Slicing: [" + ", ".join(string_index) + + "]") % index) + if index < 0: + data_index = self.data.shape[self.last_dim] + index + else: + data_index = index + slice_index = [slice(None)]*3 + slice_index[self.last_dim] = data_index + + stack_index = self.dim_indexes[self.last_dim].get(data_index) + if stack_index is None: + stack_index = self.stack.count() + try: + self.stack.addWidget(ArrayEditorWidget( + self, self.data[tuple(slice_index)])) + except IndexError: # Handle arrays of size 0 in one axis + self.stack.addWidget(ArrayEditorWidget(self, self.data)) + self.dim_indexes[self.last_dim][data_index] = stack_index + self.stack.update() + self.stack.setCurrentIndex(stack_index) + + def current_dim_changed(self, index): + """ + This change the active axis the array editor is plotting over + in 3D + """ + self.last_dim = index + string_size = ['%i']*3 + string_size[index] = '%i' + self.shape_label.setText(('Shape: (' + ', '.join(string_size) + + ') ') % self.data.shape) + if self.index_spin.value() != 0: + self.index_spin.setValue(0) + else: + # this is done since if the value is currently 0 it does not emit + # currentIndexChanged(int) + self.change_active_widget(0) + self.index_spin.setRange(-self.data.shape[index], + self.data.shape[index]-1) + + @Slot() + def accept(self): + """Reimplement Qt method.""" + try: + for index in range(self.stack.count()): + self.stack.widget(index).accept_changes() + QDialog.accept(self) + except RuntimeError: + # Sometimes under CI testing the object the following error appears + # RuntimeError: wrapped C/C++ object has been deleted + pass + + def get_value(self): + """Return modified array -- this is *not* a copy""" + # It is important to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.data + + def error(self, message): + """An error occurred, closing the dialog box""" + QMessageBox.critical(self, _("Array editor"), message) + self.setAttribute(Qt.WA_DeleteOnClose) + self.reject() + + @Slot() + def reject(self): + """Reimplement Qt method""" + if self.arraywidget is not None: + for index in range(self.stack.count()): + self.stack.widget(index).reject_changes() + QDialog.reject(self) diff --git a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py index 911689d19b6..e74751dd105 100644 --- a/spyder/plugins/variableexplorer/widgets/dataframeeditor.py +++ b/spyder/plugins/variableexplorer/widgets/dataframeeditor.py @@ -1,1430 +1,1430 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2011-2012 Lambda Foundry, Inc. and PyData Development Team -# Copyright (c) 2013 Jev Kuznetsov and contributors -# Copyright (c) 2014-2015 Scott Hansen -# Copyright (c) 2014-2016 Yuri D'Elia "wave++" -# Copyright (c) 2014- Spyder Project Contributors -# -# Components of gtabview originally distributed under the MIT (Expat) license. -# This file as a whole distributed under the terms of the New BSD License -# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). -# ----------------------------------------------------------------------------- - -""" -Pandas DataFrame Editor Dialog. - -DataFrameModel is based on the class ArrayModel from array editor -and the class DataFrameModel from the pandas project. -Present in pandas.sandbox.qtpandas in v0.13.1. - -DataFrameHeaderModel and DataFrameLevelModel are based on the classes -Header4ExtModel and Level4ExtModel from the gtabview project. -DataFrameModel is based on the classes ExtDataModel and ExtFrameModel, and -DataFrameEditor is based on gtExtTableView from the same project. - -DataFrameModel originally based on pandas/sandbox/qtpandas.py of the -`pandas project `_. -The current version is qtpandas/models/DataFrameModel.py of the -`QtPandas project `_. - -Components of gtabview from gtabview/viewer.py and gtabview/models.py of the -`gtabview project `_. -""" - -# Standard library imports - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QAbstractTableModel, QModelIndex, Qt, Signal, Slot, - QItemSelectionModel, QEvent) -from qtpy.QtGui import QColor, QCursor -from qtpy.QtWidgets import (QApplication, QCheckBox, QDialog, QGridLayout, - QHBoxLayout, QInputDialog, QLineEdit, QMenu, - QMessageBox, QPushButton, QTableView, - QScrollBar, QTableWidget, QFrame, - QItemDelegate) -from spyder_kernels.utils.lazymodules import numpy as np, pandas as pd - -# Local imports -from spyder.api.config.mixins import SpyderConfigurationAccessor -from spyder.config.base import _ -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.config.gui import get_font -from spyder.py3compat import (io, is_text_string, is_type_text_string, PY2, - to_text_string, perf_counter) -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import (add_actions, create_action, - keybinding, qapplication) -from spyder.plugins.variableexplorer.widgets.arrayeditor import get_idx_rect -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog - -# Supported Numbers and complex numbers -REAL_NUMBER_TYPES = (float, int, np.int64, np.int32) -COMPLEX_NUMBER_TYPES = (complex, np.complex64, np.complex128) -# Used to convert bool intrance to false since bool('False') will return True -_bool_false = ['false', 'f', '0', '0.', '0.0', ' '] - -# Default format for data frames with floats -DEFAULT_FORMAT = '%.6g' - -# Limit at which dataframe is considered so large that it is loaded on demand -LARGE_SIZE = 5e5 -LARGE_NROWS = 1e5 -LARGE_COLS = 60 -ROWS_TO_LOAD = 500 -COLS_TO_LOAD = 40 - -# Background colours -BACKGROUND_NUMBER_MINHUE = 0.66 # hue for largest number -BACKGROUND_NUMBER_HUERANGE = 0.33 # (hue for smallest) minus (hue for largest) -BACKGROUND_NUMBER_SATURATION = 0.7 -BACKGROUND_NUMBER_VALUE = 1.0 -BACKGROUND_NUMBER_ALPHA = 0.6 -BACKGROUND_NONNUMBER_COLOR = Qt.lightGray -BACKGROUND_INDEX_ALPHA = 0.8 -BACKGROUND_STRING_ALPHA = 0.05 -BACKGROUND_MISC_ALPHA = 0.3 - - -def bool_false_check(value): - """ - Used to convert bool entrance to false. - - Needed since any string in bool('') will return True. - """ - if value.lower() in _bool_false: - value = '' - return value - - -def global_max(col_vals, index): - """Returns the global maximum and minimum.""" - col_vals_without_None = [x for x in col_vals if x is not None] - max_col, min_col = zip(*col_vals_without_None) - return max(max_col), min(min_col) - - -class DataFrameModel(QAbstractTableModel): - """ - DataFrame Table Model. - - Partly based in ExtDataModel and ExtFrameModel classes - of the gtabview project. - - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/models.py - """ - - def __init__(self, dataFrame, format=DEFAULT_FORMAT, parent=None): - QAbstractTableModel.__init__(self) - self.dialog = parent - self.df = dataFrame - self.df_columns_list = None - self.df_index_list = None - self._format = format - self.complex_intran = None - self.display_error_idxs = [] - - self.total_rows = self.df.shape[0] - self.total_cols = self.df.shape[1] - size = self.total_rows * self.total_cols - - self.max_min_col = None - if size < LARGE_SIZE: - self.max_min_col_update() - self.colum_avg_enabled = True - self.bgcolor_enabled = True - self.colum_avg(1) - else: - self.colum_avg_enabled = False - self.bgcolor_enabled = False - self.colum_avg(0) - - # Use paging when the total size, number of rows or number of - # columns is too large - if size > LARGE_SIZE: - self.rows_loaded = ROWS_TO_LOAD - self.cols_loaded = COLS_TO_LOAD - else: - if self.total_rows > LARGE_NROWS: - self.rows_loaded = ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - if self.total_cols > LARGE_COLS: - self.cols_loaded = COLS_TO_LOAD - else: - self.cols_loaded = self.total_cols - - def _axis(self, axis): - """ - Return the corresponding labels taking into account the axis. - - The axis could be horizontal (0) or vertical (1). - """ - return self.df.columns if axis == 0 else self.df.index - - def _axis_list(self, axis): - """ - Return the corresponding labels as a list taking into account the axis. - - The axis could be horizontal (0) or vertical (1). - """ - if axis == 0: - if self.df_columns_list is None: - self.df_columns_list = self.df.columns.tolist() - return self.df_columns_list - else: - if self.df_index_list is None: - self.df_index_list = self.df.index.tolist() - return self.df_index_list - - def _axis_levels(self, axis): - """ - Return the number of levels in the labels taking into account the axis. - - Get the number of levels for the columns (0) or rows (1). - """ - ax = self._axis(axis) - return 1 if not hasattr(ax, 'levels') else len(ax.levels) - - @property - def shape(self): - """Return the shape of the dataframe.""" - return self.df.shape - - @property - def header_shape(self): - """Return the levels for the columns and rows of the dataframe.""" - return (self._axis_levels(0), self._axis_levels(1)) - - @property - def chunk_size(self): - """Return the max value of the dimensions of the dataframe.""" - return max(*self.shape()) - - def header(self, axis, x, level=0): - """ - Return the values of the labels for the header of columns or rows. - - The value corresponds to the header of column or row x in the - given level. - """ - ax = self._axis(axis) - if not hasattr(ax, 'levels'): - ax = self._axis_list(axis) - return ax[x] - else: - return ax.values[x][level] - - def name(self, axis, level): - """Return the labels of the levels if any.""" - ax = self._axis(axis) - if hasattr(ax, 'levels'): - return ax.names[level] - if ax.name: - return ax.name - - def max_min_col_update(self): - """ - Determines the maximum and minimum number in each column. - - The result is a list whose k-th entry is [vmax, vmin], where vmax and - vmin denote the maximum and minimum of the k-th column (ignoring NaN). - This list is stored in self.max_min_col. - - If the k-th column has a non-numerical dtype, then the k-th entry - is set to None. If the dtype is complex, then compute the maximum and - minimum of the absolute values. If vmax equals vmin, then vmin is - decreased by one. - """ - if self.df.shape[0] == 0: # If no rows to compute max/min then return - return - self.max_min_col = [] - for __, col in self.df.iteritems(): - # This is necessary to catch an error in Pandas when computing - # the maximum of a column. - # Fixes spyder-ide/spyder#17145 - try: - if col.dtype in REAL_NUMBER_TYPES + COMPLEX_NUMBER_TYPES: - if col.dtype in REAL_NUMBER_TYPES: - vmax = col.max(skipna=True) - vmin = col.min(skipna=True) - else: - vmax = col.abs().max(skipna=True) - vmin = col.abs().min(skipna=True) - if vmax != vmin: - max_min = [vmax, vmin] - else: - max_min = [vmax, vmin - 1] - else: - max_min = None - except TypeError: - max_min = None - self.max_min_col.append(max_min) - - def get_format(self): - """Return current format""" - # Avoid accessing the private attribute _format from outside - return self._format - - def set_format(self, format): - """Change display format""" - self._format = format - self.reset() - - def bgcolor(self, state): - """Toggle backgroundcolor""" - self.bgcolor_enabled = state > 0 - self.reset() - - def colum_avg(self, state): - """Toggle backgroundcolor""" - self.colum_avg_enabled = state > 0 - if self.colum_avg_enabled: - self.return_max = lambda col_vals, index: col_vals[index] - else: - self.return_max = global_max - self.reset() - - def get_bgcolor(self, index): - """Background color depending on value.""" - column = index.column() - - if not self.bgcolor_enabled: - return - - value = self.get_value(index.row(), column) - if self.max_min_col[column] is None or pd.isna(value): - color = QColor(BACKGROUND_NONNUMBER_COLOR) - if is_text_string(value): - color.setAlphaF(BACKGROUND_STRING_ALPHA) - else: - color.setAlphaF(BACKGROUND_MISC_ALPHA) - else: - if isinstance(value, COMPLEX_NUMBER_TYPES): - color_func = abs - else: - color_func = float - vmax, vmin = self.return_max(self.max_min_col, column) - - # This is necessary to catch an error in Pandas when computing - # the difference between the max and min of a column. - # Fixes spyder-ide/spyder#18005 - try: - if vmax - vmin == 0: - vmax_vmin_diff = 1.0 - else: - vmax_vmin_diff = vmax - vmin - except TypeError: - return - - hue = (BACKGROUND_NUMBER_MINHUE + BACKGROUND_NUMBER_HUERANGE * - (vmax - color_func(value)) / (vmax_vmin_diff)) - hue = float(abs(hue)) - if hue > 1: - hue = 1 - color = QColor.fromHsvF(hue, BACKGROUND_NUMBER_SATURATION, - BACKGROUND_NUMBER_VALUE, - BACKGROUND_NUMBER_ALPHA) - - return color - - def get_value(self, row, column): - """Return the value of the DataFrame.""" - # To increase the performance iat is used but that requires error - # handling, so fallback uses iloc - try: - value = self.df.iat[row, column] - except pd._libs.tslib.OutOfBoundsDatetime: - value = self.df.iloc[:, column].astype(str).iat[row] - except: - value = self.df.iloc[row, column] - return value - - def data(self, index, role=Qt.DisplayRole): - """Cell content""" - if not index.isValid(): - return to_qvariant() - if role == Qt.DisplayRole or role == Qt.EditRole: - column = index.column() - row = index.row() - value = self.get_value(row, column) - if isinstance(value, float): - try: - return to_qvariant(self._format % value) - except (ValueError, TypeError): - # may happen if format = '%d' and value = NaN; - # see spyder-ide/spyder#4139. - return to_qvariant(DEFAULT_FORMAT % value) - elif is_type_text_string(value): - # Don't perform any conversion on strings - # because it leads to differences between - # the data present in the dataframe and - # what is shown by Spyder - return value - else: - try: - return to_qvariant(to_text_string(value)) - except Exception: - self.display_error_idxs.append(index) - return u'Display Error!' - elif role == Qt.BackgroundColorRole: - return to_qvariant(self.get_bgcolor(index)) - elif role == Qt.FontRole: - return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - elif role == Qt.ToolTipRole: - if index in self.display_error_idxs: - return _("It is not possible to display this value because\n" - "an error ocurred while trying to do it") - return to_qvariant() - - def recalculate_index(self): - """Recalcuate index information.""" - self.df_index_list = self.df.index.tolist() - - def sort(self, column, order=Qt.AscendingOrder): - """Overriding sort method""" - if self.complex_intran is not None: - if self.complex_intran.any(axis=0).iloc[column]: - QMessageBox.critical(self.dialog, "Error", - "TypeError error: no ordering " - "relation is defined for complex numbers") - return False - try: - ascending = order == Qt.AscendingOrder - if column >= 0: - try: - self.df.sort_values(by=self.df.columns[column], - ascending=ascending, inplace=True, - kind='mergesort') - except AttributeError: - # for pandas version < 0.17 - self.df.sort(columns=self.df.columns[column], - ascending=ascending, inplace=True, - kind='mergesort') - except ValueError as e: - # Not possible to sort on duplicate columns - # See spyder-ide/spyder#5225. - QMessageBox.critical(self.dialog, "Error", - "ValueError: %s" % to_text_string(e)) - except SystemError as e: - # Not possible to sort on category dtypes - # See spyder-ide/spyder#5361. - QMessageBox.critical(self.dialog, "Error", - "SystemError: %s" % to_text_string(e)) - else: - # Update index list - self.recalculate_index() - # To sort by index - self.df.sort_index(inplace=True, ascending=ascending) - except TypeError as e: - QMessageBox.critical(self.dialog, "Error", - "TypeError error: %s" % str(e)) - return False - - self.reset() - return True - - def flags(self, index): - """Set flags""" - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | - Qt.ItemIsEditable)) - - def setData(self, index, value, role=Qt.EditRole, change_type=None): - """Cell content change""" - column = index.column() - row = index.row() - - if index in self.display_error_idxs: - return False - if change_type is not None: - try: - value = self.data(index, role=Qt.DisplayRole) - val = from_qvariant(value, str) - if change_type is bool: - val = bool_false_check(val) - self.df.iloc[row, column] = change_type(val) - except ValueError: - self.df.iloc[row, column] = change_type('0') - else: - val = from_qvariant(value, str) - current_value = self.get_value(row, column) - if isinstance(current_value, (bool, np.bool_)): - val = bool_false_check(val) - supported_types = (bool, np.bool_) + REAL_NUMBER_TYPES - if (isinstance(current_value, supported_types) or - is_text_string(current_value)): - try: - self.df.iloc[row, column] = current_value.__class__(val) - except (ValueError, OverflowError) as e: - QMessageBox.critical(self.dialog, "Error", - str(type(e).__name__) + ": " + str(e)) - return False - else: - QMessageBox.critical(self.dialog, "Error", - "Editing dtype {0!s} not yet supported." - .format(type(current_value).__name__)) - return False - self.max_min_col_update() - self.dataChanged.emit(index, index) - return True - - def get_data(self): - """Return data""" - return self.df - - def rowCount(self, index=QModelIndex()): - """DataFrame row number""" - # Avoid a "Qt exception in virtual methods" generated in our - # tests on Windows/Python 3.7 - # See spyder-ide/spyder#8910. - try: - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - except AttributeError: - return 0 - - def fetch_more(self, rows=False, columns=False): - """Get more columns and/or rows.""" - if rows and self.total_rows > self.rows_loaded: - reminder = self.total_rows - self.rows_loaded - items_to_fetch = min(reminder, ROWS_TO_LOAD) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - if columns and self.total_cols > self.cols_loaded: - reminder = self.total_cols - self.cols_loaded - items_to_fetch = min(reminder, COLS_TO_LOAD) - self.beginInsertColumns(QModelIndex(), self.cols_loaded, - self.cols_loaded + items_to_fetch - 1) - self.cols_loaded += items_to_fetch - self.endInsertColumns() - - def columnCount(self, index=QModelIndex()): - """DataFrame column number""" - # Avoid a "Qt exception in virtual methods" generated in our - # tests on Windows/Python 3.7 - # See spyder-ide/spyder#8910. - try: - # This is done to implement series - if len(self.df.shape) == 1: - return 2 - elif self.total_cols <= self.cols_loaded: - return self.total_cols - else: - return self.cols_loaded - except AttributeError: - return 0 - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class DataFrameView(QTableView, SpyderConfigurationAccessor): - """ - Data Frame view class. - - Signals - ------- - sig_sort_by_column(): Raised after more columns are fetched. - sig_fetch_more_rows(): Raised after more rows are fetched. - """ - sig_sort_by_column = Signal() - sig_fetch_more_columns = Signal() - sig_fetch_more_rows = Signal() - - CONF_SECTION = 'variable_explorer' - - def __init__(self, parent, model, header, hscroll, vscroll): - """Constructor.""" - QTableView.__init__(self, parent) - self.setModel(model) - self.setHorizontalScrollBar(hscroll) - self.setVerticalScrollBar(vscroll) - self.setHorizontalScrollMode(QTableView.ScrollPerPixel) - self.setVerticalScrollMode(QTableView.ScrollPerPixel) - - self.sort_old = [None] - self.header_class = header - self.header_class.sectionClicked.connect(self.sortByColumn) - self.menu = self.setup_menu() - self.config_shortcut(self.copy, 'copy', self) - self.horizontalScrollBar().valueChanged.connect( - self._load_more_columns) - self.verticalScrollBar().valueChanged.connect(self._load_more_rows) - - def _load_more_columns(self, value): - """Load more columns to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, columns=True) - except NameError: - pass - - def _load_more_rows(self, value): - """Load more rows to display.""" - # Needed to avoid a NameError while fetching data when closing - # See spyder-ide/spyder#12034. - try: - self.load_more_data(value, rows=True) - except NameError: - pass - - def load_more_data(self, value, rows=False, columns=False): - """Load more rows and columns to display.""" - try: - if rows and value == self.verticalScrollBar().maximum(): - self.model().fetch_more(rows=rows) - self.sig_fetch_more_rows.emit() - if columns and value == self.horizontalScrollBar().maximum(): - self.model().fetch_more(columns=columns) - self.sig_fetch_more_columns.emit() - - except NameError: - # Needed to handle a NameError while fetching data when closing - # See spyder-ide/spyder#7880. - pass - - def sortByColumn(self, index): - """Implement a column sort.""" - if self.sort_old == [None]: - self.header_class.setSortIndicatorShown(True) - sort_order = self.header_class.sortIndicatorOrder() - if not self.model().sort(index, sort_order): - if len(self.sort_old) != 2: - self.header_class.setSortIndicatorShown(False) - else: - self.header_class.setSortIndicator(self.sort_old[0], - self.sort_old[1]) - return - self.sort_old = [index, self.header_class.sortIndicatorOrder()] - self.sig_sort_by_column.emit() - - def contextMenuEvent(self, event): - """Reimplement Qt method.""" - self.menu.popup(event.globalPos()) - event.accept() - - def setup_menu(self): - """Setup context menu.""" - copy_action = create_action(self, _('Copy'), - shortcut=keybinding('Copy'), - icon=ima.icon('editcopy'), - triggered=self.copy, - context=Qt.WidgetShortcut) - functions = ((_("To bool"), bool), (_("To complex"), complex), - (_("To int"), int), (_("To float"), float), - (_("To str"), to_text_string)) - types_in_menu = [copy_action] - for name, func in functions: - def slot(): - self.change_type(func) - types_in_menu += [create_action(self, name, - triggered=slot, - context=Qt.WidgetShortcut)] - menu = QMenu(self) - add_actions(menu, types_in_menu) - return menu - - def change_type(self, func): - """A function that changes types of cells.""" - model = self.model() - index_list = self.selectedIndexes() - [model.setData(i, '', change_type=func) for i in index_list] - - @Slot() - def copy(self): - """Copy text to clipboard""" - if not self.selectedIndexes(): - return - (row_min, row_max, - col_min, col_max) = get_idx_rect(self.selectedIndexes()) - # Copy index and header too (equal True). - # See spyder-ide/spyder#11096 - index = header = True - df = self.model().df - obj = df.iloc[slice(row_min, row_max + 1), - slice(col_min, col_max + 1)] - output = io.StringIO() - try: - obj.to_csv(output, sep='\t', index=index, header=header) - except UnicodeEncodeError: - # Needed to handle encoding errors in Python 2 - # See spyder-ide/spyder#4833 - QMessageBox.critical( - self, - _("Error"), - _("Text can't be copied.")) - if not PY2: - contents = output.getvalue() - else: - contents = output.getvalue().decode('utf-8') - output.close() - clipboard = QApplication.clipboard() - clipboard.setText(contents) - - -class DataFrameHeaderModel(QAbstractTableModel): - """ - This class is the model for the header or index of the DataFrameEditor. - - Taken from gtabview project (Header4ExtModel). - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py - """ - - COLUMN_INDEX = -1 # Makes reference to the index of the table. - - def __init__(self, model, axis, palette): - """ - Header constructor. - - The 'model' is the QAbstractTableModel of the dataframe, the 'axis' is - to acknowledge if is for the header (horizontal - 0) or for the - index (vertical - 1) and the palette is the set of colors to use. - """ - super(DataFrameHeaderModel, self).__init__() - self.model = model - self.axis = axis - self._palette = palette - self.total_rows = self.model.shape[0] - self.total_cols = self.model.shape[1] - size = self.total_rows * self.total_cols - - # Use paging when the total size, number of rows or number of - # columns is too large - if size > LARGE_SIZE: - self.rows_loaded = ROWS_TO_LOAD - self.cols_loaded = COLS_TO_LOAD - else: - if self.total_cols > LARGE_COLS: - self.cols_loaded = COLS_TO_LOAD - else: - self.cols_loaded = self.total_cols - if self.total_rows > LARGE_NROWS: - self.rows_loaded = ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - - if self.axis == 0: - self.total_cols = self.model.shape[1] - self._shape = (self.model.header_shape[0], self.model.shape[1]) - else: - self.total_rows = self.model.shape[0] - self._shape = (self.model.shape[0], self.model.header_shape[1]) - - def rowCount(self, index=None): - """Get number of rows in the header.""" - if self.axis == 0: - return max(1, self._shape[0]) - else: - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - - def columnCount(self, index=QModelIndex()): - """DataFrame column number""" - if self.axis == 0: - if self.total_cols <= self.cols_loaded: - return self.total_cols - else: - return self.cols_loaded - else: - return max(1, self._shape[1]) - - def fetch_more(self, rows=False, columns=False): - """Get more columns or rows (based on axis).""" - if self.axis == 1 and self.total_rows > self.rows_loaded: - reminder = self.total_rows - self.rows_loaded - items_to_fetch = min(reminder, ROWS_TO_LOAD) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - if self.axis == 0 and self.total_cols > self.cols_loaded: - reminder = self.total_cols - self.cols_loaded - items_to_fetch = min(reminder, COLS_TO_LOAD) - self.beginInsertColumns(QModelIndex(), self.cols_loaded, - self.cols_loaded + items_to_fetch - 1) - self.cols_loaded += items_to_fetch - self.endInsertColumns() - - def sort(self, column, order=Qt.AscendingOrder): - """Overriding sort method.""" - ascending = order == Qt.AscendingOrder - self.model.sort(self.COLUMN_INDEX, order=ascending) - return True - - def headerData(self, section, orientation, role): - """Get the information to put in the header.""" - if role == Qt.TextAlignmentRole: - if orientation == Qt.Horizontal: - return Qt.AlignCenter - else: - return int(Qt.AlignRight | Qt.AlignVCenter) - if role != Qt.DisplayRole and role != Qt.ToolTipRole: - return None - if self.axis == 1 and self._shape[1] <= 1: - return None - orient_axis = 0 if orientation == Qt.Horizontal else 1 - if self.model.header_shape[orient_axis] > 1: - header = section - else: - header = self.model.header(self.axis, section) - - # Don't perform any conversion on strings - # because it leads to differences between - # the data present in the dataframe and - # what is shown by Spyder - if not is_type_text_string(header): - header = to_text_string(header) - - return header - - def data(self, index, role): - """ - Get the data for the header. - - This is used when a header has levels. - """ - if not index.isValid() or \ - index.row() >= self._shape[0] or \ - index.column() >= self._shape[1]: - return None - row, col = ((index.row(), index.column()) if self.axis == 0 - else (index.column(), index.row())) - if role != Qt.DisplayRole: - return None - if self.axis == 0 and self._shape[0] <= 1: - return None - - header = self.model.header(self.axis, col, row) - - # Don't perform any conversion on strings - # because it leads to differences between - # the data present in the dataframe and - # what is shown by Spyder - if not is_type_text_string(header): - header = to_text_string(header) - - return header - - -class DataFrameLevelModel(QAbstractTableModel): - """ - Data Frame level class. - - This class is used to represent index levels in the DataFrameEditor. When - using MultiIndex, this model creates labels for the index/header as Index i - for each section in the index/header - - Based on the gtabview project (Level4ExtModel). - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py - """ - - def __init__(self, model, palette, font): - super(DataFrameLevelModel, self).__init__() - self.model = model - self._background = palette.dark().color() - if self._background.lightness() > 127: - self._foreground = palette.text() - else: - self._foreground = palette.highlightedText() - self._palette = palette - font.setBold(True) - self._font = font - - def rowCount(self, index=None): - """Get number of rows (number of levels for the header).""" - return max(1, self.model.header_shape[0]) - - def columnCount(self, index=None): - """Get the number of columns (number of levels for the index).""" - return max(1, self.model.header_shape[1]) - - def headerData(self, section, orientation, role): - """ - Get the text to put in the header of the levels of the indexes. - - By default it returns 'Index i', where i is the section in the index - """ - if role == Qt.TextAlignmentRole: - if orientation == Qt.Horizontal: - return Qt.AlignCenter - else: - return int(Qt.AlignRight | Qt.AlignVCenter) - if role != Qt.DisplayRole and role != Qt.ToolTipRole: - return None - if self.model.header_shape[0] <= 1 and orientation == Qt.Horizontal: - if self.model.name(1,section): - return self.model.name(1,section) - return _('Index') - elif self.model.header_shape[0] <= 1: - return None - elif self.model.header_shape[1] <= 1 and orientation == Qt.Vertical: - return None - return _('Index') + ' ' + to_text_string(section) - - def data(self, index, role): - """Get the information of the levels.""" - if not index.isValid(): - return None - if role == Qt.FontRole: - return self._font - label = '' - if index.column() == self.model.header_shape[1] - 1: - label = str(self.model.name(0, index.row())) - elif index.row() == self.model.header_shape[0] - 1: - label = str(self.model.name(1, index.column())) - if role == Qt.DisplayRole and label: - return label - elif role == Qt.ForegroundRole: - return self._foreground - elif role == Qt.BackgroundRole: - return self._background - elif role == Qt.BackgroundRole: - return self._palette.window() - return None - - -class DataFrameEditor(BaseDialog, SpyderConfigurationAccessor): - """ - Dialog for displaying and editing DataFrame and related objects. - - Based on the gtabview project (ExtTableView). - For more information please see: - https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py - """ - CONF_SECTION = 'variable_explorer' - - def __init__(self, parent=None): - super().__init__(parent) - - # Destroying the C++ object right after closing the dialog box, - # otherwise it may be garbage-collected in another QThread - # (e.g. the editor's analysis thread in Spyder), thus leading to - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - self.is_series = False - self.layout = None - - def setup_and_check(self, data, title=''): - """ - Setup DataFrameEditor: - return False if data is not supported, True otherwise. - Supported types for data are DataFrame, Series and Index. - """ - self._selection_rec = False - self._model = None - - self.layout = QGridLayout() - self.layout.setSpacing(0) - self.layout.setContentsMargins(20, 20, 20, 0) - self.setLayout(self.layout) - if title: - title = to_text_string(title) + " - %s" % data.__class__.__name__ - else: - title = _("%s editor") % data.__class__.__name__ - if isinstance(data, pd.Series): - self.is_series = True - data = data.to_frame() - elif isinstance(data, pd.Index): - data = pd.DataFrame(data) - - self.setWindowTitle(title) - - self.hscroll = QScrollBar(Qt.Horizontal) - self.vscroll = QScrollBar(Qt.Vertical) - - # Create the view for the level - self.create_table_level() - - # Create the view for the horizontal header - self.create_table_header() - - # Create the view for the vertical index - self.create_table_index() - - # Create the model and view of the data - self.dataModel = DataFrameModel(data, parent=self) - self.dataModel.dataChanged.connect(self.save_and_close_enable) - self.create_data_table() - - self.layout.addWidget(self.hscroll, 2, 0, 1, 2) - self.layout.addWidget(self.vscroll, 0, 2, 2, 1) - - # autosize columns on-demand - self._autosized_cols = set() - # Set limit time to calculate column sizeHint to 300ms, - # See spyder-ide/spyder#11060 - self._max_autosize_ms = 300 - self.dataTable.installEventFilter(self) - - avg_width = self.fontMetrics().averageCharWidth() - self.min_trunc = avg_width * 12 # Minimum size for columns - self.max_width = avg_width * 64 # Maximum size for columns - - self.setLayout(self.layout) - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - btn_layout = QHBoxLayout() - btn_layout.setSpacing(5) - - btn_format = QPushButton(_("Format")) - # disable format button for int type - btn_layout.addWidget(btn_format) - btn_format.clicked.connect(self.change_format) - - btn_resize = QPushButton(_('Resize')) - btn_layout.addWidget(btn_resize) - btn_resize.clicked.connect(self.resize_to_contents) - - bgcolor = QCheckBox(_('Background color')) - bgcolor.setChecked(self.dataModel.bgcolor_enabled) - bgcolor.setEnabled(self.dataModel.bgcolor_enabled) - bgcolor.stateChanged.connect(self.change_bgcolor_enable) - btn_layout.addWidget(bgcolor) - - self.bgcolor_global = QCheckBox(_('Column min/max')) - self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) - self.bgcolor_global.setEnabled(not self.is_series and - self.dataModel.bgcolor_enabled) - self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) - btn_layout.addWidget(self.bgcolor_global) - - btn_layout.addStretch() - - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout.addWidget(self.btn_close) - - btn_layout.setContentsMargins(0, 16, 0, 16) - self.layout.addLayout(btn_layout, 4, 0, 1, 2) - self.setModel(self.dataModel) - self.resizeColumnsToContents() - - format = '%' + self.get_conf('dataframe_format') - self.dataModel.set_format(format) - - return True - - @Slot(QModelIndex, QModelIndex) - def save_and_close_enable(self, top_left, bottom_right): - """Handle the data change event to enable the save and close button.""" - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def create_table_level(self): - """Create the QTableView that will hold the level model.""" - self.table_level = QTableView() - self.table_level.setEditTriggers(QTableWidget.NoEditTriggers) - self.table_level.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_level.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_level.setFrameStyle(QFrame.Plain) - self.table_level.horizontalHeader().sectionResized.connect( - self._index_resized) - self.table_level.verticalHeader().sectionResized.connect( - self._header_resized) - self.table_level.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.table_level, 0, 0) - self.table_level.setContentsMargins(0, 0, 0, 0) - self.table_level.horizontalHeader().sectionClicked.connect( - self.sortByIndex) - - def create_table_header(self): - """Create the QTableView that will hold the header model.""" - self.table_header = QTableView() - self.table_header.verticalHeader().hide() - self.table_header.setEditTriggers(QTableWidget.NoEditTriggers) - self.table_header.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_header.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_header.setHorizontalScrollMode(QTableView.ScrollPerPixel) - self.table_header.setHorizontalScrollBar(self.hscroll) - self.table_header.setFrameStyle(QFrame.Plain) - self.table_header.horizontalHeader().sectionResized.connect( - self._column_resized) - self.table_header.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.table_header, 0, 1) - - def create_table_index(self): - """Create the QTableView that will hold the index model.""" - self.table_index = QTableView() - self.table_index.horizontalHeader().hide() - self.table_index.setEditTriggers(QTableWidget.NoEditTriggers) - self.table_index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_index.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.table_index.setVerticalScrollMode(QTableView.ScrollPerPixel) - self.table_index.setVerticalScrollBar(self.vscroll) - self.table_index.setFrameStyle(QFrame.Plain) - self.table_index.verticalHeader().sectionResized.connect( - self._row_resized) - self.table_index.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.table_index, 1, 0) - self.table_index.setContentsMargins(0, 0, 0, 0) - - def create_data_table(self): - """Create the QTableView that will hold the data model.""" - self.dataTable = DataFrameView(self, self.dataModel, - self.table_header.horizontalHeader(), - self.hscroll, self.vscroll) - self.dataTable.verticalHeader().hide() - self.dataTable.horizontalHeader().hide() - self.dataTable.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.dataTable.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.dataTable.setHorizontalScrollMode(QTableView.ScrollPerPixel) - self.dataTable.setVerticalScrollMode(QTableView.ScrollPerPixel) - self.dataTable.setFrameStyle(QFrame.Plain) - self.dataTable.setItemDelegate(QItemDelegate()) - self.layout.addWidget(self.dataTable, 1, 1) - self.setFocusProxy(self.dataTable) - self.dataTable.sig_sort_by_column.connect(self._sort_update) - self.dataTable.sig_fetch_more_columns.connect(self._fetch_more_columns) - self.dataTable.sig_fetch_more_rows.connect(self._fetch_more_rows) - - def sortByIndex(self, index): - """Implement a Index sort.""" - self.table_level.horizontalHeader().setSortIndicatorShown(True) - sort_order = self.table_level.horizontalHeader().sortIndicatorOrder() - self.table_index.model().sort(index, sort_order) - self._sort_update() - - def model(self): - """Get the model of the dataframe.""" - return self._model - - def _column_resized(self, col, old_width, new_width): - """Update the column width.""" - self.dataTable.setColumnWidth(col, new_width) - self._update_layout() - - def _row_resized(self, row, old_height, new_height): - """Update the row height.""" - self.dataTable.setRowHeight(row, new_height) - self._update_layout() - - def _index_resized(self, col, old_width, new_width): - """Resize the corresponding column of the index section selected.""" - self.table_index.setColumnWidth(col, new_width) - self._update_layout() - - def _header_resized(self, row, old_height, new_height): - """Resize the corresponding row of the header section selected.""" - self.table_header.setRowHeight(row, new_height) - self._update_layout() - - def _update_layout(self): - """Set the width and height of the QTableViews and hide rows.""" - h_width = max(self.table_level.verticalHeader().sizeHint().width(), - self.table_index.verticalHeader().sizeHint().width()) - self.table_level.verticalHeader().setFixedWidth(h_width) - self.table_index.verticalHeader().setFixedWidth(h_width) - - last_row = self._model.header_shape[0] - 1 - if last_row < 0: - hdr_height = self.table_level.horizontalHeader().height() - else: - hdr_height = self.table_level.rowViewportPosition(last_row) + \ - self.table_level.rowHeight(last_row) + \ - self.table_level.horizontalHeader().height() - # Check if the header shape has only one row (which display the - # same info than the horizontal header). - if last_row == 0: - self.table_level.setRowHidden(0, True) - self.table_header.setRowHidden(0, True) - self.table_header.setFixedHeight(hdr_height) - self.table_level.setFixedHeight(hdr_height) - - last_col = self._model.header_shape[1] - 1 - if last_col < 0: - idx_width = self.table_level.verticalHeader().width() - else: - idx_width = self.table_level.columnViewportPosition(last_col) + \ - self.table_level.columnWidth(last_col) + \ - self.table_level.verticalHeader().width() - self.table_index.setFixedWidth(idx_width) - self.table_level.setFixedWidth(idx_width) - self._resizeVisibleColumnsToContents() - - def _reset_model(self, table, model): - """Set the model in the given table.""" - old_sel_model = table.selectionModel() - table.setModel(model) - if old_sel_model: - del old_sel_model - - def setAutosizeLimitTime(self, limit_ms): - """Set maximum time to calculate size hint for columns.""" - self._max_autosize_ms = limit_ms - - def setModel(self, model, relayout=True): - """Set the model for the data, header/index and level views.""" - self._model = model - sel_model = self.dataTable.selectionModel() - sel_model.currentColumnChanged.connect( - self._resizeCurrentColumnToContents) - - # Asociate the models (level, vertical index and horizontal header) - # with its corresponding view. - self._reset_model(self.table_level, DataFrameLevelModel(model, - self.palette(), - self.font())) - self._reset_model(self.table_header, DataFrameHeaderModel( - model, - 0, - self.palette())) - self._reset_model(self.table_index, DataFrameHeaderModel( - model, - 1, - self.palette())) - - # Needs to be called after setting all table models - if relayout: - self._update_layout() - - def setCurrentIndex(self, y, x): - """Set current selection.""" - self.dataTable.selectionModel().setCurrentIndex( - self.dataTable.model().index(y, x), - QItemSelectionModel.ClearAndSelect) - - def _sizeHintForColumn(self, table, col, limit_ms=None): - """Get the size hint for a given column in a table.""" - max_row = table.model().rowCount() - lm_start = perf_counter() - lm_row = 64 if limit_ms else max_row - max_width = self.min_trunc - for row in range(max_row): - v = table.sizeHintForIndex(table.model().index(row, col)) - max_width = max(max_width, v.width()) - if row > lm_row: - lm_now = perf_counter() - lm_elapsed = (lm_now - lm_start) * 1000 - if lm_elapsed >= limit_ms: - break - lm_row = int((row / lm_elapsed) * limit_ms) - return max_width - - def _resizeColumnToContents(self, header, data, col, limit_ms): - """Resize a column by its contents.""" - hdr_width = self._sizeHintForColumn(header, col, limit_ms) - data_width = self._sizeHintForColumn(data, col, limit_ms) - if data_width > hdr_width: - width = min(self.max_width, data_width) - elif hdr_width > data_width * 2: - width = max(min(hdr_width, self.min_trunc), min(self.max_width, - data_width)) - else: - width = max(min(self.max_width, hdr_width), self.min_trunc) - header.setColumnWidth(col, width) - - def _resizeColumnsToContents(self, header, data, limit_ms): - """Resize all the colummns to its contents.""" - max_col = data.model().columnCount() - if limit_ms is None: - max_col_ms = None - else: - max_col_ms = limit_ms / max(1, max_col) - for col in range(max_col): - self._resizeColumnToContents(header, data, col, max_col_ms) - - def eventFilter(self, obj, event): - """Override eventFilter to catch resize event.""" - if obj == self.dataTable and event.type() == QEvent.Resize: - self._resizeVisibleColumnsToContents() - return False - - def _resizeVisibleColumnsToContents(self): - """Resize the columns that are in the view.""" - index_column = self.dataTable.rect().topLeft().x() - start = col = self.dataTable.columnAt(index_column) - width = self._model.shape[1] - end = self.dataTable.columnAt(self.dataTable.rect().bottomRight().x()) - end = width if end == -1 else end + 1 - if self._max_autosize_ms is None: - max_col_ms = None - else: - max_col_ms = self._max_autosize_ms / max(1, end - start) - while col < end: - resized = False - if col not in self._autosized_cols: - self._autosized_cols.add(col) - resized = True - self._resizeColumnToContents(self.table_header, self.dataTable, - col, max_col_ms) - col += 1 - if resized: - # As we resize columns, the boundary will change - index_column = self.dataTable.rect().bottomRight().x() - end = self.dataTable.columnAt(index_column) - end = width if end == -1 else end + 1 - if max_col_ms is not None: - max_col_ms = self._max_autosize_ms / max(1, end - start) - - def _resizeCurrentColumnToContents(self, new_index, old_index): - """Resize the current column to its contents.""" - if new_index.column() not in self._autosized_cols: - # Ensure the requested column is fully into view after resizing - self._resizeVisibleColumnsToContents() - self.dataTable.scrollTo(new_index) - - def resizeColumnsToContents(self): - """Resize the columns to its contents.""" - self._autosized_cols = set() - self._resizeColumnsToContents(self.table_level, - self.table_index, self._max_autosize_ms) - self._update_layout() - - def change_bgcolor_enable(self, state): - """ - This is implementet so column min/max is only active when bgcolor is - """ - self.dataModel.bgcolor(state) - self.bgcolor_global.setEnabled(not self.is_series and state > 0) - - def change_format(self): - """ - Ask user for display format for floats and use it. - """ - format, valid = QInputDialog.getText(self, _('Format'), - _("Float formatting"), - QLineEdit.Normal, - self.dataModel.get_format()) - if valid: - format = str(format) - try: - format % 1.1 - except: - msg = _("Format ({}) is incorrect").format(format) - QMessageBox.critical(self, _("Error"), msg) - return - if not format.startswith('%'): - msg = _("Format ({}) should start with '%'").format(format) - QMessageBox.critical(self, _("Error"), msg) - return - self.dataModel.set_format(format) - - format = format[1:] - self.set_conf('dataframe_format', format) - - def get_value(self): - """Return modified Dataframe -- this is *not* a copy""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - df = self.dataModel.get_data() - if self.is_series: - return df.iloc[:, 0] - else: - return df - - def _update_header_size(self): - """Update the column width of the header.""" - self.table_header.resizeColumnsToContents() - column_count = self.table_header.model().columnCount() - for index in range(0, column_count): - if index < column_count: - column_width = self.dataTable.columnWidth(index) - header_width = self.table_header.columnWidth(index) - if column_width > header_width: - self.table_header.setColumnWidth(index, column_width) - else: - self.dataTable.setColumnWidth(index, header_width) - else: - break - - def _sort_update(self): - """ - Update the model for all the QTableView objects. - - Uses the model of the dataTable as the base. - """ - # Update index list calculation - self.dataModel.recalculate_index() - self.setModel(self.dataTable.model()) - - def _fetch_more_columns(self): - """Fetch more data for the header (columns).""" - self.table_header.model().fetch_more() - - def _fetch_more_rows(self): - """Fetch more data for the index (rows).""" - self.table_index.model().fetch_more() - - def resize_to_contents(self): - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - self.dataTable.resizeColumnsToContents() - self.dataModel.fetch_more(columns=True) - self.dataTable.resizeColumnsToContents() - self._update_header_size() - QApplication.restoreOverrideCursor() - - -#============================================================================== -# Tests -#============================================================================== -def test_edit(data, title="", parent=None): - """Test subroutine""" - dlg = DataFrameEditor(parent=parent) - - if dlg.setup_and_check(data, title=title): - dlg.exec_() - return dlg.get_value() - else: - import sys - sys.exit(1) - - -def test(): - """DataFrame editor test""" - from numpy import nan - from pandas.util.testing import assert_frame_equal, assert_series_equal - - app = qapplication() # analysis:ignore - - df1 = pd.DataFrame( - [ - [True, "bool"], - [1+1j, "complex"], - ['test', "string"], - [1.11, "float"], - [1, "int"], - [np.random.rand(3, 3), "Unkown type"], - ["Large value", 100], - ["áéí", "unicode"] - ], - index=['a', 'b', nan, nan, nan, 'c', "Test global max", 'd'], - columns=[nan, 'Type'] - ) - out = test_edit(df1) - assert_frame_equal(df1, out) - - result = pd.Series([True, "bool"], index=[nan, 'Type'], name='a') - out = test_edit(df1.iloc[0]) - assert_series_equal(result, out) - - df1 = pd.DataFrame(np.random.rand(100100, 10)) - out = test_edit(df1) - assert_frame_equal(out, df1) - - series = pd.Series(np.arange(10), name=0) - out = test_edit(series) - assert_series_equal(series, out) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2011-2012 Lambda Foundry, Inc. and PyData Development Team +# Copyright (c) 2013 Jev Kuznetsov and contributors +# Copyright (c) 2014-2015 Scott Hansen +# Copyright (c) 2014-2016 Yuri D'Elia "wave++" +# Copyright (c) 2014- Spyder Project Contributors +# +# Components of gtabview originally distributed under the MIT (Expat) license. +# This file as a whole distributed under the terms of the New BSD License +# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). +# ----------------------------------------------------------------------------- + +""" +Pandas DataFrame Editor Dialog. + +DataFrameModel is based on the class ArrayModel from array editor +and the class DataFrameModel from the pandas project. +Present in pandas.sandbox.qtpandas in v0.13.1. + +DataFrameHeaderModel and DataFrameLevelModel are based on the classes +Header4ExtModel and Level4ExtModel from the gtabview project. +DataFrameModel is based on the classes ExtDataModel and ExtFrameModel, and +DataFrameEditor is based on gtExtTableView from the same project. + +DataFrameModel originally based on pandas/sandbox/qtpandas.py of the +`pandas project `_. +The current version is qtpandas/models/DataFrameModel.py of the +`QtPandas project `_. + +Components of gtabview from gtabview/viewer.py and gtabview/models.py of the +`gtabview project `_. +""" + +# Standard library imports + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QAbstractTableModel, QModelIndex, Qt, Signal, Slot, + QItemSelectionModel, QEvent) +from qtpy.QtGui import QColor, QCursor +from qtpy.QtWidgets import (QApplication, QCheckBox, QDialog, QGridLayout, + QHBoxLayout, QInputDialog, QLineEdit, QMenu, + QMessageBox, QPushButton, QTableView, + QScrollBar, QTableWidget, QFrame, + QItemDelegate) +from spyder_kernels.utils.lazymodules import numpy as np, pandas as pd + +# Local imports +from spyder.api.config.mixins import SpyderConfigurationAccessor +from spyder.config.base import _ +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.config.gui import get_font +from spyder.py3compat import (io, is_text_string, is_type_text_string, PY2, + to_text_string, perf_counter) +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import (add_actions, create_action, + keybinding, qapplication) +from spyder.plugins.variableexplorer.widgets.arrayeditor import get_idx_rect +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog + +# Supported Numbers and complex numbers +REAL_NUMBER_TYPES = (float, int, np.int64, np.int32) +COMPLEX_NUMBER_TYPES = (complex, np.complex64, np.complex128) +# Used to convert bool intrance to false since bool('False') will return True +_bool_false = ['false', 'f', '0', '0.', '0.0', ' '] + +# Default format for data frames with floats +DEFAULT_FORMAT = '%.6g' + +# Limit at which dataframe is considered so large that it is loaded on demand +LARGE_SIZE = 5e5 +LARGE_NROWS = 1e5 +LARGE_COLS = 60 +ROWS_TO_LOAD = 500 +COLS_TO_LOAD = 40 + +# Background colours +BACKGROUND_NUMBER_MINHUE = 0.66 # hue for largest number +BACKGROUND_NUMBER_HUERANGE = 0.33 # (hue for smallest) minus (hue for largest) +BACKGROUND_NUMBER_SATURATION = 0.7 +BACKGROUND_NUMBER_VALUE = 1.0 +BACKGROUND_NUMBER_ALPHA = 0.6 +BACKGROUND_NONNUMBER_COLOR = Qt.lightGray +BACKGROUND_INDEX_ALPHA = 0.8 +BACKGROUND_STRING_ALPHA = 0.05 +BACKGROUND_MISC_ALPHA = 0.3 + + +def bool_false_check(value): + """ + Used to convert bool entrance to false. + + Needed since any string in bool('') will return True. + """ + if value.lower() in _bool_false: + value = '' + return value + + +def global_max(col_vals, index): + """Returns the global maximum and minimum.""" + col_vals_without_None = [x for x in col_vals if x is not None] + max_col, min_col = zip(*col_vals_without_None) + return max(max_col), min(min_col) + + +class DataFrameModel(QAbstractTableModel): + """ + DataFrame Table Model. + + Partly based in ExtDataModel and ExtFrameModel classes + of the gtabview project. + + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/models.py + """ + + def __init__(self, dataFrame, format=DEFAULT_FORMAT, parent=None): + QAbstractTableModel.__init__(self) + self.dialog = parent + self.df = dataFrame + self.df_columns_list = None + self.df_index_list = None + self._format = format + self.complex_intran = None + self.display_error_idxs = [] + + self.total_rows = self.df.shape[0] + self.total_cols = self.df.shape[1] + size = self.total_rows * self.total_cols + + self.max_min_col = None + if size < LARGE_SIZE: + self.max_min_col_update() + self.colum_avg_enabled = True + self.bgcolor_enabled = True + self.colum_avg(1) + else: + self.colum_avg_enabled = False + self.bgcolor_enabled = False + self.colum_avg(0) + + # Use paging when the total size, number of rows or number of + # columns is too large + if size > LARGE_SIZE: + self.rows_loaded = ROWS_TO_LOAD + self.cols_loaded = COLS_TO_LOAD + else: + if self.total_rows > LARGE_NROWS: + self.rows_loaded = ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + if self.total_cols > LARGE_COLS: + self.cols_loaded = COLS_TO_LOAD + else: + self.cols_loaded = self.total_cols + + def _axis(self, axis): + """ + Return the corresponding labels taking into account the axis. + + The axis could be horizontal (0) or vertical (1). + """ + return self.df.columns if axis == 0 else self.df.index + + def _axis_list(self, axis): + """ + Return the corresponding labels as a list taking into account the axis. + + The axis could be horizontal (0) or vertical (1). + """ + if axis == 0: + if self.df_columns_list is None: + self.df_columns_list = self.df.columns.tolist() + return self.df_columns_list + else: + if self.df_index_list is None: + self.df_index_list = self.df.index.tolist() + return self.df_index_list + + def _axis_levels(self, axis): + """ + Return the number of levels in the labels taking into account the axis. + + Get the number of levels for the columns (0) or rows (1). + """ + ax = self._axis(axis) + return 1 if not hasattr(ax, 'levels') else len(ax.levels) + + @property + def shape(self): + """Return the shape of the dataframe.""" + return self.df.shape + + @property + def header_shape(self): + """Return the levels for the columns and rows of the dataframe.""" + return (self._axis_levels(0), self._axis_levels(1)) + + @property + def chunk_size(self): + """Return the max value of the dimensions of the dataframe.""" + return max(*self.shape()) + + def header(self, axis, x, level=0): + """ + Return the values of the labels for the header of columns or rows. + + The value corresponds to the header of column or row x in the + given level. + """ + ax = self._axis(axis) + if not hasattr(ax, 'levels'): + ax = self._axis_list(axis) + return ax[x] + else: + return ax.values[x][level] + + def name(self, axis, level): + """Return the labels of the levels if any.""" + ax = self._axis(axis) + if hasattr(ax, 'levels'): + return ax.names[level] + if ax.name: + return ax.name + + def max_min_col_update(self): + """ + Determines the maximum and minimum number in each column. + + The result is a list whose k-th entry is [vmax, vmin], where vmax and + vmin denote the maximum and minimum of the k-th column (ignoring NaN). + This list is stored in self.max_min_col. + + If the k-th column has a non-numerical dtype, then the k-th entry + is set to None. If the dtype is complex, then compute the maximum and + minimum of the absolute values. If vmax equals vmin, then vmin is + decreased by one. + """ + if self.df.shape[0] == 0: # If no rows to compute max/min then return + return + self.max_min_col = [] + for __, col in self.df.iteritems(): + # This is necessary to catch an error in Pandas when computing + # the maximum of a column. + # Fixes spyder-ide/spyder#17145 + try: + if col.dtype in REAL_NUMBER_TYPES + COMPLEX_NUMBER_TYPES: + if col.dtype in REAL_NUMBER_TYPES: + vmax = col.max(skipna=True) + vmin = col.min(skipna=True) + else: + vmax = col.abs().max(skipna=True) + vmin = col.abs().min(skipna=True) + if vmax != vmin: + max_min = [vmax, vmin] + else: + max_min = [vmax, vmin - 1] + else: + max_min = None + except TypeError: + max_min = None + self.max_min_col.append(max_min) + + def get_format(self): + """Return current format""" + # Avoid accessing the private attribute _format from outside + return self._format + + def set_format(self, format): + """Change display format""" + self._format = format + self.reset() + + def bgcolor(self, state): + """Toggle backgroundcolor""" + self.bgcolor_enabled = state > 0 + self.reset() + + def colum_avg(self, state): + """Toggle backgroundcolor""" + self.colum_avg_enabled = state > 0 + if self.colum_avg_enabled: + self.return_max = lambda col_vals, index: col_vals[index] + else: + self.return_max = global_max + self.reset() + + def get_bgcolor(self, index): + """Background color depending on value.""" + column = index.column() + + if not self.bgcolor_enabled: + return + + value = self.get_value(index.row(), column) + if self.max_min_col[column] is None or pd.isna(value): + color = QColor(BACKGROUND_NONNUMBER_COLOR) + if is_text_string(value): + color.setAlphaF(BACKGROUND_STRING_ALPHA) + else: + color.setAlphaF(BACKGROUND_MISC_ALPHA) + else: + if isinstance(value, COMPLEX_NUMBER_TYPES): + color_func = abs + else: + color_func = float + vmax, vmin = self.return_max(self.max_min_col, column) + + # This is necessary to catch an error in Pandas when computing + # the difference between the max and min of a column. + # Fixes spyder-ide/spyder#18005 + try: + if vmax - vmin == 0: + vmax_vmin_diff = 1.0 + else: + vmax_vmin_diff = vmax - vmin + except TypeError: + return + + hue = (BACKGROUND_NUMBER_MINHUE + BACKGROUND_NUMBER_HUERANGE * + (vmax - color_func(value)) / (vmax_vmin_diff)) + hue = float(abs(hue)) + if hue > 1: + hue = 1 + color = QColor.fromHsvF(hue, BACKGROUND_NUMBER_SATURATION, + BACKGROUND_NUMBER_VALUE, + BACKGROUND_NUMBER_ALPHA) + + return color + + def get_value(self, row, column): + """Return the value of the DataFrame.""" + # To increase the performance iat is used but that requires error + # handling, so fallback uses iloc + try: + value = self.df.iat[row, column] + except pd._libs.tslib.OutOfBoundsDatetime: + value = self.df.iloc[:, column].astype(str).iat[row] + except: + value = self.df.iloc[row, column] + return value + + def data(self, index, role=Qt.DisplayRole): + """Cell content""" + if not index.isValid(): + return to_qvariant() + if role == Qt.DisplayRole or role == Qt.EditRole: + column = index.column() + row = index.row() + value = self.get_value(row, column) + if isinstance(value, float): + try: + return to_qvariant(self._format % value) + except (ValueError, TypeError): + # may happen if format = '%d' and value = NaN; + # see spyder-ide/spyder#4139. + return to_qvariant(DEFAULT_FORMAT % value) + elif is_type_text_string(value): + # Don't perform any conversion on strings + # because it leads to differences between + # the data present in the dataframe and + # what is shown by Spyder + return value + else: + try: + return to_qvariant(to_text_string(value)) + except Exception: + self.display_error_idxs.append(index) + return u'Display Error!' + elif role == Qt.BackgroundColorRole: + return to_qvariant(self.get_bgcolor(index)) + elif role == Qt.FontRole: + return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + elif role == Qt.ToolTipRole: + if index in self.display_error_idxs: + return _("It is not possible to display this value because\n" + "an error ocurred while trying to do it") + return to_qvariant() + + def recalculate_index(self): + """Recalcuate index information.""" + self.df_index_list = self.df.index.tolist() + + def sort(self, column, order=Qt.AscendingOrder): + """Overriding sort method""" + if self.complex_intran is not None: + if self.complex_intran.any(axis=0).iloc[column]: + QMessageBox.critical(self.dialog, "Error", + "TypeError error: no ordering " + "relation is defined for complex numbers") + return False + try: + ascending = order == Qt.AscendingOrder + if column >= 0: + try: + self.df.sort_values(by=self.df.columns[column], + ascending=ascending, inplace=True, + kind='mergesort') + except AttributeError: + # for pandas version < 0.17 + self.df.sort(columns=self.df.columns[column], + ascending=ascending, inplace=True, + kind='mergesort') + except ValueError as e: + # Not possible to sort on duplicate columns + # See spyder-ide/spyder#5225. + QMessageBox.critical(self.dialog, "Error", + "ValueError: %s" % to_text_string(e)) + except SystemError as e: + # Not possible to sort on category dtypes + # See spyder-ide/spyder#5361. + QMessageBox.critical(self.dialog, "Error", + "SystemError: %s" % to_text_string(e)) + else: + # Update index list + self.recalculate_index() + # To sort by index + self.df.sort_index(inplace=True, ascending=ascending) + except TypeError as e: + QMessageBox.critical(self.dialog, "Error", + "TypeError error: %s" % str(e)) + return False + + self.reset() + return True + + def flags(self, index): + """Set flags""" + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | + Qt.ItemIsEditable)) + + def setData(self, index, value, role=Qt.EditRole, change_type=None): + """Cell content change""" + column = index.column() + row = index.row() + + if index in self.display_error_idxs: + return False + if change_type is not None: + try: + value = self.data(index, role=Qt.DisplayRole) + val = from_qvariant(value, str) + if change_type is bool: + val = bool_false_check(val) + self.df.iloc[row, column] = change_type(val) + except ValueError: + self.df.iloc[row, column] = change_type('0') + else: + val = from_qvariant(value, str) + current_value = self.get_value(row, column) + if isinstance(current_value, (bool, np.bool_)): + val = bool_false_check(val) + supported_types = (bool, np.bool_) + REAL_NUMBER_TYPES + if (isinstance(current_value, supported_types) or + is_text_string(current_value)): + try: + self.df.iloc[row, column] = current_value.__class__(val) + except (ValueError, OverflowError) as e: + QMessageBox.critical(self.dialog, "Error", + str(type(e).__name__) + ": " + str(e)) + return False + else: + QMessageBox.critical(self.dialog, "Error", + "Editing dtype {0!s} not yet supported." + .format(type(current_value).__name__)) + return False + self.max_min_col_update() + self.dataChanged.emit(index, index) + return True + + def get_data(self): + """Return data""" + return self.df + + def rowCount(self, index=QModelIndex()): + """DataFrame row number""" + # Avoid a "Qt exception in virtual methods" generated in our + # tests on Windows/Python 3.7 + # See spyder-ide/spyder#8910. + try: + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + except AttributeError: + return 0 + + def fetch_more(self, rows=False, columns=False): + """Get more columns and/or rows.""" + if rows and self.total_rows > self.rows_loaded: + reminder = self.total_rows - self.rows_loaded + items_to_fetch = min(reminder, ROWS_TO_LOAD) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + if columns and self.total_cols > self.cols_loaded: + reminder = self.total_cols - self.cols_loaded + items_to_fetch = min(reminder, COLS_TO_LOAD) + self.beginInsertColumns(QModelIndex(), self.cols_loaded, + self.cols_loaded + items_to_fetch - 1) + self.cols_loaded += items_to_fetch + self.endInsertColumns() + + def columnCount(self, index=QModelIndex()): + """DataFrame column number""" + # Avoid a "Qt exception in virtual methods" generated in our + # tests on Windows/Python 3.7 + # See spyder-ide/spyder#8910. + try: + # This is done to implement series + if len(self.df.shape) == 1: + return 2 + elif self.total_cols <= self.cols_loaded: + return self.total_cols + else: + return self.cols_loaded + except AttributeError: + return 0 + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class DataFrameView(QTableView, SpyderConfigurationAccessor): + """ + Data Frame view class. + + Signals + ------- + sig_sort_by_column(): Raised after more columns are fetched. + sig_fetch_more_rows(): Raised after more rows are fetched. + """ + sig_sort_by_column = Signal() + sig_fetch_more_columns = Signal() + sig_fetch_more_rows = Signal() + + CONF_SECTION = 'variable_explorer' + + def __init__(self, parent, model, header, hscroll, vscroll): + """Constructor.""" + QTableView.__init__(self, parent) + self.setModel(model) + self.setHorizontalScrollBar(hscroll) + self.setVerticalScrollBar(vscroll) + self.setHorizontalScrollMode(QTableView.ScrollPerPixel) + self.setVerticalScrollMode(QTableView.ScrollPerPixel) + + self.sort_old = [None] + self.header_class = header + self.header_class.sectionClicked.connect(self.sortByColumn) + self.menu = self.setup_menu() + self.config_shortcut(self.copy, 'copy', self) + self.horizontalScrollBar().valueChanged.connect( + self._load_more_columns) + self.verticalScrollBar().valueChanged.connect(self._load_more_rows) + + def _load_more_columns(self, value): + """Load more columns to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, columns=True) + except NameError: + pass + + def _load_more_rows(self, value): + """Load more rows to display.""" + # Needed to avoid a NameError while fetching data when closing + # See spyder-ide/spyder#12034. + try: + self.load_more_data(value, rows=True) + except NameError: + pass + + def load_more_data(self, value, rows=False, columns=False): + """Load more rows and columns to display.""" + try: + if rows and value == self.verticalScrollBar().maximum(): + self.model().fetch_more(rows=rows) + self.sig_fetch_more_rows.emit() + if columns and value == self.horizontalScrollBar().maximum(): + self.model().fetch_more(columns=columns) + self.sig_fetch_more_columns.emit() + + except NameError: + # Needed to handle a NameError while fetching data when closing + # See spyder-ide/spyder#7880. + pass + + def sortByColumn(self, index): + """Implement a column sort.""" + if self.sort_old == [None]: + self.header_class.setSortIndicatorShown(True) + sort_order = self.header_class.sortIndicatorOrder() + if not self.model().sort(index, sort_order): + if len(self.sort_old) != 2: + self.header_class.setSortIndicatorShown(False) + else: + self.header_class.setSortIndicator(self.sort_old[0], + self.sort_old[1]) + return + self.sort_old = [index, self.header_class.sortIndicatorOrder()] + self.sig_sort_by_column.emit() + + def contextMenuEvent(self, event): + """Reimplement Qt method.""" + self.menu.popup(event.globalPos()) + event.accept() + + def setup_menu(self): + """Setup context menu.""" + copy_action = create_action(self, _('Copy'), + shortcut=keybinding('Copy'), + icon=ima.icon('editcopy'), + triggered=self.copy, + context=Qt.WidgetShortcut) + functions = ((_("To bool"), bool), (_("To complex"), complex), + (_("To int"), int), (_("To float"), float), + (_("To str"), to_text_string)) + types_in_menu = [copy_action] + for name, func in functions: + def slot(): + self.change_type(func) + types_in_menu += [create_action(self, name, + triggered=slot, + context=Qt.WidgetShortcut)] + menu = QMenu(self) + add_actions(menu, types_in_menu) + return menu + + def change_type(self, func): + """A function that changes types of cells.""" + model = self.model() + index_list = self.selectedIndexes() + [model.setData(i, '', change_type=func) for i in index_list] + + @Slot() + def copy(self): + """Copy text to clipboard""" + if not self.selectedIndexes(): + return + (row_min, row_max, + col_min, col_max) = get_idx_rect(self.selectedIndexes()) + # Copy index and header too (equal True). + # See spyder-ide/spyder#11096 + index = header = True + df = self.model().df + obj = df.iloc[slice(row_min, row_max + 1), + slice(col_min, col_max + 1)] + output = io.StringIO() + try: + obj.to_csv(output, sep='\t', index=index, header=header) + except UnicodeEncodeError: + # Needed to handle encoding errors in Python 2 + # See spyder-ide/spyder#4833 + QMessageBox.critical( + self, + _("Error"), + _("Text can't be copied.")) + if not PY2: + contents = output.getvalue() + else: + contents = output.getvalue().decode('utf-8') + output.close() + clipboard = QApplication.clipboard() + clipboard.setText(contents) + + +class DataFrameHeaderModel(QAbstractTableModel): + """ + This class is the model for the header or index of the DataFrameEditor. + + Taken from gtabview project (Header4ExtModel). + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py + """ + + COLUMN_INDEX = -1 # Makes reference to the index of the table. + + def __init__(self, model, axis, palette): + """ + Header constructor. + + The 'model' is the QAbstractTableModel of the dataframe, the 'axis' is + to acknowledge if is for the header (horizontal - 0) or for the + index (vertical - 1) and the palette is the set of colors to use. + """ + super(DataFrameHeaderModel, self).__init__() + self.model = model + self.axis = axis + self._palette = palette + self.total_rows = self.model.shape[0] + self.total_cols = self.model.shape[1] + size = self.total_rows * self.total_cols + + # Use paging when the total size, number of rows or number of + # columns is too large + if size > LARGE_SIZE: + self.rows_loaded = ROWS_TO_LOAD + self.cols_loaded = COLS_TO_LOAD + else: + if self.total_cols > LARGE_COLS: + self.cols_loaded = COLS_TO_LOAD + else: + self.cols_loaded = self.total_cols + if self.total_rows > LARGE_NROWS: + self.rows_loaded = ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + + if self.axis == 0: + self.total_cols = self.model.shape[1] + self._shape = (self.model.header_shape[0], self.model.shape[1]) + else: + self.total_rows = self.model.shape[0] + self._shape = (self.model.shape[0], self.model.header_shape[1]) + + def rowCount(self, index=None): + """Get number of rows in the header.""" + if self.axis == 0: + return max(1, self._shape[0]) + else: + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + + def columnCount(self, index=QModelIndex()): + """DataFrame column number""" + if self.axis == 0: + if self.total_cols <= self.cols_loaded: + return self.total_cols + else: + return self.cols_loaded + else: + return max(1, self._shape[1]) + + def fetch_more(self, rows=False, columns=False): + """Get more columns or rows (based on axis).""" + if self.axis == 1 and self.total_rows > self.rows_loaded: + reminder = self.total_rows - self.rows_loaded + items_to_fetch = min(reminder, ROWS_TO_LOAD) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + if self.axis == 0 and self.total_cols > self.cols_loaded: + reminder = self.total_cols - self.cols_loaded + items_to_fetch = min(reminder, COLS_TO_LOAD) + self.beginInsertColumns(QModelIndex(), self.cols_loaded, + self.cols_loaded + items_to_fetch - 1) + self.cols_loaded += items_to_fetch + self.endInsertColumns() + + def sort(self, column, order=Qt.AscendingOrder): + """Overriding sort method.""" + ascending = order == Qt.AscendingOrder + self.model.sort(self.COLUMN_INDEX, order=ascending) + return True + + def headerData(self, section, orientation, role): + """Get the information to put in the header.""" + if role == Qt.TextAlignmentRole: + if orientation == Qt.Horizontal: + return Qt.AlignCenter + else: + return int(Qt.AlignRight | Qt.AlignVCenter) + if role != Qt.DisplayRole and role != Qt.ToolTipRole: + return None + if self.axis == 1 and self._shape[1] <= 1: + return None + orient_axis = 0 if orientation == Qt.Horizontal else 1 + if self.model.header_shape[orient_axis] > 1: + header = section + else: + header = self.model.header(self.axis, section) + + # Don't perform any conversion on strings + # because it leads to differences between + # the data present in the dataframe and + # what is shown by Spyder + if not is_type_text_string(header): + header = to_text_string(header) + + return header + + def data(self, index, role): + """ + Get the data for the header. + + This is used when a header has levels. + """ + if not index.isValid() or \ + index.row() >= self._shape[0] or \ + index.column() >= self._shape[1]: + return None + row, col = ((index.row(), index.column()) if self.axis == 0 + else (index.column(), index.row())) + if role != Qt.DisplayRole: + return None + if self.axis == 0 and self._shape[0] <= 1: + return None + + header = self.model.header(self.axis, col, row) + + # Don't perform any conversion on strings + # because it leads to differences between + # the data present in the dataframe and + # what is shown by Spyder + if not is_type_text_string(header): + header = to_text_string(header) + + return header + + +class DataFrameLevelModel(QAbstractTableModel): + """ + Data Frame level class. + + This class is used to represent index levels in the DataFrameEditor. When + using MultiIndex, this model creates labels for the index/header as Index i + for each section in the index/header + + Based on the gtabview project (Level4ExtModel). + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py + """ + + def __init__(self, model, palette, font): + super(DataFrameLevelModel, self).__init__() + self.model = model + self._background = palette.dark().color() + if self._background.lightness() > 127: + self._foreground = palette.text() + else: + self._foreground = palette.highlightedText() + self._palette = palette + font.setBold(True) + self._font = font + + def rowCount(self, index=None): + """Get number of rows (number of levels for the header).""" + return max(1, self.model.header_shape[0]) + + def columnCount(self, index=None): + """Get the number of columns (number of levels for the index).""" + return max(1, self.model.header_shape[1]) + + def headerData(self, section, orientation, role): + """ + Get the text to put in the header of the levels of the indexes. + + By default it returns 'Index i', where i is the section in the index + """ + if role == Qt.TextAlignmentRole: + if orientation == Qt.Horizontal: + return Qt.AlignCenter + else: + return int(Qt.AlignRight | Qt.AlignVCenter) + if role != Qt.DisplayRole and role != Qt.ToolTipRole: + return None + if self.model.header_shape[0] <= 1 and orientation == Qt.Horizontal: + if self.model.name(1,section): + return self.model.name(1,section) + return _('Index') + elif self.model.header_shape[0] <= 1: + return None + elif self.model.header_shape[1] <= 1 and orientation == Qt.Vertical: + return None + return _('Index') + ' ' + to_text_string(section) + + def data(self, index, role): + """Get the information of the levels.""" + if not index.isValid(): + return None + if role == Qt.FontRole: + return self._font + label = '' + if index.column() == self.model.header_shape[1] - 1: + label = str(self.model.name(0, index.row())) + elif index.row() == self.model.header_shape[0] - 1: + label = str(self.model.name(1, index.column())) + if role == Qt.DisplayRole and label: + return label + elif role == Qt.ForegroundRole: + return self._foreground + elif role == Qt.BackgroundRole: + return self._background + elif role == Qt.BackgroundRole: + return self._palette.window() + return None + + +class DataFrameEditor(BaseDialog, SpyderConfigurationAccessor): + """ + Dialog for displaying and editing DataFrame and related objects. + + Based on the gtabview project (ExtTableView). + For more information please see: + https://github.com/wavexx/gtabview/blob/master/gtabview/viewer.py + """ + CONF_SECTION = 'variable_explorer' + + def __init__(self, parent=None): + super().__init__(parent) + + # Destroying the C++ object right after closing the dialog box, + # otherwise it may be garbage-collected in another QThread + # (e.g. the editor's analysis thread in Spyder), thus leading to + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + self.is_series = False + self.layout = None + + def setup_and_check(self, data, title=''): + """ + Setup DataFrameEditor: + return False if data is not supported, True otherwise. + Supported types for data are DataFrame, Series and Index. + """ + self._selection_rec = False + self._model = None + + self.layout = QGridLayout() + self.layout.setSpacing(0) + self.layout.setContentsMargins(20, 20, 20, 0) + self.setLayout(self.layout) + if title: + title = to_text_string(title) + " - %s" % data.__class__.__name__ + else: + title = _("%s editor") % data.__class__.__name__ + if isinstance(data, pd.Series): + self.is_series = True + data = data.to_frame() + elif isinstance(data, pd.Index): + data = pd.DataFrame(data) + + self.setWindowTitle(title) + + self.hscroll = QScrollBar(Qt.Horizontal) + self.vscroll = QScrollBar(Qt.Vertical) + + # Create the view for the level + self.create_table_level() + + # Create the view for the horizontal header + self.create_table_header() + + # Create the view for the vertical index + self.create_table_index() + + # Create the model and view of the data + self.dataModel = DataFrameModel(data, parent=self) + self.dataModel.dataChanged.connect(self.save_and_close_enable) + self.create_data_table() + + self.layout.addWidget(self.hscroll, 2, 0, 1, 2) + self.layout.addWidget(self.vscroll, 0, 2, 2, 1) + + # autosize columns on-demand + self._autosized_cols = set() + # Set limit time to calculate column sizeHint to 300ms, + # See spyder-ide/spyder#11060 + self._max_autosize_ms = 300 + self.dataTable.installEventFilter(self) + + avg_width = self.fontMetrics().averageCharWidth() + self.min_trunc = avg_width * 12 # Minimum size for columns + self.max_width = avg_width * 64 # Maximum size for columns + + self.setLayout(self.layout) + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + btn_layout = QHBoxLayout() + btn_layout.setSpacing(5) + + btn_format = QPushButton(_("Format")) + # disable format button for int type + btn_layout.addWidget(btn_format) + btn_format.clicked.connect(self.change_format) + + btn_resize = QPushButton(_('Resize')) + btn_layout.addWidget(btn_resize) + btn_resize.clicked.connect(self.resize_to_contents) + + bgcolor = QCheckBox(_('Background color')) + bgcolor.setChecked(self.dataModel.bgcolor_enabled) + bgcolor.setEnabled(self.dataModel.bgcolor_enabled) + bgcolor.stateChanged.connect(self.change_bgcolor_enable) + btn_layout.addWidget(bgcolor) + + self.bgcolor_global = QCheckBox(_('Column min/max')) + self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) + self.bgcolor_global.setEnabled(not self.is_series and + self.dataModel.bgcolor_enabled) + self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) + btn_layout.addWidget(self.bgcolor_global) + + btn_layout.addStretch() + + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout.addWidget(self.btn_close) + + btn_layout.setContentsMargins(0, 16, 0, 16) + self.layout.addLayout(btn_layout, 4, 0, 1, 2) + self.setModel(self.dataModel) + self.resizeColumnsToContents() + + format = '%' + self.get_conf('dataframe_format') + self.dataModel.set_format(format) + + return True + + @Slot(QModelIndex, QModelIndex) + def save_and_close_enable(self, top_left, bottom_right): + """Handle the data change event to enable the save and close button.""" + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def create_table_level(self): + """Create the QTableView that will hold the level model.""" + self.table_level = QTableView() + self.table_level.setEditTriggers(QTableWidget.NoEditTriggers) + self.table_level.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_level.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_level.setFrameStyle(QFrame.Plain) + self.table_level.horizontalHeader().sectionResized.connect( + self._index_resized) + self.table_level.verticalHeader().sectionResized.connect( + self._header_resized) + self.table_level.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.table_level, 0, 0) + self.table_level.setContentsMargins(0, 0, 0, 0) + self.table_level.horizontalHeader().sectionClicked.connect( + self.sortByIndex) + + def create_table_header(self): + """Create the QTableView that will hold the header model.""" + self.table_header = QTableView() + self.table_header.verticalHeader().hide() + self.table_header.setEditTriggers(QTableWidget.NoEditTriggers) + self.table_header.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_header.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_header.setHorizontalScrollMode(QTableView.ScrollPerPixel) + self.table_header.setHorizontalScrollBar(self.hscroll) + self.table_header.setFrameStyle(QFrame.Plain) + self.table_header.horizontalHeader().sectionResized.connect( + self._column_resized) + self.table_header.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.table_header, 0, 1) + + def create_table_index(self): + """Create the QTableView that will hold the index model.""" + self.table_index = QTableView() + self.table_index.horizontalHeader().hide() + self.table_index.setEditTriggers(QTableWidget.NoEditTriggers) + self.table_index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_index.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.table_index.setVerticalScrollMode(QTableView.ScrollPerPixel) + self.table_index.setVerticalScrollBar(self.vscroll) + self.table_index.setFrameStyle(QFrame.Plain) + self.table_index.verticalHeader().sectionResized.connect( + self._row_resized) + self.table_index.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.table_index, 1, 0) + self.table_index.setContentsMargins(0, 0, 0, 0) + + def create_data_table(self): + """Create the QTableView that will hold the data model.""" + self.dataTable = DataFrameView(self, self.dataModel, + self.table_header.horizontalHeader(), + self.hscroll, self.vscroll) + self.dataTable.verticalHeader().hide() + self.dataTable.horizontalHeader().hide() + self.dataTable.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.dataTable.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.dataTable.setHorizontalScrollMode(QTableView.ScrollPerPixel) + self.dataTable.setVerticalScrollMode(QTableView.ScrollPerPixel) + self.dataTable.setFrameStyle(QFrame.Plain) + self.dataTable.setItemDelegate(QItemDelegate()) + self.layout.addWidget(self.dataTable, 1, 1) + self.setFocusProxy(self.dataTable) + self.dataTable.sig_sort_by_column.connect(self._sort_update) + self.dataTable.sig_fetch_more_columns.connect(self._fetch_more_columns) + self.dataTable.sig_fetch_more_rows.connect(self._fetch_more_rows) + + def sortByIndex(self, index): + """Implement a Index sort.""" + self.table_level.horizontalHeader().setSortIndicatorShown(True) + sort_order = self.table_level.horizontalHeader().sortIndicatorOrder() + self.table_index.model().sort(index, sort_order) + self._sort_update() + + def model(self): + """Get the model of the dataframe.""" + return self._model + + def _column_resized(self, col, old_width, new_width): + """Update the column width.""" + self.dataTable.setColumnWidth(col, new_width) + self._update_layout() + + def _row_resized(self, row, old_height, new_height): + """Update the row height.""" + self.dataTable.setRowHeight(row, new_height) + self._update_layout() + + def _index_resized(self, col, old_width, new_width): + """Resize the corresponding column of the index section selected.""" + self.table_index.setColumnWidth(col, new_width) + self._update_layout() + + def _header_resized(self, row, old_height, new_height): + """Resize the corresponding row of the header section selected.""" + self.table_header.setRowHeight(row, new_height) + self._update_layout() + + def _update_layout(self): + """Set the width and height of the QTableViews and hide rows.""" + h_width = max(self.table_level.verticalHeader().sizeHint().width(), + self.table_index.verticalHeader().sizeHint().width()) + self.table_level.verticalHeader().setFixedWidth(h_width) + self.table_index.verticalHeader().setFixedWidth(h_width) + + last_row = self._model.header_shape[0] - 1 + if last_row < 0: + hdr_height = self.table_level.horizontalHeader().height() + else: + hdr_height = self.table_level.rowViewportPosition(last_row) + \ + self.table_level.rowHeight(last_row) + \ + self.table_level.horizontalHeader().height() + # Check if the header shape has only one row (which display the + # same info than the horizontal header). + if last_row == 0: + self.table_level.setRowHidden(0, True) + self.table_header.setRowHidden(0, True) + self.table_header.setFixedHeight(hdr_height) + self.table_level.setFixedHeight(hdr_height) + + last_col = self._model.header_shape[1] - 1 + if last_col < 0: + idx_width = self.table_level.verticalHeader().width() + else: + idx_width = self.table_level.columnViewportPosition(last_col) + \ + self.table_level.columnWidth(last_col) + \ + self.table_level.verticalHeader().width() + self.table_index.setFixedWidth(idx_width) + self.table_level.setFixedWidth(idx_width) + self._resizeVisibleColumnsToContents() + + def _reset_model(self, table, model): + """Set the model in the given table.""" + old_sel_model = table.selectionModel() + table.setModel(model) + if old_sel_model: + del old_sel_model + + def setAutosizeLimitTime(self, limit_ms): + """Set maximum time to calculate size hint for columns.""" + self._max_autosize_ms = limit_ms + + def setModel(self, model, relayout=True): + """Set the model for the data, header/index and level views.""" + self._model = model + sel_model = self.dataTable.selectionModel() + sel_model.currentColumnChanged.connect( + self._resizeCurrentColumnToContents) + + # Asociate the models (level, vertical index and horizontal header) + # with its corresponding view. + self._reset_model(self.table_level, DataFrameLevelModel(model, + self.palette(), + self.font())) + self._reset_model(self.table_header, DataFrameHeaderModel( + model, + 0, + self.palette())) + self._reset_model(self.table_index, DataFrameHeaderModel( + model, + 1, + self.palette())) + + # Needs to be called after setting all table models + if relayout: + self._update_layout() + + def setCurrentIndex(self, y, x): + """Set current selection.""" + self.dataTable.selectionModel().setCurrentIndex( + self.dataTable.model().index(y, x), + QItemSelectionModel.ClearAndSelect) + + def _sizeHintForColumn(self, table, col, limit_ms=None): + """Get the size hint for a given column in a table.""" + max_row = table.model().rowCount() + lm_start = perf_counter() + lm_row = 64 if limit_ms else max_row + max_width = self.min_trunc + for row in range(max_row): + v = table.sizeHintForIndex(table.model().index(row, col)) + max_width = max(max_width, v.width()) + if row > lm_row: + lm_now = perf_counter() + lm_elapsed = (lm_now - lm_start) * 1000 + if lm_elapsed >= limit_ms: + break + lm_row = int((row / lm_elapsed) * limit_ms) + return max_width + + def _resizeColumnToContents(self, header, data, col, limit_ms): + """Resize a column by its contents.""" + hdr_width = self._sizeHintForColumn(header, col, limit_ms) + data_width = self._sizeHintForColumn(data, col, limit_ms) + if data_width > hdr_width: + width = min(self.max_width, data_width) + elif hdr_width > data_width * 2: + width = max(min(hdr_width, self.min_trunc), min(self.max_width, + data_width)) + else: + width = max(min(self.max_width, hdr_width), self.min_trunc) + header.setColumnWidth(col, width) + + def _resizeColumnsToContents(self, header, data, limit_ms): + """Resize all the colummns to its contents.""" + max_col = data.model().columnCount() + if limit_ms is None: + max_col_ms = None + else: + max_col_ms = limit_ms / max(1, max_col) + for col in range(max_col): + self._resizeColumnToContents(header, data, col, max_col_ms) + + def eventFilter(self, obj, event): + """Override eventFilter to catch resize event.""" + if obj == self.dataTable and event.type() == QEvent.Resize: + self._resizeVisibleColumnsToContents() + return False + + def _resizeVisibleColumnsToContents(self): + """Resize the columns that are in the view.""" + index_column = self.dataTable.rect().topLeft().x() + start = col = self.dataTable.columnAt(index_column) + width = self._model.shape[1] + end = self.dataTable.columnAt(self.dataTable.rect().bottomRight().x()) + end = width if end == -1 else end + 1 + if self._max_autosize_ms is None: + max_col_ms = None + else: + max_col_ms = self._max_autosize_ms / max(1, end - start) + while col < end: + resized = False + if col not in self._autosized_cols: + self._autosized_cols.add(col) + resized = True + self._resizeColumnToContents(self.table_header, self.dataTable, + col, max_col_ms) + col += 1 + if resized: + # As we resize columns, the boundary will change + index_column = self.dataTable.rect().bottomRight().x() + end = self.dataTable.columnAt(index_column) + end = width if end == -1 else end + 1 + if max_col_ms is not None: + max_col_ms = self._max_autosize_ms / max(1, end - start) + + def _resizeCurrentColumnToContents(self, new_index, old_index): + """Resize the current column to its contents.""" + if new_index.column() not in self._autosized_cols: + # Ensure the requested column is fully into view after resizing + self._resizeVisibleColumnsToContents() + self.dataTable.scrollTo(new_index) + + def resizeColumnsToContents(self): + """Resize the columns to its contents.""" + self._autosized_cols = set() + self._resizeColumnsToContents(self.table_level, + self.table_index, self._max_autosize_ms) + self._update_layout() + + def change_bgcolor_enable(self, state): + """ + This is implementet so column min/max is only active when bgcolor is + """ + self.dataModel.bgcolor(state) + self.bgcolor_global.setEnabled(not self.is_series and state > 0) + + def change_format(self): + """ + Ask user for display format for floats and use it. + """ + format, valid = QInputDialog.getText(self, _('Format'), + _("Float formatting"), + QLineEdit.Normal, + self.dataModel.get_format()) + if valid: + format = str(format) + try: + format % 1.1 + except: + msg = _("Format ({}) is incorrect").format(format) + QMessageBox.critical(self, _("Error"), msg) + return + if not format.startswith('%'): + msg = _("Format ({}) should start with '%'").format(format) + QMessageBox.critical(self, _("Error"), msg) + return + self.dataModel.set_format(format) + + format = format[1:] + self.set_conf('dataframe_format', format) + + def get_value(self): + """Return modified Dataframe -- this is *not* a copy""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + df = self.dataModel.get_data() + if self.is_series: + return df.iloc[:, 0] + else: + return df + + def _update_header_size(self): + """Update the column width of the header.""" + self.table_header.resizeColumnsToContents() + column_count = self.table_header.model().columnCount() + for index in range(0, column_count): + if index < column_count: + column_width = self.dataTable.columnWidth(index) + header_width = self.table_header.columnWidth(index) + if column_width > header_width: + self.table_header.setColumnWidth(index, column_width) + else: + self.dataTable.setColumnWidth(index, header_width) + else: + break + + def _sort_update(self): + """ + Update the model for all the QTableView objects. + + Uses the model of the dataTable as the base. + """ + # Update index list calculation + self.dataModel.recalculate_index() + self.setModel(self.dataTable.model()) + + def _fetch_more_columns(self): + """Fetch more data for the header (columns).""" + self.table_header.model().fetch_more() + + def _fetch_more_rows(self): + """Fetch more data for the index (rows).""" + self.table_index.model().fetch_more() + + def resize_to_contents(self): + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + self.dataTable.resizeColumnsToContents() + self.dataModel.fetch_more(columns=True) + self.dataTable.resizeColumnsToContents() + self._update_header_size() + QApplication.restoreOverrideCursor() + + +#============================================================================== +# Tests +#============================================================================== +def test_edit(data, title="", parent=None): + """Test subroutine""" + dlg = DataFrameEditor(parent=parent) + + if dlg.setup_and_check(data, title=title): + dlg.exec_() + return dlg.get_value() + else: + import sys + sys.exit(1) + + +def test(): + """DataFrame editor test""" + from numpy import nan + from pandas.util.testing import assert_frame_equal, assert_series_equal + + app = qapplication() # analysis:ignore + + df1 = pd.DataFrame( + [ + [True, "bool"], + [1+1j, "complex"], + ['test', "string"], + [1.11, "float"], + [1, "int"], + [np.random.rand(3, 3), "Unkown type"], + ["Large value", 100], + ["áéí", "unicode"] + ], + index=['a', 'b', nan, nan, nan, 'c', "Test global max", 'd'], + columns=[nan, 'Type'] + ) + out = test_edit(df1) + assert_frame_equal(df1, out) + + result = pd.Series([True, "bool"], index=[nan, 'Type'], name='a') + out = test_edit(df1.iloc[0]) + assert_series_equal(result, out) + + df1 = pd.DataFrame(np.random.rand(100100, 10)) + out = test_edit(df1) + assert_frame_equal(out, df1) + + series = pd.Series(np.arange(10), name=0) + out = test_edit(series) + assert_series_equal(series, out) + + +if __name__ == '__main__': + test() diff --git a/spyder/plugins/variableexplorer/widgets/importwizard.py b/spyder/plugins/variableexplorer/widgets/importwizard.py index 90393fe0ef2..23485af974e 100644 --- a/spyder/plugins/variableexplorer/widgets/importwizard.py +++ b/spyder/plugins/variableexplorer/widgets/importwizard.py @@ -1,642 +1,642 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Text data Importing Wizard based on Qt -""" - -# Standard library imports -import datetime -from functools import partial as ft_partial - -# Third party imports -from qtpy.compat import to_qvariant -from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot -from qtpy.QtGui import QColor, QIntValidator -from qtpy.QtWidgets import (QCheckBox, QDialog, QFrame, QGridLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, - QPushButton, QMenu, QMessageBox, QRadioButton, - QSizePolicy, QSpacerItem, QTableView, QTabWidget, - QTextEdit, QVBoxLayout, QWidget) -from spyder_kernels.utils.lazymodules import ( - FakeObject, numpy as np, pandas as pd) - -# Local import -from spyder.config.base import _ -from spyder.py3compat import (INT_TYPES, io, TEXT_TYPES, to_text_string, - zip_longest) -from spyder.utils import programs -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import add_actions, create_action -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog -from spyder.utils.palette import SpyderPalette - - -def try_to_parse(value): - _types = ('int', 'float') - for _t in _types: - try: - _val = eval("%s('%s')" % (_t, value)) - return _val - except (ValueError, SyntaxError): - pass - return value - - -def try_to_eval(value): - try: - return eval(value) - except (NameError, SyntaxError, ImportError): - return value - - -#----date and datetime objects support -try: - from dateutil.parser import parse as dateparse -except: - def dateparse(datestr, dayfirst=True): # analysis:ignore - """Just for 'day/month/year' strings""" - _a, _b, _c = list(map(int, datestr.split('/'))) - if dayfirst: - return datetime.datetime(_c, _b, _a) - return datetime.datetime(_c, _a, _b) - -def datestr_to_datetime(value, dayfirst=True): - return dateparse(value, dayfirst=dayfirst) - -#----Background colors for supported types -def get_color(value, alpha): - """Return color depending on value type""" - colors = { - bool: SpyderPalette.GROUP_1, - tuple([float] + list(INT_TYPES)): SpyderPalette.GROUP_2, - TEXT_TYPES: SpyderPalette.GROUP_3, - datetime.date: SpyderPalette.GROUP_4, - list: SpyderPalette.GROUP_5, - set: SpyderPalette.GROUP_6, - tuple: SpyderPalette.GROUP_7, - dict: SpyderPalette.GROUP_8, - np.ndarray: SpyderPalette.GROUP_9, - } - - color = QColor() - for typ in colors: - if isinstance(value, typ): - color = QColor(colors[typ]) - color.setAlphaF(alpha) - return color - - -class ContentsWidget(QWidget): - """Import wizard contents widget""" - asDataChanged = Signal(bool) - - def __init__(self, parent, text): - QWidget.__init__(self, parent) - - self.text_editor = QTextEdit(self) - self.text_editor.setText(text) - self.text_editor.setReadOnly(True) - - # Type frame - type_layout = QHBoxLayout() - type_label = QLabel(_("Import as")) - type_layout.addWidget(type_label) - data_btn = QRadioButton(_("data")) - data_btn.setChecked(True) - self._as_data= True - type_layout.addWidget(data_btn) - code_btn = QRadioButton(_("code")) - self._as_code = False - type_layout.addWidget(code_btn) - txt_btn = QRadioButton(_("text")) - type_layout.addWidget(txt_btn) - - h_spacer = QSpacerItem(40, 20, - QSizePolicy.Expanding, QSizePolicy.Minimum) - type_layout.addItem(h_spacer) - type_frame = QFrame() - type_frame.setLayout(type_layout) - - # Opts frame - grid_layout = QGridLayout() - grid_layout.setSpacing(0) - - col_label = QLabel(_("Column separator:")) - grid_layout.addWidget(col_label, 0, 0) - col_w = QWidget() - col_btn_layout = QHBoxLayout() - self.tab_btn = QRadioButton(_("Tab")) - self.tab_btn.setChecked(False) - col_btn_layout.addWidget(self.tab_btn) - self.ws_btn = QRadioButton(_("Whitespace")) - self.ws_btn.setChecked(False) - col_btn_layout.addWidget(self.ws_btn) - other_btn_col = QRadioButton(_("other")) - other_btn_col.setChecked(True) - col_btn_layout.addWidget(other_btn_col) - col_w.setLayout(col_btn_layout) - grid_layout.addWidget(col_w, 0, 1) - self.line_edt = QLineEdit(",") - self.line_edt.setMaximumWidth(30) - self.line_edt.setEnabled(True) - other_btn_col.toggled.connect(self.line_edt.setEnabled) - grid_layout.addWidget(self.line_edt, 0, 2) - - row_label = QLabel(_("Row separator:")) - grid_layout.addWidget(row_label, 1, 0) - row_w = QWidget() - row_btn_layout = QHBoxLayout() - self.eol_btn = QRadioButton(_("EOL")) - self.eol_btn.setChecked(True) - row_btn_layout.addWidget(self.eol_btn) - other_btn_row = QRadioButton(_("other")) - row_btn_layout.addWidget(other_btn_row) - row_w.setLayout(row_btn_layout) - grid_layout.addWidget(row_w, 1, 1) - self.line_edt_row = QLineEdit(";") - self.line_edt_row.setMaximumWidth(30) - self.line_edt_row.setEnabled(False) - other_btn_row.toggled.connect(self.line_edt_row.setEnabled) - grid_layout.addWidget(self.line_edt_row, 1, 2) - - grid_layout.setRowMinimumHeight(2, 15) - - other_group = QGroupBox(_("Additional options")) - other_layout = QGridLayout() - other_group.setLayout(other_layout) - - skiprows_label = QLabel(_("Skip rows:")) - other_layout.addWidget(skiprows_label, 0, 0) - self.skiprows_edt = QLineEdit('0') - self.skiprows_edt.setMaximumWidth(30) - intvalid = QIntValidator(0, len(to_text_string(text).splitlines()), - self.skiprows_edt) - self.skiprows_edt.setValidator(intvalid) - other_layout.addWidget(self.skiprows_edt, 0, 1) - - other_layout.setColumnMinimumWidth(2, 5) - - comments_label = QLabel(_("Comments:")) - other_layout.addWidget(comments_label, 0, 3) - self.comments_edt = QLineEdit('#') - self.comments_edt.setMaximumWidth(30) - other_layout.addWidget(self.comments_edt, 0, 4) - - self.trnsp_box = QCheckBox(_("Transpose")) - #self.trnsp_box.setEnabled(False) - other_layout.addWidget(self.trnsp_box, 1, 0, 2, 0) - - grid_layout.addWidget(other_group, 3, 0, 2, 0) - - opts_frame = QFrame() - opts_frame.setLayout(grid_layout) - - data_btn.toggled.connect(opts_frame.setEnabled) - data_btn.toggled.connect(self.set_as_data) - code_btn.toggled.connect(self.set_as_code) -# self.connect(txt_btn, SIGNAL("toggled(bool)"), -# self, SLOT("is_text(bool)")) - - # Final layout - layout = QVBoxLayout() - layout.addWidget(type_frame) - layout.addWidget(self.text_editor) - layout.addWidget(opts_frame) - self.setLayout(layout) - - def get_as_data(self): - """Return if data type conversion""" - return self._as_data - - def get_as_code(self): - """Return if code type conversion""" - return self._as_code - - def get_as_num(self): - """Return if numeric type conversion""" - return self._as_num - - def get_col_sep(self): - """Return the column separator""" - if self.tab_btn.isChecked(): - return u"\t" - elif self.ws_btn.isChecked(): - return None - return to_text_string(self.line_edt.text()) - - def get_row_sep(self): - """Return the row separator""" - if self.eol_btn.isChecked(): - return u"\n" - return to_text_string(self.line_edt_row.text()) - - def get_skiprows(self): - """Return number of lines to be skipped""" - return int(to_text_string(self.skiprows_edt.text())) - - def get_comments(self): - """Return comment string""" - return to_text_string(self.comments_edt.text()) - - @Slot(bool) - def set_as_data(self, as_data): - """Set if data type conversion""" - self._as_data = as_data - self.asDataChanged.emit(as_data) - - @Slot(bool) - def set_as_code(self, as_code): - """Set if code type conversion""" - self._as_code = as_code - - -class PreviewTableModel(QAbstractTableModel): - """Import wizard preview table model""" - def __init__(self, data=[], parent=None): - QAbstractTableModel.__init__(self, parent) - self._data = data - - def rowCount(self, parent=QModelIndex()): - """Return row count""" - return len(self._data) - - def columnCount(self, parent=QModelIndex()): - """Return column count""" - return len(self._data[0]) - - def _display_data(self, index): - """Return a data element""" - return to_qvariant(self._data[index.row()][index.column()]) - - def data(self, index, role=Qt.DisplayRole): - """Return a model data element""" - if not index.isValid(): - return to_qvariant() - if role == Qt.DisplayRole: - return self._display_data(index) - elif role == Qt.BackgroundColorRole: - return to_qvariant(get_color( - self._data[index.row()][index.column()], 0.5)) - elif role == Qt.TextAlignmentRole: - return to_qvariant(int(Qt.AlignRight|Qt.AlignVCenter)) - return to_qvariant() - - def setData(self, index, value, role=Qt.EditRole): - """Set model data""" - return False - - def get_data(self): - """Return a copy of model data""" - return self._data[:][:] - - def parse_data_type(self, index, **kwargs): - """Parse a type to an other type""" - if not index.isValid(): - return False - try: - if kwargs['atype'] == "date": - self._data[index.row()][index.column()] = \ - datestr_to_datetime(self._data[index.row()][index.column()], - kwargs['dayfirst']).date() - elif kwargs['atype'] == "perc": - _tmp = self._data[index.row()][index.column()].replace("%", "") - self._data[index.row()][index.column()] = eval(_tmp)/100. - elif kwargs['atype'] == "account": - _tmp = self._data[index.row()][index.column()].replace(",", "") - self._data[index.row()][index.column()] = eval(_tmp) - elif kwargs['atype'] == "unicode": - self._data[index.row()][index.column()] = to_text_string( - self._data[index.row()][index.column()]) - elif kwargs['atype'] == "int": - self._data[index.row()][index.column()] = int( - self._data[index.row()][index.column()]) - elif kwargs['atype'] == "float": - self._data[index.row()][index.column()] = float( - self._data[index.row()][index.column()]) - self.dataChanged.emit(index, index) - except Exception as instance: - print(instance) # spyder: test-skip - - def reset(self): - self.beginResetModel() - self.endResetModel() - -class PreviewTable(QTableView): - """Import wizard preview widget""" - def __init__(self, parent): - QTableView.__init__(self, parent) - self._model = None - - # Setting up actions - self.date_dayfirst_action = create_action(self, "dayfirst", - triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=True)) - self.date_monthfirst_action = create_action(self, "monthfirst", - triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=False)) - self.perc_action = create_action(self, "perc", - triggered=ft_partial(self.parse_to_type, atype="perc")) - self.acc_action = create_action(self, "account", - triggered=ft_partial(self.parse_to_type, atype="account")) - self.str_action = create_action(self, "unicode", - triggered=ft_partial(self.parse_to_type, atype="unicode")) - self.int_action = create_action(self, "int", - triggered=ft_partial(self.parse_to_type, atype="int")) - self.float_action = create_action(self, "float", - triggered=ft_partial(self.parse_to_type, atype="float")) - - # Setting up menus - self.date_menu = QMenu() - self.date_menu.setTitle("Date") - add_actions( self.date_menu, (self.date_dayfirst_action, - self.date_monthfirst_action)) - self.parse_menu = QMenu(self) - self.parse_menu.addMenu(self.date_menu) - add_actions( self.parse_menu, (self.perc_action, self.acc_action)) - self.parse_menu.setTitle("String to") - self.opt_menu = QMenu(self) - self.opt_menu.addMenu(self.parse_menu) - add_actions( self.opt_menu, (self.str_action, self.int_action, - self.float_action)) - - def _shape_text(self, text, colsep=u"\t", rowsep=u"\n", - transpose=False, skiprows=0, comments='#'): - """Decode the shape of the given text""" - assert colsep != rowsep - out = [] - text_rows = text.split(rowsep)[skiprows:] - for row in text_rows: - stripped = to_text_string(row).strip() - if len(stripped) == 0 or stripped.startswith(comments): - continue - line = to_text_string(row).split(colsep) - line = [try_to_parse(to_text_string(x)) for x in line] - out.append(line) - # Replace missing elements with np.nan's or None's - if programs.is_module_installed('numpy'): - from numpy import nan - out = list(zip_longest(*out, fillvalue=nan)) - else: - out = list(zip_longest(*out, fillvalue=None)) - # Tranpose the last result to get the expected one - out = [[r[col] for r in out] for col in range(len(out[0]))] - if transpose: - return [[r[col] for r in out] for col in range(len(out[0]))] - return out - - def get_data(self): - """Return model data""" - if self._model is None: - return None - return self._model.get_data() - - def process_data(self, text, colsep=u"\t", rowsep=u"\n", - transpose=False, skiprows=0, comments='#'): - """Put data into table model""" - data = self._shape_text(text, colsep, rowsep, transpose, skiprows, - comments) - self._model = PreviewTableModel(data) - self.setModel(self._model) - - @Slot() - def parse_to_type(self,**kwargs): - """Parse to a given type""" - indexes = self.selectedIndexes() - if not indexes: return - for index in indexes: - self.model().parse_data_type(index, **kwargs) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - self.opt_menu.popup(event.globalPos()) - event.accept() - - -class PreviewWidget(QWidget): - """Import wizard preview widget""" - - def __init__(self, parent): - QWidget.__init__(self, parent) - - vert_layout = QVBoxLayout() - - # Type frame - type_layout = QHBoxLayout() - type_label = QLabel(_("Import as")) - type_layout.addWidget(type_label) - - self.array_btn = array_btn = QRadioButton(_("array")) - available_array = np.ndarray is not FakeObject - array_btn.setEnabled(available_array) - array_btn.setChecked(available_array) - type_layout.addWidget(array_btn) - - list_btn = QRadioButton(_("list")) - list_btn.setChecked(not array_btn.isChecked()) - type_layout.addWidget(list_btn) - - if pd: - self.df_btn = df_btn = QRadioButton(_("DataFrame")) - df_btn.setChecked(False) - type_layout.addWidget(df_btn) - - h_spacer = QSpacerItem(40, 20, - QSizePolicy.Expanding, QSizePolicy.Minimum) - type_layout.addItem(h_spacer) - type_frame = QFrame() - type_frame.setLayout(type_layout) - - self._table_view = PreviewTable(self) - vert_layout.addWidget(type_frame) - vert_layout.addWidget(self._table_view) - self.setLayout(vert_layout) - - def open_data(self, text, colsep=u"\t", rowsep=u"\n", - transpose=False, skiprows=0, comments='#'): - """Open clipboard text as table""" - if pd: - self.pd_text = text - self.pd_info = dict(sep=colsep, lineterminator=rowsep, - skiprows=skiprows, comment=comments) - if colsep is None: - self.pd_info = dict(lineterminator=rowsep, skiprows=skiprows, - comment=comments, delim_whitespace=True) - self._table_view.process_data(text, colsep, rowsep, transpose, - skiprows, comments) - - def get_data(self): - """Return table data""" - return self._table_view.get_data() - - -class ImportWizard(BaseDialog): - """Text data import wizard""" - def __init__(self, parent, text, - title=None, icon=None, contents_title=None, varname=None): - super().__init__(parent) - - # Destroying the C++ object right after closing the dialog box, - # otherwise it may be garbage-collected in another QThread - # (e.g. the editor's analysis thread in Spyder), thus leading to - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - - if title is None: - title = _("Import wizard") - self.setWindowTitle(title) - if icon is None: - self.setWindowIcon(ima.icon('fileimport')) - if contents_title is None: - contents_title = _("Raw text") - - if varname is None: - varname = _("variable_name") - - self.var_name, self.clip_data = None, None - - # Setting GUI - self.tab_widget = QTabWidget(self) - self.text_widget = ContentsWidget(self, text) - self.table_widget = PreviewWidget(self) - - self.tab_widget.addTab(self.text_widget, _("text")) - self.tab_widget.setTabText(0, contents_title) - self.tab_widget.addTab(self.table_widget, _("table")) - self.tab_widget.setTabText(1, _("Preview")) - self.tab_widget.setTabEnabled(1, False) - - name_layout = QHBoxLayout() - name_label = QLabel(_("Variable Name")) - name_layout.addWidget(name_label) - - self.name_edt = QLineEdit() - self.name_edt.setText(varname) - name_layout.addWidget(self.name_edt) - - btns_layout = QHBoxLayout() - cancel_btn = QPushButton(_("Cancel")) - btns_layout.addWidget(cancel_btn) - cancel_btn.clicked.connect(self.reject) - h_spacer = QSpacerItem(40, 20, - QSizePolicy.Expanding, QSizePolicy.Minimum) - btns_layout.addItem(h_spacer) - self.back_btn = QPushButton(_("Previous")) - self.back_btn.setEnabled(False) - btns_layout.addWidget(self.back_btn) - self.back_btn.clicked.connect(ft_partial(self._set_step, step=-1)) - self.fwd_btn = QPushButton(_("Next")) - if not text: - self.fwd_btn.setEnabled(False) - btns_layout.addWidget(self.fwd_btn) - self.fwd_btn.clicked.connect(ft_partial(self._set_step, step=1)) - self.done_btn = QPushButton(_("Done")) - self.done_btn.setEnabled(False) - btns_layout.addWidget(self.done_btn) - self.done_btn.clicked.connect(self.process) - - self.text_widget.asDataChanged.connect(self.fwd_btn.setEnabled) - self.text_widget.asDataChanged.connect(self.done_btn.setDisabled) - layout = QVBoxLayout() - layout.addLayout(name_layout) - layout.addWidget(self.tab_widget) - layout.addLayout(btns_layout) - self.setLayout(layout) - - def _focus_tab(self, tab_idx): - """Change tab focus""" - for i in range(self.tab_widget.count()): - self.tab_widget.setTabEnabled(i, False) - self.tab_widget.setTabEnabled(tab_idx, True) - self.tab_widget.setCurrentIndex(tab_idx) - - def _set_step(self, step): - """Proceed to a given step""" - new_tab = self.tab_widget.currentIndex() + step - assert new_tab < self.tab_widget.count() and new_tab >= 0 - if new_tab == self.tab_widget.count()-1: - try: - self.table_widget.open_data(self._get_plain_text(), - self.text_widget.get_col_sep(), - self.text_widget.get_row_sep(), - self.text_widget.trnsp_box.isChecked(), - self.text_widget.get_skiprows(), - self.text_widget.get_comments()) - self.done_btn.setEnabled(True) - self.done_btn.setDefault(True) - self.fwd_btn.setEnabled(False) - self.back_btn.setEnabled(True) - except (SyntaxError, AssertionError) as error: - QMessageBox.critical(self, _("Import wizard"), - _("Unable to proceed to next step" - "

    Please check your entries." - "

    Error message:
    %s") % str(error)) - return - elif new_tab == 0: - self.done_btn.setEnabled(False) - self.fwd_btn.setEnabled(True) - self.back_btn.setEnabled(False) - self._focus_tab(new_tab) - - def get_data(self): - """Return processed data""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.var_name, self.clip_data - - def _simplify_shape(self, alist, rec=0): - """Reduce the alist dimension if needed""" - if rec != 0: - if len(alist) == 1: - return alist[-1] - return alist - if len(alist) == 1: - return self._simplify_shape(alist[-1], 1) - return [self._simplify_shape(al, 1) for al in alist] - - def _get_table_data(self): - """Return clipboard processed as data""" - data = self._simplify_shape( - self.table_widget.get_data()) - if self.table_widget.array_btn.isChecked(): - return np.array(data) - elif (pd.read_csv is not FakeObject and - self.table_widget.df_btn.isChecked()): - info = self.table_widget.pd_info - buf = io.StringIO(self.table_widget.pd_text) - return pd.read_csv(buf, **info) - return data - - def _get_plain_text(self): - """Return clipboard as text""" - return self.text_widget.text_editor.toPlainText() - - @Slot() - def process(self): - """Process the data from clipboard""" - var_name = self.name_edt.text() - try: - self.var_name = str(var_name) - except UnicodeEncodeError: - self.var_name = to_text_string(var_name) - if self.text_widget.get_as_data(): - self.clip_data = self._get_table_data() - elif self.text_widget.get_as_code(): - self.clip_data = try_to_eval( - to_text_string(self._get_plain_text())) - else: - self.clip_data = to_text_string(self._get_plain_text()) - self.accept() - - -def test(text): - """Test""" - from spyder.utils.qthelpers import qapplication - _app = qapplication() # analysis:ignore - dialog = ImportWizard(None, text) - if dialog.exec_(): - print(dialog.get_data()) # spyder: test-skip - -if __name__ == "__main__": - test(u"17/11/1976\t1.34\n14/05/09\t3.14") +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Text data Importing Wizard based on Qt +""" + +# Standard library imports +import datetime +from functools import partial as ft_partial + +# Third party imports +from qtpy.compat import to_qvariant +from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot +from qtpy.QtGui import QColor, QIntValidator +from qtpy.QtWidgets import (QCheckBox, QDialog, QFrame, QGridLayout, QGroupBox, + QHBoxLayout, QLabel, QLineEdit, + QPushButton, QMenu, QMessageBox, QRadioButton, + QSizePolicy, QSpacerItem, QTableView, QTabWidget, + QTextEdit, QVBoxLayout, QWidget) +from spyder_kernels.utils.lazymodules import ( + FakeObject, numpy as np, pandas as pd) + +# Local import +from spyder.config.base import _ +from spyder.py3compat import (INT_TYPES, io, TEXT_TYPES, to_text_string, + zip_longest) +from spyder.utils import programs +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import add_actions, create_action +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog +from spyder.utils.palette import SpyderPalette + + +def try_to_parse(value): + _types = ('int', 'float') + for _t in _types: + try: + _val = eval("%s('%s')" % (_t, value)) + return _val + except (ValueError, SyntaxError): + pass + return value + + +def try_to_eval(value): + try: + return eval(value) + except (NameError, SyntaxError, ImportError): + return value + + +#----date and datetime objects support +try: + from dateutil.parser import parse as dateparse +except: + def dateparse(datestr, dayfirst=True): # analysis:ignore + """Just for 'day/month/year' strings""" + _a, _b, _c = list(map(int, datestr.split('/'))) + if dayfirst: + return datetime.datetime(_c, _b, _a) + return datetime.datetime(_c, _a, _b) + +def datestr_to_datetime(value, dayfirst=True): + return dateparse(value, dayfirst=dayfirst) + +#----Background colors for supported types +def get_color(value, alpha): + """Return color depending on value type""" + colors = { + bool: SpyderPalette.GROUP_1, + tuple([float] + list(INT_TYPES)): SpyderPalette.GROUP_2, + TEXT_TYPES: SpyderPalette.GROUP_3, + datetime.date: SpyderPalette.GROUP_4, + list: SpyderPalette.GROUP_5, + set: SpyderPalette.GROUP_6, + tuple: SpyderPalette.GROUP_7, + dict: SpyderPalette.GROUP_8, + np.ndarray: SpyderPalette.GROUP_9, + } + + color = QColor() + for typ in colors: + if isinstance(value, typ): + color = QColor(colors[typ]) + color.setAlphaF(alpha) + return color + + +class ContentsWidget(QWidget): + """Import wizard contents widget""" + asDataChanged = Signal(bool) + + def __init__(self, parent, text): + QWidget.__init__(self, parent) + + self.text_editor = QTextEdit(self) + self.text_editor.setText(text) + self.text_editor.setReadOnly(True) + + # Type frame + type_layout = QHBoxLayout() + type_label = QLabel(_("Import as")) + type_layout.addWidget(type_label) + data_btn = QRadioButton(_("data")) + data_btn.setChecked(True) + self._as_data= True + type_layout.addWidget(data_btn) + code_btn = QRadioButton(_("code")) + self._as_code = False + type_layout.addWidget(code_btn) + txt_btn = QRadioButton(_("text")) + type_layout.addWidget(txt_btn) + + h_spacer = QSpacerItem(40, 20, + QSizePolicy.Expanding, QSizePolicy.Minimum) + type_layout.addItem(h_spacer) + type_frame = QFrame() + type_frame.setLayout(type_layout) + + # Opts frame + grid_layout = QGridLayout() + grid_layout.setSpacing(0) + + col_label = QLabel(_("Column separator:")) + grid_layout.addWidget(col_label, 0, 0) + col_w = QWidget() + col_btn_layout = QHBoxLayout() + self.tab_btn = QRadioButton(_("Tab")) + self.tab_btn.setChecked(False) + col_btn_layout.addWidget(self.tab_btn) + self.ws_btn = QRadioButton(_("Whitespace")) + self.ws_btn.setChecked(False) + col_btn_layout.addWidget(self.ws_btn) + other_btn_col = QRadioButton(_("other")) + other_btn_col.setChecked(True) + col_btn_layout.addWidget(other_btn_col) + col_w.setLayout(col_btn_layout) + grid_layout.addWidget(col_w, 0, 1) + self.line_edt = QLineEdit(",") + self.line_edt.setMaximumWidth(30) + self.line_edt.setEnabled(True) + other_btn_col.toggled.connect(self.line_edt.setEnabled) + grid_layout.addWidget(self.line_edt, 0, 2) + + row_label = QLabel(_("Row separator:")) + grid_layout.addWidget(row_label, 1, 0) + row_w = QWidget() + row_btn_layout = QHBoxLayout() + self.eol_btn = QRadioButton(_("EOL")) + self.eol_btn.setChecked(True) + row_btn_layout.addWidget(self.eol_btn) + other_btn_row = QRadioButton(_("other")) + row_btn_layout.addWidget(other_btn_row) + row_w.setLayout(row_btn_layout) + grid_layout.addWidget(row_w, 1, 1) + self.line_edt_row = QLineEdit(";") + self.line_edt_row.setMaximumWidth(30) + self.line_edt_row.setEnabled(False) + other_btn_row.toggled.connect(self.line_edt_row.setEnabled) + grid_layout.addWidget(self.line_edt_row, 1, 2) + + grid_layout.setRowMinimumHeight(2, 15) + + other_group = QGroupBox(_("Additional options")) + other_layout = QGridLayout() + other_group.setLayout(other_layout) + + skiprows_label = QLabel(_("Skip rows:")) + other_layout.addWidget(skiprows_label, 0, 0) + self.skiprows_edt = QLineEdit('0') + self.skiprows_edt.setMaximumWidth(30) + intvalid = QIntValidator(0, len(to_text_string(text).splitlines()), + self.skiprows_edt) + self.skiprows_edt.setValidator(intvalid) + other_layout.addWidget(self.skiprows_edt, 0, 1) + + other_layout.setColumnMinimumWidth(2, 5) + + comments_label = QLabel(_("Comments:")) + other_layout.addWidget(comments_label, 0, 3) + self.comments_edt = QLineEdit('#') + self.comments_edt.setMaximumWidth(30) + other_layout.addWidget(self.comments_edt, 0, 4) + + self.trnsp_box = QCheckBox(_("Transpose")) + #self.trnsp_box.setEnabled(False) + other_layout.addWidget(self.trnsp_box, 1, 0, 2, 0) + + grid_layout.addWidget(other_group, 3, 0, 2, 0) + + opts_frame = QFrame() + opts_frame.setLayout(grid_layout) + + data_btn.toggled.connect(opts_frame.setEnabled) + data_btn.toggled.connect(self.set_as_data) + code_btn.toggled.connect(self.set_as_code) +# self.connect(txt_btn, SIGNAL("toggled(bool)"), +# self, SLOT("is_text(bool)")) + + # Final layout + layout = QVBoxLayout() + layout.addWidget(type_frame) + layout.addWidget(self.text_editor) + layout.addWidget(opts_frame) + self.setLayout(layout) + + def get_as_data(self): + """Return if data type conversion""" + return self._as_data + + def get_as_code(self): + """Return if code type conversion""" + return self._as_code + + def get_as_num(self): + """Return if numeric type conversion""" + return self._as_num + + def get_col_sep(self): + """Return the column separator""" + if self.tab_btn.isChecked(): + return u"\t" + elif self.ws_btn.isChecked(): + return None + return to_text_string(self.line_edt.text()) + + def get_row_sep(self): + """Return the row separator""" + if self.eol_btn.isChecked(): + return u"\n" + return to_text_string(self.line_edt_row.text()) + + def get_skiprows(self): + """Return number of lines to be skipped""" + return int(to_text_string(self.skiprows_edt.text())) + + def get_comments(self): + """Return comment string""" + return to_text_string(self.comments_edt.text()) + + @Slot(bool) + def set_as_data(self, as_data): + """Set if data type conversion""" + self._as_data = as_data + self.asDataChanged.emit(as_data) + + @Slot(bool) + def set_as_code(self, as_code): + """Set if code type conversion""" + self._as_code = as_code + + +class PreviewTableModel(QAbstractTableModel): + """Import wizard preview table model""" + def __init__(self, data=[], parent=None): + QAbstractTableModel.__init__(self, parent) + self._data = data + + def rowCount(self, parent=QModelIndex()): + """Return row count""" + return len(self._data) + + def columnCount(self, parent=QModelIndex()): + """Return column count""" + return len(self._data[0]) + + def _display_data(self, index): + """Return a data element""" + return to_qvariant(self._data[index.row()][index.column()]) + + def data(self, index, role=Qt.DisplayRole): + """Return a model data element""" + if not index.isValid(): + return to_qvariant() + if role == Qt.DisplayRole: + return self._display_data(index) + elif role == Qt.BackgroundColorRole: + return to_qvariant(get_color( + self._data[index.row()][index.column()], 0.5)) + elif role == Qt.TextAlignmentRole: + return to_qvariant(int(Qt.AlignRight|Qt.AlignVCenter)) + return to_qvariant() + + def setData(self, index, value, role=Qt.EditRole): + """Set model data""" + return False + + def get_data(self): + """Return a copy of model data""" + return self._data[:][:] + + def parse_data_type(self, index, **kwargs): + """Parse a type to an other type""" + if not index.isValid(): + return False + try: + if kwargs['atype'] == "date": + self._data[index.row()][index.column()] = \ + datestr_to_datetime(self._data[index.row()][index.column()], + kwargs['dayfirst']).date() + elif kwargs['atype'] == "perc": + _tmp = self._data[index.row()][index.column()].replace("%", "") + self._data[index.row()][index.column()] = eval(_tmp)/100. + elif kwargs['atype'] == "account": + _tmp = self._data[index.row()][index.column()].replace(",", "") + self._data[index.row()][index.column()] = eval(_tmp) + elif kwargs['atype'] == "unicode": + self._data[index.row()][index.column()] = to_text_string( + self._data[index.row()][index.column()]) + elif kwargs['atype'] == "int": + self._data[index.row()][index.column()] = int( + self._data[index.row()][index.column()]) + elif kwargs['atype'] == "float": + self._data[index.row()][index.column()] = float( + self._data[index.row()][index.column()]) + self.dataChanged.emit(index, index) + except Exception as instance: + print(instance) # spyder: test-skip + + def reset(self): + self.beginResetModel() + self.endResetModel() + +class PreviewTable(QTableView): + """Import wizard preview widget""" + def __init__(self, parent): + QTableView.__init__(self, parent) + self._model = None + + # Setting up actions + self.date_dayfirst_action = create_action(self, "dayfirst", + triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=True)) + self.date_monthfirst_action = create_action(self, "monthfirst", + triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=False)) + self.perc_action = create_action(self, "perc", + triggered=ft_partial(self.parse_to_type, atype="perc")) + self.acc_action = create_action(self, "account", + triggered=ft_partial(self.parse_to_type, atype="account")) + self.str_action = create_action(self, "unicode", + triggered=ft_partial(self.parse_to_type, atype="unicode")) + self.int_action = create_action(self, "int", + triggered=ft_partial(self.parse_to_type, atype="int")) + self.float_action = create_action(self, "float", + triggered=ft_partial(self.parse_to_type, atype="float")) + + # Setting up menus + self.date_menu = QMenu() + self.date_menu.setTitle("Date") + add_actions( self.date_menu, (self.date_dayfirst_action, + self.date_monthfirst_action)) + self.parse_menu = QMenu(self) + self.parse_menu.addMenu(self.date_menu) + add_actions( self.parse_menu, (self.perc_action, self.acc_action)) + self.parse_menu.setTitle("String to") + self.opt_menu = QMenu(self) + self.opt_menu.addMenu(self.parse_menu) + add_actions( self.opt_menu, (self.str_action, self.int_action, + self.float_action)) + + def _shape_text(self, text, colsep=u"\t", rowsep=u"\n", + transpose=False, skiprows=0, comments='#'): + """Decode the shape of the given text""" + assert colsep != rowsep + out = [] + text_rows = text.split(rowsep)[skiprows:] + for row in text_rows: + stripped = to_text_string(row).strip() + if len(stripped) == 0 or stripped.startswith(comments): + continue + line = to_text_string(row).split(colsep) + line = [try_to_parse(to_text_string(x)) for x in line] + out.append(line) + # Replace missing elements with np.nan's or None's + if programs.is_module_installed('numpy'): + from numpy import nan + out = list(zip_longest(*out, fillvalue=nan)) + else: + out = list(zip_longest(*out, fillvalue=None)) + # Tranpose the last result to get the expected one + out = [[r[col] for r in out] for col in range(len(out[0]))] + if transpose: + return [[r[col] for r in out] for col in range(len(out[0]))] + return out + + def get_data(self): + """Return model data""" + if self._model is None: + return None + return self._model.get_data() + + def process_data(self, text, colsep=u"\t", rowsep=u"\n", + transpose=False, skiprows=0, comments='#'): + """Put data into table model""" + data = self._shape_text(text, colsep, rowsep, transpose, skiprows, + comments) + self._model = PreviewTableModel(data) + self.setModel(self._model) + + @Slot() + def parse_to_type(self,**kwargs): + """Parse to a given type""" + indexes = self.selectedIndexes() + if not indexes: return + for index in indexes: + self.model().parse_data_type(index, **kwargs) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + self.opt_menu.popup(event.globalPos()) + event.accept() + + +class PreviewWidget(QWidget): + """Import wizard preview widget""" + + def __init__(self, parent): + QWidget.__init__(self, parent) + + vert_layout = QVBoxLayout() + + # Type frame + type_layout = QHBoxLayout() + type_label = QLabel(_("Import as")) + type_layout.addWidget(type_label) + + self.array_btn = array_btn = QRadioButton(_("array")) + available_array = np.ndarray is not FakeObject + array_btn.setEnabled(available_array) + array_btn.setChecked(available_array) + type_layout.addWidget(array_btn) + + list_btn = QRadioButton(_("list")) + list_btn.setChecked(not array_btn.isChecked()) + type_layout.addWidget(list_btn) + + if pd: + self.df_btn = df_btn = QRadioButton(_("DataFrame")) + df_btn.setChecked(False) + type_layout.addWidget(df_btn) + + h_spacer = QSpacerItem(40, 20, + QSizePolicy.Expanding, QSizePolicy.Minimum) + type_layout.addItem(h_spacer) + type_frame = QFrame() + type_frame.setLayout(type_layout) + + self._table_view = PreviewTable(self) + vert_layout.addWidget(type_frame) + vert_layout.addWidget(self._table_view) + self.setLayout(vert_layout) + + def open_data(self, text, colsep=u"\t", rowsep=u"\n", + transpose=False, skiprows=0, comments='#'): + """Open clipboard text as table""" + if pd: + self.pd_text = text + self.pd_info = dict(sep=colsep, lineterminator=rowsep, + skiprows=skiprows, comment=comments) + if colsep is None: + self.pd_info = dict(lineterminator=rowsep, skiprows=skiprows, + comment=comments, delim_whitespace=True) + self._table_view.process_data(text, colsep, rowsep, transpose, + skiprows, comments) + + def get_data(self): + """Return table data""" + return self._table_view.get_data() + + +class ImportWizard(BaseDialog): + """Text data import wizard""" + def __init__(self, parent, text, + title=None, icon=None, contents_title=None, varname=None): + super().__init__(parent) + + # Destroying the C++ object right after closing the dialog box, + # otherwise it may be garbage-collected in another QThread + # (e.g. the editor's analysis thread in Spyder), thus leading to + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + + if title is None: + title = _("Import wizard") + self.setWindowTitle(title) + if icon is None: + self.setWindowIcon(ima.icon('fileimport')) + if contents_title is None: + contents_title = _("Raw text") + + if varname is None: + varname = _("variable_name") + + self.var_name, self.clip_data = None, None + + # Setting GUI + self.tab_widget = QTabWidget(self) + self.text_widget = ContentsWidget(self, text) + self.table_widget = PreviewWidget(self) + + self.tab_widget.addTab(self.text_widget, _("text")) + self.tab_widget.setTabText(0, contents_title) + self.tab_widget.addTab(self.table_widget, _("table")) + self.tab_widget.setTabText(1, _("Preview")) + self.tab_widget.setTabEnabled(1, False) + + name_layout = QHBoxLayout() + name_label = QLabel(_("Variable Name")) + name_layout.addWidget(name_label) + + self.name_edt = QLineEdit() + self.name_edt.setText(varname) + name_layout.addWidget(self.name_edt) + + btns_layout = QHBoxLayout() + cancel_btn = QPushButton(_("Cancel")) + btns_layout.addWidget(cancel_btn) + cancel_btn.clicked.connect(self.reject) + h_spacer = QSpacerItem(40, 20, + QSizePolicy.Expanding, QSizePolicy.Minimum) + btns_layout.addItem(h_spacer) + self.back_btn = QPushButton(_("Previous")) + self.back_btn.setEnabled(False) + btns_layout.addWidget(self.back_btn) + self.back_btn.clicked.connect(ft_partial(self._set_step, step=-1)) + self.fwd_btn = QPushButton(_("Next")) + if not text: + self.fwd_btn.setEnabled(False) + btns_layout.addWidget(self.fwd_btn) + self.fwd_btn.clicked.connect(ft_partial(self._set_step, step=1)) + self.done_btn = QPushButton(_("Done")) + self.done_btn.setEnabled(False) + btns_layout.addWidget(self.done_btn) + self.done_btn.clicked.connect(self.process) + + self.text_widget.asDataChanged.connect(self.fwd_btn.setEnabled) + self.text_widget.asDataChanged.connect(self.done_btn.setDisabled) + layout = QVBoxLayout() + layout.addLayout(name_layout) + layout.addWidget(self.tab_widget) + layout.addLayout(btns_layout) + self.setLayout(layout) + + def _focus_tab(self, tab_idx): + """Change tab focus""" + for i in range(self.tab_widget.count()): + self.tab_widget.setTabEnabled(i, False) + self.tab_widget.setTabEnabled(tab_idx, True) + self.tab_widget.setCurrentIndex(tab_idx) + + def _set_step(self, step): + """Proceed to a given step""" + new_tab = self.tab_widget.currentIndex() + step + assert new_tab < self.tab_widget.count() and new_tab >= 0 + if new_tab == self.tab_widget.count()-1: + try: + self.table_widget.open_data(self._get_plain_text(), + self.text_widget.get_col_sep(), + self.text_widget.get_row_sep(), + self.text_widget.trnsp_box.isChecked(), + self.text_widget.get_skiprows(), + self.text_widget.get_comments()) + self.done_btn.setEnabled(True) + self.done_btn.setDefault(True) + self.fwd_btn.setEnabled(False) + self.back_btn.setEnabled(True) + except (SyntaxError, AssertionError) as error: + QMessageBox.critical(self, _("Import wizard"), + _("Unable to proceed to next step" + "

    Please check your entries." + "

    Error message:
    %s") % str(error)) + return + elif new_tab == 0: + self.done_btn.setEnabled(False) + self.fwd_btn.setEnabled(True) + self.back_btn.setEnabled(False) + self._focus_tab(new_tab) + + def get_data(self): + """Return processed data""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.var_name, self.clip_data + + def _simplify_shape(self, alist, rec=0): + """Reduce the alist dimension if needed""" + if rec != 0: + if len(alist) == 1: + return alist[-1] + return alist + if len(alist) == 1: + return self._simplify_shape(alist[-1], 1) + return [self._simplify_shape(al, 1) for al in alist] + + def _get_table_data(self): + """Return clipboard processed as data""" + data = self._simplify_shape( + self.table_widget.get_data()) + if self.table_widget.array_btn.isChecked(): + return np.array(data) + elif (pd.read_csv is not FakeObject and + self.table_widget.df_btn.isChecked()): + info = self.table_widget.pd_info + buf = io.StringIO(self.table_widget.pd_text) + return pd.read_csv(buf, **info) + return data + + def _get_plain_text(self): + """Return clipboard as text""" + return self.text_widget.text_editor.toPlainText() + + @Slot() + def process(self): + """Process the data from clipboard""" + var_name = self.name_edt.text() + try: + self.var_name = str(var_name) + except UnicodeEncodeError: + self.var_name = to_text_string(var_name) + if self.text_widget.get_as_data(): + self.clip_data = self._get_table_data() + elif self.text_widget.get_as_code(): + self.clip_data = try_to_eval( + to_text_string(self._get_plain_text())) + else: + self.clip_data = to_text_string(self._get_plain_text()) + self.accept() + + +def test(text): + """Test""" + from spyder.utils.qthelpers import qapplication + _app = qapplication() # analysis:ignore + dialog = ImportWizard(None, text) + if dialog.exec_(): + print(dialog.get_data()) # spyder: test-skip + +if __name__ == "__main__": + test(u"17/11/1976\t1.34\n14/05/09\t3.14") diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py index f4217ec1eaf..3a945fdb837 100644 --- a/spyder/plugins/variableexplorer/widgets/main_widget.py +++ b/spyder/plugins/variableexplorer/widgets/main_widget.py @@ -1,642 +1,642 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Variable Explorer Main Plugin Widget. -""" - -# Third party imports -from qtpy.QtCore import QTimer, Signal, Slot -from qtpy.QtWidgets import ( - QAction, QHBoxLayout, QWidget) - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.shellconnect.main_widget import ShellConnectMainWidget -from spyder.plugins.variableexplorer.widgets.namespacebrowser import ( - NamespaceBrowser, NamespacesBrowserFinder, VALID_VARIABLE_CHARS) -from spyder.utils.programs import is_module_installed - -# Localization -_ = get_translation('spyder') - - -# ============================================================================= -# ---- Constants -# ============================================================================= -class VariableExplorerWidgetActions: - # Triggers - ImportData = 'import_data_action' - SaveData = 'save_data_action' - SaveDataAs = 'save_data_as_action' - ResetNamespace = 'reset_namespaces_action' - Search = 'search' - Refresh = 'refresh' - - # Toggles - ToggleExcludePrivate = 'toggle_exclude_private_action' - ToggleExcludeUpperCase = 'toggle_exclude_uppercase_action' - ToggleExcludeCapitalized = 'toggle_exclude_capitalized_action' - ToggleExcludeUnsupported = 'toggle_exclude_unsupported_action' - ToggleExcludeCallablesAndModules = ( - 'toggle_exclude_callables_and_modules_action') - ToggleMinMax = 'toggle_minmax_action' - - -class VariableExplorerWidgetOptionsMenuSections: - Display = 'excludes_section' - Highlight = 'highlight_section' - - -class VariableExplorerWidgetMainToolBarSections: - Main = 'main_section' - - -class VariableExplorerWidgetMenus: - EmptyContextMenu = 'empty' - PopulatedContextMenu = 'populated' - - -class VariableExplorerContextMenuActions: - ResizeRowsAction = 'resize_rows_action' - ResizeColumnsAction = 'resize_columns_action' - PasteAction = 'paste_action' - CopyAction = 'copy' - EditAction = 'edit_action' - PlotAction = 'plot_action' - HistogramAction = 'histogram_action' - ImshowAction = 'imshow_action' - SaveArrayAction = 'save_array_action' - InsertAction = 'insert_action' - RemoveAction = 'remove_action' - RenameAction = 'rename_action' - DuplicateAction = 'duplicate_action' - ViewAction = 'view_action' - - -class VariableExplorerContextMenuSections: - Edit = 'edit_section' - Insert = 'insert_section' - View = 'view_section' - Resize = 'resize_section' - - -# ============================================================================= -# ---- Widgets -# ============================================================================= - -class VariableExplorerWidget(ShellConnectMainWidget): - - # PluginMainWidget class constants - ENABLE_SPINNER = True - - # Other class constants - INITIAL_FREE_MEMORY_TIME_TRIGGER = 60 * 1000 # ms - SECONDARY_FREE_MEMORY_TIME_TRIGGER = 180 * 1000 # ms - - # Signals - sig_free_memory_requested = Signal() - - def __init__(self, name=None, plugin=None, parent=None): - super().__init__(name, plugin, parent) - - # Widgets - self.context_menu = None - self.empty_context_menu = None - - # --- Finder - self.finder = None - - # ---- PluginMainWidget API - # ------------------------------------------------------------------------ - def get_title(self): - return _('Variable Explorer') - - def setup(self): - # ---- Options menu actions - exclude_private_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludePrivate, - text=_("Exclude private variables"), - tip=_("Exclude variables that start with an underscore"), - toggled=True, - option='exclude_private', - ) - - exclude_uppercase_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeUpperCase, - text=_("Exclude all-uppercase variables"), - tip=_("Exclude variables whose name is uppercase"), - toggled=True, - option='exclude_uppercase', - ) - - exclude_capitalized_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeCapitalized, - text=_("Exclude capitalized variables"), - tip=_("Exclude variables whose name starts with a capital " - "letter"), - toggled=True, - option='exclude_capitalized', - ) - - exclude_unsupported_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeUnsupported, - text=_("Exclude unsupported data types"), - tip=_("Exclude references to data types that don't have " - "an specialized viewer or can't be edited."), - toggled=True, - option='exclude_unsupported', - ) - - exclude_callables_and_modules_action = self.create_action( - VariableExplorerWidgetActions.ToggleExcludeCallablesAndModules, - text=_("Exclude callables and modules"), - tip=_("Exclude references to functions, modules and " - "any other callable."), - toggled=True, - option='exclude_callables_and_modules' - ) - - self.show_minmax_action = self.create_action( - VariableExplorerWidgetActions.ToggleMinMax, - text=_("Show arrays min/max"), - tip=_("Show minimum and maximum of arrays"), - toggled=True, - option='minmax' - ) - - # ---- Toolbar actions - import_data_action = self.create_action( - VariableExplorerWidgetActions.ImportData, - text=_('Import data'), - icon=self.create_icon('fileimport'), - triggered=lambda x: self.import_data(), - ) - - save_action = self.create_action( - VariableExplorerWidgetActions.SaveData, - text=_("Save data"), - icon=self.create_icon('filesave'), - triggered=lambda x: self.save_data(), - ) - - save_as_action = self.create_action( - VariableExplorerWidgetActions.SaveDataAs, - text=_("Save data as..."), - icon=self.create_icon('filesaveas'), - triggered=lambda x: self.save_data(), - ) - - reset_namespace_action = self.create_action( - VariableExplorerWidgetActions.ResetNamespace, - text=_("Remove all variables"), - icon=self.create_icon('editdelete'), - triggered=lambda x: self.reset_namespace(), - ) - - search_action = self.create_action( - VariableExplorerWidgetActions.Search, - text=_("Search variable names and types"), - icon=self.create_icon('find'), - toggled=self.show_finder, - register_shortcut=True - ) - - refresh_action = self.create_action( - VariableExplorerWidgetActions.Refresh, - text=_("Refresh variables"), - icon=self.create_icon('refresh'), - triggered=self.refresh_table, - register_shortcut=True, - ) - - # ---- Context menu actions - resize_rows_action = self.create_action( - VariableExplorerContextMenuActions.ResizeRowsAction, - text=_("Resize rows to contents"), - icon=self.create_icon('collapse_row'), - triggered=self.resize_rows - ) - - resize_columns_action = self.create_action( - VariableExplorerContextMenuActions.ResizeColumnsAction, - _("Resize columns to contents"), - icon=self.create_icon('collapse_column'), - triggered=self.resize_columns - ) - - self.paste_action = self.create_action( - VariableExplorerContextMenuActions.PasteAction, - _("Paste"), - icon=self.create_icon('editpaste'), - triggered=self.paste - ) - - self.copy_action = self.create_action( - VariableExplorerContextMenuActions.CopyAction, - _("Copy"), - icon=self.create_icon('editcopy'), - triggered=self.copy - ) - - self.edit_action = self.create_action( - VariableExplorerContextMenuActions.EditAction, - _("Edit"), - icon=self.create_icon('edit'), - triggered=self.edit_item - ) - - self.plot_action = self.create_action( - VariableExplorerContextMenuActions.PlotAction, - _("Plot"), - icon=self.create_icon('plot'), - triggered=self.plot_item - ) - self.plot_action.setVisible(False) - - self.hist_action = self.create_action( - VariableExplorerContextMenuActions.HistogramAction, - _("Histogram"), - icon=self.create_icon('hist'), - triggered=self.histogram_item - ) - self.hist_action.setVisible(False) - - self.imshow_action = self.create_action( - VariableExplorerContextMenuActions.ImshowAction, - _("Show image"), - icon=self.create_icon('imshow'), - triggered=self.imshow_item - ) - self.imshow_action.setVisible(False) - - self.save_array_action = self.create_action( - VariableExplorerContextMenuActions.SaveArrayAction, - _("Save array"), - icon=self.create_icon('filesave'), - triggered=self.save_array - ) - self.save_array_action.setVisible(False) - - self.insert_action = self.create_action( - VariableExplorerContextMenuActions.InsertAction, - _("Insert"), - icon=self.create_icon('insert'), - triggered=self.insert_item - ) - - self.remove_action = self.create_action( - VariableExplorerContextMenuActions.RemoveAction, - _("Remove"), - icon=self.create_icon('editdelete'), - triggered=self.remove_item - ) - - self.rename_action = self.create_action( - VariableExplorerContextMenuActions.RenameAction, - _("Rename"), - icon=self.create_icon('rename'), - triggered=self.rename_item - ) - - self.duplicate_action = self.create_action( - VariableExplorerContextMenuActions.DuplicateAction, - _("Duplicate"), - icon=self.create_icon('edit_add'), - triggered=self.duplicate_item - ) - - self.view_action = self.create_action( - VariableExplorerContextMenuActions.ViewAction, - _("View with the Object Explorer"), - icon=self.create_icon('outline_explorer'), - triggered=self.view_item - ) - - # Options menu - options_menu = self.get_options_menu() - for item in [exclude_private_action, exclude_uppercase_action, - exclude_capitalized_action, exclude_unsupported_action, - exclude_callables_and_modules_action, - self.show_minmax_action]: - self.add_item_to_menu( - item, - menu=options_menu, - section=VariableExplorerWidgetOptionsMenuSections.Display, - ) - - # Main toolbar - main_toolbar = self.get_main_toolbar() - for item in [import_data_action, save_action, save_as_action, - reset_namespace_action, search_action, refresh_action]: - self.add_item_to_toolbar( - item, - toolbar=main_toolbar, - section=VariableExplorerWidgetMainToolBarSections.Main, - ) - save_action.setEnabled(False) - - # ---- Context menu to show when there are variables present - self.context_menu = self.create_menu( - VariableExplorerWidgetMenus.PopulatedContextMenu) - for item in [self.edit_action, self.copy_action, self.paste_action, - self.rename_action, self.remove_action, - self.save_array_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.Edit, - ) - - for item in [self.insert_action, self.duplicate_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.Insert, - ) - - for item in [self.view_action, self.plot_action, self.hist_action, - self.imshow_action, self.show_minmax_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.View, - ) - - for item in [resize_rows_action, resize_columns_action]: - self.add_item_to_menu( - item, - menu=self.context_menu, - section=VariableExplorerContextMenuSections.Resize, - ) - - # ---- Context menu when the variable explorer is empty - self.empty_context_menu = self.create_menu( - VariableExplorerWidgetMenus.EmptyContextMenu) - for item in [self.insert_action, self.paste_action]: - self.add_item_to_menu( - item, - menu=self.empty_context_menu, - section=VariableExplorerContextMenuSections.Edit, - ) - - def update_actions(self): - action = self.get_action(VariableExplorerWidgetActions.ToggleMinMax) - action.setEnabled(is_module_installed('numpy')) - nsb = self.current_widget() - - for __, action in self.get_actions().items(): - if action: - # IMPORTANT: Since we are defining the main actions in here - # and the context is WidgetWithChildrenShortcut we need to - # assign the same actions to the children widgets in order - # for shortcuts to work - if nsb: - save_data_action = self.get_action( - VariableExplorerWidgetActions.SaveData) - save_data_action.setEnabled(nsb.filename is not None) - - nsb_actions = nsb.actions() - if action not in nsb_actions: - nsb.addAction(action) - - @on_conf_change - def on_section_conf_change(self, section): - for index in range(self.count()): - widget = self._stack.widget(index) - if widget: - widget.setup() - - # ---- Stack accesors - # ------------------------------------------------------------------------ - def update_finder(self, nsb, old_nsb): - """Initialize or update finder widget.""" - if self.finder is None: - # Initialize finder/search related widgets - self.finder = QWidget(self) - self.text_finder = NamespacesBrowserFinder( - nsb.editor, - callback=nsb.editor.set_regex, - main=nsb, - regex_base=VALID_VARIABLE_CHARS) - self.finder.text_finder = self.text_finder - self.finder_close_button = self.create_toolbutton( - 'close_finder', - triggered=self.hide_finder, - icon=self.create_icon('DialogCloseButton'), - ) - - finder_layout = QHBoxLayout() - finder_layout.addWidget(self.finder_close_button) - finder_layout.addWidget(self.text_finder) - finder_layout.setContentsMargins(0, 0, 0, 0) - self.finder.setLayout(finder_layout) - - layout = self.layout() - layout.addSpacing(1) - layout.addWidget(self.finder) - else: - # Just update references to the same text_finder (Custom QLineEdit) - # widget to the new current NamespaceBrowser and save current - # finder state in the previous NamespaceBrowser - if old_nsb is not None: - self.save_finder_state(old_nsb) - self.text_finder.update_parent( - nsb.editor, - callback=nsb.editor.set_regex, - main=nsb, - ) - - def switch_widget(self, nsb, old_nsb): - """ - Set the current NamespaceBrowser. - - This also setup the finder widget to work with the current - NamespaceBrowser. - """ - self.update_finder(nsb, old_nsb) - finder_visible = nsb.set_text_finder(self.text_finder) - self.finder.setVisible(finder_visible) - search_action = self.get_action(VariableExplorerWidgetActions.Search) - search_action.setChecked(finder_visible) - - # ---- Public API - # ------------------------------------------------------------------------ - - def create_new_widget(self, shellwidget): - nsb = NamespaceBrowser(self) - nsb.set_shellwidget(shellwidget) - nsb.setup() - nsb.sig_free_memory_requested.connect( - self.free_memory) - nsb.sig_start_spinner_requested.connect( - self.start_spinner) - nsb.sig_stop_spinner_requested.connect( - self.stop_spinner) - nsb.sig_hide_finder_requested.connect( - self.hide_finder) - self._set_actions_and_menus(nsb) - return nsb - - def close_widget(self, nsb): - nsb.close() - - def import_data(self, filenames=None): - """ - Import data in current namespace. - """ - if self.count(): - nsb = self.current_widget() - nsb.refresh_table() - nsb.import_data(filenames=filenames) - - def save_data(self): - if self.count(): - nsb = self.current_widget() - nsb.save_data() - self.update_actions() - - def reset_namespace(self): - if self.count(): - nsb = self.current_widget() - nsb.reset_namespace() - - @Slot(bool) - def show_finder(self, checked): - if self.count(): - nsb = self.current_widget() - if checked: - self.finder.text_finder.setText(nsb.last_find) - else: - self.save_finder_state(nsb) - self.finder.text_finder.setText('') - self.finder.setVisible(checked) - if self.finder.isVisible(): - self.finder.text_finder.setFocus() - else: - nsb.editor.setFocus() - - @Slot() - def hide_finder(self): - action = self.get_action(VariableExplorerWidgetActions.Search) - action.setChecked(False) - nsb = self.current_widget() - self.save_finder_state(nsb) - self.finder.text_finder.setText('') - - def save_finder_state(self, nsb): - """ - Save finder state (last input text and visibility). - - The values are saved in the given NamespaceBrowser. - """ - last_find = self.text_finder.text() - finder_visibility = self.finder.isVisible() - nsb.save_finder_state(last_find, finder_visibility) - - def refresh_table(self): - if self.count(): - nsb = self.current_widget() - nsb.refresh_table() - - @Slot() - def free_memory(self): - """ - Free memory signal. - """ - self.sig_free_memory_requested.emit() - QTimer.singleShot(self.INITIAL_FREE_MEMORY_TIME_TRIGGER, - self.sig_free_memory_requested) - QTimer.singleShot(self.SECONDARY_FREE_MEMORY_TIME_TRIGGER, - self.sig_free_memory_requested) - - def resize_rows(self): - self._current_editor.resizeRowsToContents() - - def resize_columns(self): - self._current_editor.resize_column_contents() - - def paste(self): - self._current_editor.paste() - - def copy(self): - self._current_editor.copy() - - def edit_item(self): - self._current_editor.edit_item() - - def plot_item(self): - self._current_editor.plot_item('plot') - - def histogram_item(self): - self._current_editor.plot_item('hist') - - def imshow_item(self): - self._current_editor.imshow_item() - - def save_array(self): - self._current_editor.save_array() - - def insert_item(self): - self._current_editor.insert_item(below=False) - - def remove_item(self): - self._current_editor.remove_item() - - def rename_item(self): - self._current_editor.rename_item() - - def duplicate_item(self): - self._current_editor.duplicate_item() - - def view_item(self): - self._current_editor.view_item() - - # ---- Private API - # ------------------------------------------------------------------------ - @property - def _current_editor(self): - editor = None - if self.count(): - nsb = self.current_widget() - editor = nsb.editor - return editor - - def _set_actions_and_menus(self, nsb): - """ - Set actions and menus created here and used by the namespace - browser editor. - - Although this is not ideal, it's necessary to be able to use - the CollectionsEditor widget separately from this plugin. - """ - editor = nsb.editor - - # Actions - editor.paste_action = self.paste_action - editor.copy_action = self.copy_action - editor.edit_action = self.edit_action - editor.plot_action = self.plot_action - editor.hist_action = self.hist_action - editor.imshow_action = self.imshow_action - editor.save_array_action = self.save_array_action - editor.insert_action = self.insert_action - editor.remove_action = self.remove_action - editor.minmax_action = self.show_minmax_action - editor.rename_action = self.rename_action - editor.duplicate_action = self.duplicate_action - editor.view_action = self.view_action - - # Menus - editor.menu = self.context_menu - editor.empty_ws_menu = self.empty_context_menu - - # These actions are not used for dictionaries (so we don't need them - # for namespaces) but we have to create them so they can be used in - # several places in CollectionsEditor. - editor.insert_action_above = QAction() - editor.insert_action_below = QAction() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Variable Explorer Main Plugin Widget. +""" + +# Third party imports +from qtpy.QtCore import QTimer, Signal, Slot +from qtpy.QtWidgets import ( + QAction, QHBoxLayout, QWidget) + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.shellconnect.main_widget import ShellConnectMainWidget +from spyder.plugins.variableexplorer.widgets.namespacebrowser import ( + NamespaceBrowser, NamespacesBrowserFinder, VALID_VARIABLE_CHARS) +from spyder.utils.programs import is_module_installed + +# Localization +_ = get_translation('spyder') + + +# ============================================================================= +# ---- Constants +# ============================================================================= +class VariableExplorerWidgetActions: + # Triggers + ImportData = 'import_data_action' + SaveData = 'save_data_action' + SaveDataAs = 'save_data_as_action' + ResetNamespace = 'reset_namespaces_action' + Search = 'search' + Refresh = 'refresh' + + # Toggles + ToggleExcludePrivate = 'toggle_exclude_private_action' + ToggleExcludeUpperCase = 'toggle_exclude_uppercase_action' + ToggleExcludeCapitalized = 'toggle_exclude_capitalized_action' + ToggleExcludeUnsupported = 'toggle_exclude_unsupported_action' + ToggleExcludeCallablesAndModules = ( + 'toggle_exclude_callables_and_modules_action') + ToggleMinMax = 'toggle_minmax_action' + + +class VariableExplorerWidgetOptionsMenuSections: + Display = 'excludes_section' + Highlight = 'highlight_section' + + +class VariableExplorerWidgetMainToolBarSections: + Main = 'main_section' + + +class VariableExplorerWidgetMenus: + EmptyContextMenu = 'empty' + PopulatedContextMenu = 'populated' + + +class VariableExplorerContextMenuActions: + ResizeRowsAction = 'resize_rows_action' + ResizeColumnsAction = 'resize_columns_action' + PasteAction = 'paste_action' + CopyAction = 'copy' + EditAction = 'edit_action' + PlotAction = 'plot_action' + HistogramAction = 'histogram_action' + ImshowAction = 'imshow_action' + SaveArrayAction = 'save_array_action' + InsertAction = 'insert_action' + RemoveAction = 'remove_action' + RenameAction = 'rename_action' + DuplicateAction = 'duplicate_action' + ViewAction = 'view_action' + + +class VariableExplorerContextMenuSections: + Edit = 'edit_section' + Insert = 'insert_section' + View = 'view_section' + Resize = 'resize_section' + + +# ============================================================================= +# ---- Widgets +# ============================================================================= + +class VariableExplorerWidget(ShellConnectMainWidget): + + # PluginMainWidget class constants + ENABLE_SPINNER = True + + # Other class constants + INITIAL_FREE_MEMORY_TIME_TRIGGER = 60 * 1000 # ms + SECONDARY_FREE_MEMORY_TIME_TRIGGER = 180 * 1000 # ms + + # Signals + sig_free_memory_requested = Signal() + + def __init__(self, name=None, plugin=None, parent=None): + super().__init__(name, plugin, parent) + + # Widgets + self.context_menu = None + self.empty_context_menu = None + + # --- Finder + self.finder = None + + # ---- PluginMainWidget API + # ------------------------------------------------------------------------ + def get_title(self): + return _('Variable Explorer') + + def setup(self): + # ---- Options menu actions + exclude_private_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludePrivate, + text=_("Exclude private variables"), + tip=_("Exclude variables that start with an underscore"), + toggled=True, + option='exclude_private', + ) + + exclude_uppercase_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeUpperCase, + text=_("Exclude all-uppercase variables"), + tip=_("Exclude variables whose name is uppercase"), + toggled=True, + option='exclude_uppercase', + ) + + exclude_capitalized_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeCapitalized, + text=_("Exclude capitalized variables"), + tip=_("Exclude variables whose name starts with a capital " + "letter"), + toggled=True, + option='exclude_capitalized', + ) + + exclude_unsupported_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeUnsupported, + text=_("Exclude unsupported data types"), + tip=_("Exclude references to data types that don't have " + "an specialized viewer or can't be edited."), + toggled=True, + option='exclude_unsupported', + ) + + exclude_callables_and_modules_action = self.create_action( + VariableExplorerWidgetActions.ToggleExcludeCallablesAndModules, + text=_("Exclude callables and modules"), + tip=_("Exclude references to functions, modules and " + "any other callable."), + toggled=True, + option='exclude_callables_and_modules' + ) + + self.show_minmax_action = self.create_action( + VariableExplorerWidgetActions.ToggleMinMax, + text=_("Show arrays min/max"), + tip=_("Show minimum and maximum of arrays"), + toggled=True, + option='minmax' + ) + + # ---- Toolbar actions + import_data_action = self.create_action( + VariableExplorerWidgetActions.ImportData, + text=_('Import data'), + icon=self.create_icon('fileimport'), + triggered=lambda x: self.import_data(), + ) + + save_action = self.create_action( + VariableExplorerWidgetActions.SaveData, + text=_("Save data"), + icon=self.create_icon('filesave'), + triggered=lambda x: self.save_data(), + ) + + save_as_action = self.create_action( + VariableExplorerWidgetActions.SaveDataAs, + text=_("Save data as..."), + icon=self.create_icon('filesaveas'), + triggered=lambda x: self.save_data(), + ) + + reset_namespace_action = self.create_action( + VariableExplorerWidgetActions.ResetNamespace, + text=_("Remove all variables"), + icon=self.create_icon('editdelete'), + triggered=lambda x: self.reset_namespace(), + ) + + search_action = self.create_action( + VariableExplorerWidgetActions.Search, + text=_("Search variable names and types"), + icon=self.create_icon('find'), + toggled=self.show_finder, + register_shortcut=True + ) + + refresh_action = self.create_action( + VariableExplorerWidgetActions.Refresh, + text=_("Refresh variables"), + icon=self.create_icon('refresh'), + triggered=self.refresh_table, + register_shortcut=True, + ) + + # ---- Context menu actions + resize_rows_action = self.create_action( + VariableExplorerContextMenuActions.ResizeRowsAction, + text=_("Resize rows to contents"), + icon=self.create_icon('collapse_row'), + triggered=self.resize_rows + ) + + resize_columns_action = self.create_action( + VariableExplorerContextMenuActions.ResizeColumnsAction, + _("Resize columns to contents"), + icon=self.create_icon('collapse_column'), + triggered=self.resize_columns + ) + + self.paste_action = self.create_action( + VariableExplorerContextMenuActions.PasteAction, + _("Paste"), + icon=self.create_icon('editpaste'), + triggered=self.paste + ) + + self.copy_action = self.create_action( + VariableExplorerContextMenuActions.CopyAction, + _("Copy"), + icon=self.create_icon('editcopy'), + triggered=self.copy + ) + + self.edit_action = self.create_action( + VariableExplorerContextMenuActions.EditAction, + _("Edit"), + icon=self.create_icon('edit'), + triggered=self.edit_item + ) + + self.plot_action = self.create_action( + VariableExplorerContextMenuActions.PlotAction, + _("Plot"), + icon=self.create_icon('plot'), + triggered=self.plot_item + ) + self.plot_action.setVisible(False) + + self.hist_action = self.create_action( + VariableExplorerContextMenuActions.HistogramAction, + _("Histogram"), + icon=self.create_icon('hist'), + triggered=self.histogram_item + ) + self.hist_action.setVisible(False) + + self.imshow_action = self.create_action( + VariableExplorerContextMenuActions.ImshowAction, + _("Show image"), + icon=self.create_icon('imshow'), + triggered=self.imshow_item + ) + self.imshow_action.setVisible(False) + + self.save_array_action = self.create_action( + VariableExplorerContextMenuActions.SaveArrayAction, + _("Save array"), + icon=self.create_icon('filesave'), + triggered=self.save_array + ) + self.save_array_action.setVisible(False) + + self.insert_action = self.create_action( + VariableExplorerContextMenuActions.InsertAction, + _("Insert"), + icon=self.create_icon('insert'), + triggered=self.insert_item + ) + + self.remove_action = self.create_action( + VariableExplorerContextMenuActions.RemoveAction, + _("Remove"), + icon=self.create_icon('editdelete'), + triggered=self.remove_item + ) + + self.rename_action = self.create_action( + VariableExplorerContextMenuActions.RenameAction, + _("Rename"), + icon=self.create_icon('rename'), + triggered=self.rename_item + ) + + self.duplicate_action = self.create_action( + VariableExplorerContextMenuActions.DuplicateAction, + _("Duplicate"), + icon=self.create_icon('edit_add'), + triggered=self.duplicate_item + ) + + self.view_action = self.create_action( + VariableExplorerContextMenuActions.ViewAction, + _("View with the Object Explorer"), + icon=self.create_icon('outline_explorer'), + triggered=self.view_item + ) + + # Options menu + options_menu = self.get_options_menu() + for item in [exclude_private_action, exclude_uppercase_action, + exclude_capitalized_action, exclude_unsupported_action, + exclude_callables_and_modules_action, + self.show_minmax_action]: + self.add_item_to_menu( + item, + menu=options_menu, + section=VariableExplorerWidgetOptionsMenuSections.Display, + ) + + # Main toolbar + main_toolbar = self.get_main_toolbar() + for item in [import_data_action, save_action, save_as_action, + reset_namespace_action, search_action, refresh_action]: + self.add_item_to_toolbar( + item, + toolbar=main_toolbar, + section=VariableExplorerWidgetMainToolBarSections.Main, + ) + save_action.setEnabled(False) + + # ---- Context menu to show when there are variables present + self.context_menu = self.create_menu( + VariableExplorerWidgetMenus.PopulatedContextMenu) + for item in [self.edit_action, self.copy_action, self.paste_action, + self.rename_action, self.remove_action, + self.save_array_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.Edit, + ) + + for item in [self.insert_action, self.duplicate_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.Insert, + ) + + for item in [self.view_action, self.plot_action, self.hist_action, + self.imshow_action, self.show_minmax_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.View, + ) + + for item in [resize_rows_action, resize_columns_action]: + self.add_item_to_menu( + item, + menu=self.context_menu, + section=VariableExplorerContextMenuSections.Resize, + ) + + # ---- Context menu when the variable explorer is empty + self.empty_context_menu = self.create_menu( + VariableExplorerWidgetMenus.EmptyContextMenu) + for item in [self.insert_action, self.paste_action]: + self.add_item_to_menu( + item, + menu=self.empty_context_menu, + section=VariableExplorerContextMenuSections.Edit, + ) + + def update_actions(self): + action = self.get_action(VariableExplorerWidgetActions.ToggleMinMax) + action.setEnabled(is_module_installed('numpy')) + nsb = self.current_widget() + + for __, action in self.get_actions().items(): + if action: + # IMPORTANT: Since we are defining the main actions in here + # and the context is WidgetWithChildrenShortcut we need to + # assign the same actions to the children widgets in order + # for shortcuts to work + if nsb: + save_data_action = self.get_action( + VariableExplorerWidgetActions.SaveData) + save_data_action.setEnabled(nsb.filename is not None) + + nsb_actions = nsb.actions() + if action not in nsb_actions: + nsb.addAction(action) + + @on_conf_change + def on_section_conf_change(self, section): + for index in range(self.count()): + widget = self._stack.widget(index) + if widget: + widget.setup() + + # ---- Stack accesors + # ------------------------------------------------------------------------ + def update_finder(self, nsb, old_nsb): + """Initialize or update finder widget.""" + if self.finder is None: + # Initialize finder/search related widgets + self.finder = QWidget(self) + self.text_finder = NamespacesBrowserFinder( + nsb.editor, + callback=nsb.editor.set_regex, + main=nsb, + regex_base=VALID_VARIABLE_CHARS) + self.finder.text_finder = self.text_finder + self.finder_close_button = self.create_toolbutton( + 'close_finder', + triggered=self.hide_finder, + icon=self.create_icon('DialogCloseButton'), + ) + + finder_layout = QHBoxLayout() + finder_layout.addWidget(self.finder_close_button) + finder_layout.addWidget(self.text_finder) + finder_layout.setContentsMargins(0, 0, 0, 0) + self.finder.setLayout(finder_layout) + + layout = self.layout() + layout.addSpacing(1) + layout.addWidget(self.finder) + else: + # Just update references to the same text_finder (Custom QLineEdit) + # widget to the new current NamespaceBrowser and save current + # finder state in the previous NamespaceBrowser + if old_nsb is not None: + self.save_finder_state(old_nsb) + self.text_finder.update_parent( + nsb.editor, + callback=nsb.editor.set_regex, + main=nsb, + ) + + def switch_widget(self, nsb, old_nsb): + """ + Set the current NamespaceBrowser. + + This also setup the finder widget to work with the current + NamespaceBrowser. + """ + self.update_finder(nsb, old_nsb) + finder_visible = nsb.set_text_finder(self.text_finder) + self.finder.setVisible(finder_visible) + search_action = self.get_action(VariableExplorerWidgetActions.Search) + search_action.setChecked(finder_visible) + + # ---- Public API + # ------------------------------------------------------------------------ + + def create_new_widget(self, shellwidget): + nsb = NamespaceBrowser(self) + nsb.set_shellwidget(shellwidget) + nsb.setup() + nsb.sig_free_memory_requested.connect( + self.free_memory) + nsb.sig_start_spinner_requested.connect( + self.start_spinner) + nsb.sig_stop_spinner_requested.connect( + self.stop_spinner) + nsb.sig_hide_finder_requested.connect( + self.hide_finder) + self._set_actions_and_menus(nsb) + return nsb + + def close_widget(self, nsb): + nsb.close() + + def import_data(self, filenames=None): + """ + Import data in current namespace. + """ + if self.count(): + nsb = self.current_widget() + nsb.refresh_table() + nsb.import_data(filenames=filenames) + + def save_data(self): + if self.count(): + nsb = self.current_widget() + nsb.save_data() + self.update_actions() + + def reset_namespace(self): + if self.count(): + nsb = self.current_widget() + nsb.reset_namespace() + + @Slot(bool) + def show_finder(self, checked): + if self.count(): + nsb = self.current_widget() + if checked: + self.finder.text_finder.setText(nsb.last_find) + else: + self.save_finder_state(nsb) + self.finder.text_finder.setText('') + self.finder.setVisible(checked) + if self.finder.isVisible(): + self.finder.text_finder.setFocus() + else: + nsb.editor.setFocus() + + @Slot() + def hide_finder(self): + action = self.get_action(VariableExplorerWidgetActions.Search) + action.setChecked(False) + nsb = self.current_widget() + self.save_finder_state(nsb) + self.finder.text_finder.setText('') + + def save_finder_state(self, nsb): + """ + Save finder state (last input text and visibility). + + The values are saved in the given NamespaceBrowser. + """ + last_find = self.text_finder.text() + finder_visibility = self.finder.isVisible() + nsb.save_finder_state(last_find, finder_visibility) + + def refresh_table(self): + if self.count(): + nsb = self.current_widget() + nsb.refresh_table() + + @Slot() + def free_memory(self): + """ + Free memory signal. + """ + self.sig_free_memory_requested.emit() + QTimer.singleShot(self.INITIAL_FREE_MEMORY_TIME_TRIGGER, + self.sig_free_memory_requested) + QTimer.singleShot(self.SECONDARY_FREE_MEMORY_TIME_TRIGGER, + self.sig_free_memory_requested) + + def resize_rows(self): + self._current_editor.resizeRowsToContents() + + def resize_columns(self): + self._current_editor.resize_column_contents() + + def paste(self): + self._current_editor.paste() + + def copy(self): + self._current_editor.copy() + + def edit_item(self): + self._current_editor.edit_item() + + def plot_item(self): + self._current_editor.plot_item('plot') + + def histogram_item(self): + self._current_editor.plot_item('hist') + + def imshow_item(self): + self._current_editor.imshow_item() + + def save_array(self): + self._current_editor.save_array() + + def insert_item(self): + self._current_editor.insert_item(below=False) + + def remove_item(self): + self._current_editor.remove_item() + + def rename_item(self): + self._current_editor.rename_item() + + def duplicate_item(self): + self._current_editor.duplicate_item() + + def view_item(self): + self._current_editor.view_item() + + # ---- Private API + # ------------------------------------------------------------------------ + @property + def _current_editor(self): + editor = None + if self.count(): + nsb = self.current_widget() + editor = nsb.editor + return editor + + def _set_actions_and_menus(self, nsb): + """ + Set actions and menus created here and used by the namespace + browser editor. + + Although this is not ideal, it's necessary to be able to use + the CollectionsEditor widget separately from this plugin. + """ + editor = nsb.editor + + # Actions + editor.paste_action = self.paste_action + editor.copy_action = self.copy_action + editor.edit_action = self.edit_action + editor.plot_action = self.plot_action + editor.hist_action = self.hist_action + editor.imshow_action = self.imshow_action + editor.save_array_action = self.save_array_action + editor.insert_action = self.insert_action + editor.remove_action = self.remove_action + editor.minmax_action = self.show_minmax_action + editor.rename_action = self.rename_action + editor.duplicate_action = self.duplicate_action + editor.view_action = self.view_action + + # Menus + editor.menu = self.context_menu + editor.empty_ws_menu = self.empty_context_menu + + # These actions are not used for dictionaries (so we don't need them + # for namespaces) but we have to create them so they can be used in + # several places in CollectionsEditor. + editor.insert_action_above = QAction() + editor.insert_action_below = QAction() diff --git a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py index ecfae98a9c5..e45eee5fd93 100644 --- a/spyder/plugins/variableexplorer/widgets/namespacebrowser.py +++ b/spyder/plugins/variableexplorer/widgets/namespacebrowser.py @@ -1,321 +1,321 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Namespace browser widget. - -This is the main widget used in the Variable Explorer plugin -""" - -# Standard library imports -import os -import os.path as osp - -# Third library imports -from qtpy import PYQT5 -from qtpy.compat import getopenfilenames, getsavefilename -from qtpy.QtCore import Qt, Signal, Slot -from qtpy.QtGui import QCursor -from qtpy.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, - QMessageBox, QVBoxLayout, QWidget) -from spyder_kernels.utils.iofuncs import iofunctions -from spyder_kernels.utils.misc import fix_reference_name -from spyder_kernels.utils.nsview import REMOTE_SETTINGS - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.widgets.collectionseditor import RemoteCollectionsEditorTableView -from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard -from spyder.utils import encoding -from spyder.utils.misc import getcwd_or_home, remove_backslashes -from spyder.widgets.helperwidgets import FinderLineEdit - - -# Localization -_ = get_translation('spyder') - -# Constants -VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w" - - -class NamespaceBrowser(QWidget, SpyderWidgetMixin): - """ - Namespace browser (global variables explorer widget). - """ - # This is necessary to test the widget separately from its plugin - CONF_SECTION = 'variable_explorer' - - # Signals - sig_free_memory_requested = Signal() - sig_start_spinner_requested = Signal() - sig_stop_spinner_requested = Signal() - sig_hide_finder_requested = Signal() - - def __init__(self, parent): - if PYQT5: - super().__init__(parent=parent, class_parent=parent) - else: - QWidget.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - # Attributes - self.filename = None - self.text_finder = None - self.last_find = '' - self.finder_is_visible = False - - # Widgets - self.editor = None - self.shellwidget = None - - def setup(self): - """ - Setup the namespace browser with provided options. - """ - assert self.shellwidget is not None - - if self.editor is not None: - self.shellwidget.set_namespace_view_settings() - self.refresh_table() - else: - # Widgets - self.editor = RemoteCollectionsEditorTableView( - self, - data=None, - shellwidget=self.shellwidget, - create_menu=False, - ) - - # Signals - self.editor.sig_files_dropped.connect(self.import_data) - self.editor.sig_free_memory_requested.connect( - self.sig_free_memory_requested) - self.editor.sig_editor_creation_started.connect( - self.sig_start_spinner_requested) - self.editor.sig_editor_shown.connect( - self.sig_stop_spinner_requested) - - # Layout - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.editor) - self.setLayout(layout) - - def get_view_settings(self): - """Return dict editor view settings""" - settings = {} - for name in REMOTE_SETTINGS: - settings[name] = self.get_conf(name) - - return settings - - def set_shellwidget(self, shellwidget): - """Bind shellwidget instance to namespace browser""" - self.shellwidget = shellwidget - shellwidget.set_namespacebrowser(self) - - def set_text_finder(self, text_finder): - """Bind NamespaceBrowsersFinder to namespace browser.""" - self.text_finder = text_finder - if self.finder_is_visible: - self.text_finder.setText(self.last_find) - self.editor.finder = text_finder - - return self.finder_is_visible - - def save_finder_state(self, last_find, finder_visibility): - """Save last finder/search text input and finder visibility.""" - if last_find and finder_visibility: - self.last_find = last_find - self.finder_is_visible = finder_visibility - - def refresh_table(self): - """Refresh variable table.""" - self.shellwidget.refresh_namespacebrowser() - try: - self.editor.resizeRowToContents() - except TypeError: - pass - - def process_remote_view(self, remote_view): - """Process remote view""" - # To load all variables when a new filtering search is - # started. - self.text_finder.load_all = False - - if remote_view is not None: - self.set_data(remote_view) - - def set_var_properties(self, properties): - """Set properties of variables""" - if properties is not None: - self.editor.var_properties = properties - - def set_data(self, data): - """Set data.""" - if data != self.editor.source_model.get_data(): - self.editor.set_data(data) - self.editor.adjust_columns() - - @Slot(list) - def import_data(self, filenames=None): - """Import data from text file.""" - title = _("Import data") - if filenames is None: - if self.filename is None: - basedir = getcwd_or_home() - else: - basedir = osp.dirname(self.filename) - filenames, _selfilter = getopenfilenames(self, title, basedir, - iofunctions.load_filters) - if not filenames: - return - elif isinstance(filenames, str): - filenames = [filenames] - - for filename in filenames: - self.filename = str(filename) - if os.name == "nt": - self.filename = remove_backslashes(self.filename) - extension = osp.splitext(self.filename)[1].lower() - - if extension not in iofunctions.load_funcs: - buttons = QMessageBox.Yes | QMessageBox.Cancel - answer = QMessageBox.question(self, title, - _("Unsupported file extension '%s'

    " - "Would you like to import it anyway " - "(by selecting a known file format)?" - ) % extension, buttons) - if answer == QMessageBox.Cancel: - return - formats = list(iofunctions.load_extensions.keys()) - item, ok = QInputDialog.getItem(self, title, - _('Open file as:'), - formats, 0, False) - if ok: - extension = iofunctions.load_extensions[str(item)] - else: - return - - load_func = iofunctions.load_funcs[extension] - - # 'import_wizard' (self.setup_io) - if isinstance(load_func, str): - # Import data with import wizard - error_message = None - try: - text, _encoding = encoding.read(self.filename) - base_name = osp.basename(self.filename) - editor = ImportWizard(self, text, title=base_name, - varname=fix_reference_name(base_name)) - if editor.exec_(): - var_name, clip_data = editor.get_data() - self.editor.new_value(var_name, clip_data) - except Exception as error: - error_message = str(error) - else: - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - QApplication.processEvents() - error_message = self.shellwidget.load_data(self.filename, - extension) - QApplication.restoreOverrideCursor() - QApplication.processEvents() - - if error_message is not None: - QMessageBox.critical(self, title, - _("Unable to load '%s'" - "

    " - "The error message was:
    %s" - ) % (self.filename, error_message)) - self.refresh_table() - - def reset_namespace(self): - warning = self.get_conf( - section='ipython_console', - option='show_reset_namespace_warning' - ) - self.shellwidget.reset_namespace(warning=warning, message=True) - self.editor.automatic_column_width = True - - def save_data(self): - """Save data""" - filename = self.filename - if filename is None: - filename = getcwd_or_home() - extension = osp.splitext(filename)[1].lower() - if not extension: - # Needed to prevent trying to save a data file without extension - # See spyder-ide/spyder#7196 - filename = filename + '.spydata' - filename, _selfilter = getsavefilename(self, _("Save data"), - filename, - iofunctions.save_filters) - if filename: - self.filename = filename - else: - return False - - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - QApplication.processEvents() - - error_message = self.shellwidget.save_namespace(self.filename) - - QApplication.restoreOverrideCursor() - QApplication.processEvents() - if error_message is not None: - if 'Some objects could not be saved:' in error_message: - save_data_message = ( - _("Some objects could not be saved:") - + "

    {obj_list}".format( - obj_list=error_message.split(': ')[1])) - else: - save_data_message = _( - "Unable to save current workspace" - "

    " - "The error message was:
    ") + error_message - - QMessageBox.critical(self, _("Save data"), save_data_message) - - -class NamespacesBrowserFinder(FinderLineEdit): - """Textbox for filtering listed variables in the table.""" - # To load all variables when filtering. - load_all = False - - def update_parent(self, parent, callback=None, main=None): - self._parent = parent - self.main = main - try: - self.textChanged.disconnect() - except TypeError: - pass - if callback: - self.textChanged.connect(callback) - - def load_all_variables(self): - """Load all variables to correctly filter them.""" - if not self.load_all: - self._parent.parent().editor.source_model.load_all() - self.load_all = True - - def keyPressEvent(self, event): - """Qt and FilterLineEdit Override.""" - key = event.key() - if key in [Qt.Key_Up]: - self.load_all_variables() - self._parent.previous_row() - elif key in [Qt.Key_Down]: - self.load_all_variables() - self._parent.next_row() - elif key in [Qt.Key_Escape]: - self.main.sig_hide_finder_requested.emit() - elif key in [Qt.Key_Enter, Qt.Key_Return]: - # TODO: Check if an editor needs to be shown - pass - else: - self.load_all_variables() - super(NamespacesBrowserFinder, self).keyPressEvent(event) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Namespace browser widget. + +This is the main widget used in the Variable Explorer plugin +""" + +# Standard library imports +import os +import os.path as osp + +# Third library imports +from qtpy import PYQT5 +from qtpy.compat import getopenfilenames, getsavefilename +from qtpy.QtCore import Qt, Signal, Slot +from qtpy.QtGui import QCursor +from qtpy.QtWidgets import (QApplication, QHBoxLayout, QInputDialog, + QMessageBox, QVBoxLayout, QWidget) +from spyder_kernels.utils.iofuncs import iofunctions +from spyder_kernels.utils.misc import fix_reference_name +from spyder_kernels.utils.nsview import REMOTE_SETTINGS + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.widgets.collectionseditor import RemoteCollectionsEditorTableView +from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard +from spyder.utils import encoding +from spyder.utils.misc import getcwd_or_home, remove_backslashes +from spyder.widgets.helperwidgets import FinderLineEdit + + +# Localization +_ = get_translation('spyder') + +# Constants +VALID_VARIABLE_CHARS = r"[^\w+*=¡!¿?'\"#$%&()/<>\-\[\]{}^`´;,|¬]*\w" + + +class NamespaceBrowser(QWidget, SpyderWidgetMixin): + """ + Namespace browser (global variables explorer widget). + """ + # This is necessary to test the widget separately from its plugin + CONF_SECTION = 'variable_explorer' + + # Signals + sig_free_memory_requested = Signal() + sig_start_spinner_requested = Signal() + sig_stop_spinner_requested = Signal() + sig_hide_finder_requested = Signal() + + def __init__(self, parent): + if PYQT5: + super().__init__(parent=parent, class_parent=parent) + else: + QWidget.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + # Attributes + self.filename = None + self.text_finder = None + self.last_find = '' + self.finder_is_visible = False + + # Widgets + self.editor = None + self.shellwidget = None + + def setup(self): + """ + Setup the namespace browser with provided options. + """ + assert self.shellwidget is not None + + if self.editor is not None: + self.shellwidget.set_namespace_view_settings() + self.refresh_table() + else: + # Widgets + self.editor = RemoteCollectionsEditorTableView( + self, + data=None, + shellwidget=self.shellwidget, + create_menu=False, + ) + + # Signals + self.editor.sig_files_dropped.connect(self.import_data) + self.editor.sig_free_memory_requested.connect( + self.sig_free_memory_requested) + self.editor.sig_editor_creation_started.connect( + self.sig_start_spinner_requested) + self.editor.sig_editor_shown.connect( + self.sig_stop_spinner_requested) + + # Layout + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.editor) + self.setLayout(layout) + + def get_view_settings(self): + """Return dict editor view settings""" + settings = {} + for name in REMOTE_SETTINGS: + settings[name] = self.get_conf(name) + + return settings + + def set_shellwidget(self, shellwidget): + """Bind shellwidget instance to namespace browser""" + self.shellwidget = shellwidget + shellwidget.set_namespacebrowser(self) + + def set_text_finder(self, text_finder): + """Bind NamespaceBrowsersFinder to namespace browser.""" + self.text_finder = text_finder + if self.finder_is_visible: + self.text_finder.setText(self.last_find) + self.editor.finder = text_finder + + return self.finder_is_visible + + def save_finder_state(self, last_find, finder_visibility): + """Save last finder/search text input and finder visibility.""" + if last_find and finder_visibility: + self.last_find = last_find + self.finder_is_visible = finder_visibility + + def refresh_table(self): + """Refresh variable table.""" + self.shellwidget.refresh_namespacebrowser() + try: + self.editor.resizeRowToContents() + except TypeError: + pass + + def process_remote_view(self, remote_view): + """Process remote view""" + # To load all variables when a new filtering search is + # started. + self.text_finder.load_all = False + + if remote_view is not None: + self.set_data(remote_view) + + def set_var_properties(self, properties): + """Set properties of variables""" + if properties is not None: + self.editor.var_properties = properties + + def set_data(self, data): + """Set data.""" + if data != self.editor.source_model.get_data(): + self.editor.set_data(data) + self.editor.adjust_columns() + + @Slot(list) + def import_data(self, filenames=None): + """Import data from text file.""" + title = _("Import data") + if filenames is None: + if self.filename is None: + basedir = getcwd_or_home() + else: + basedir = osp.dirname(self.filename) + filenames, _selfilter = getopenfilenames(self, title, basedir, + iofunctions.load_filters) + if not filenames: + return + elif isinstance(filenames, str): + filenames = [filenames] + + for filename in filenames: + self.filename = str(filename) + if os.name == "nt": + self.filename = remove_backslashes(self.filename) + extension = osp.splitext(self.filename)[1].lower() + + if extension not in iofunctions.load_funcs: + buttons = QMessageBox.Yes | QMessageBox.Cancel + answer = QMessageBox.question(self, title, + _("Unsupported file extension '%s'

    " + "Would you like to import it anyway " + "(by selecting a known file format)?" + ) % extension, buttons) + if answer == QMessageBox.Cancel: + return + formats = list(iofunctions.load_extensions.keys()) + item, ok = QInputDialog.getItem(self, title, + _('Open file as:'), + formats, 0, False) + if ok: + extension = iofunctions.load_extensions[str(item)] + else: + return + + load_func = iofunctions.load_funcs[extension] + + # 'import_wizard' (self.setup_io) + if isinstance(load_func, str): + # Import data with import wizard + error_message = None + try: + text, _encoding = encoding.read(self.filename) + base_name = osp.basename(self.filename) + editor = ImportWizard(self, text, title=base_name, + varname=fix_reference_name(base_name)) + if editor.exec_(): + var_name, clip_data = editor.get_data() + self.editor.new_value(var_name, clip_data) + except Exception as error: + error_message = str(error) + else: + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QApplication.processEvents() + error_message = self.shellwidget.load_data(self.filename, + extension) + QApplication.restoreOverrideCursor() + QApplication.processEvents() + + if error_message is not None: + QMessageBox.critical(self, title, + _("Unable to load '%s'" + "

    " + "The error message was:
    %s" + ) % (self.filename, error_message)) + self.refresh_table() + + def reset_namespace(self): + warning = self.get_conf( + section='ipython_console', + option='show_reset_namespace_warning' + ) + self.shellwidget.reset_namespace(warning=warning, message=True) + self.editor.automatic_column_width = True + + def save_data(self): + """Save data""" + filename = self.filename + if filename is None: + filename = getcwd_or_home() + extension = osp.splitext(filename)[1].lower() + if not extension: + # Needed to prevent trying to save a data file without extension + # See spyder-ide/spyder#7196 + filename = filename + '.spydata' + filename, _selfilter = getsavefilename(self, _("Save data"), + filename, + iofunctions.save_filters) + if filename: + self.filename = filename + else: + return False + + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QApplication.processEvents() + + error_message = self.shellwidget.save_namespace(self.filename) + + QApplication.restoreOverrideCursor() + QApplication.processEvents() + if error_message is not None: + if 'Some objects could not be saved:' in error_message: + save_data_message = ( + _("Some objects could not be saved:") + + "

    {obj_list}".format( + obj_list=error_message.split(': ')[1])) + else: + save_data_message = _( + "Unable to save current workspace" + "

    " + "The error message was:
    ") + error_message + + QMessageBox.critical(self, _("Save data"), save_data_message) + + +class NamespacesBrowserFinder(FinderLineEdit): + """Textbox for filtering listed variables in the table.""" + # To load all variables when filtering. + load_all = False + + def update_parent(self, parent, callback=None, main=None): + self._parent = parent + self.main = main + try: + self.textChanged.disconnect() + except TypeError: + pass + if callback: + self.textChanged.connect(callback) + + def load_all_variables(self): + """Load all variables to correctly filter them.""" + if not self.load_all: + self._parent.parent().editor.source_model.load_all() + self.load_all = True + + def keyPressEvent(self, event): + """Qt and FilterLineEdit Override.""" + key = event.key() + if key in [Qt.Key_Up]: + self.load_all_variables() + self._parent.previous_row() + elif key in [Qt.Key_Down]: + self.load_all_variables() + self._parent.next_row() + elif key in [Qt.Key_Escape]: + self.main.sig_hide_finder_requested.emit() + elif key in [Qt.Key_Enter, Qt.Key_Return]: + # TODO: Check if an editor needs to be shown + pass + else: + self.load_all_variables() + super(NamespacesBrowserFinder, self).keyPressEvent(event) diff --git a/spyder/plugins/variableexplorer/widgets/objecteditor.py b/spyder/plugins/variableexplorer/widgets/objecteditor.py index cc2747c8d1c..5fc885103b8 100644 --- a/spyder/plugins/variableexplorer/widgets/objecteditor.py +++ b/spyder/plugins/variableexplorer/widgets/objecteditor.py @@ -1,175 +1,175 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Generic object editor dialog -""" - -# Standard library imports -import datetime - -# Third party imports -from qtpy.QtCore import QObject -from spyder_kernels.utils.lazymodules import ( - FakeObject, numpy as np, pandas as pd, PIL) -from spyder_kernels.utils.nsview import is_known_type - -# Local imports -from spyder.py3compat import is_text_string -from spyder.plugins.variableexplorer.widgets.arrayeditor import ArrayEditor -from spyder.plugins.variableexplorer.widgets.dataframeeditor import ( - DataFrameEditor) -from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor -from spyder.widgets.collectionseditor import CollectionsEditor - - -class DialogKeeper(QObject): - def __init__(self): - QObject.__init__(self) - self.dialogs = {} - self.namespace = None - - def set_namespace(self, namespace): - self.namespace = namespace - - def create_dialog(self, dialog, refname, func): - self.dialogs[id(dialog)] = dialog, refname, func - dialog.accepted.connect( - lambda eid=id(dialog): self.editor_accepted(eid)) - dialog.rejected.connect( - lambda eid=id(dialog): self.editor_rejected(eid)) - dialog.show() - dialog.activateWindow() - dialog.raise_() - - def editor_accepted(self, dialog_id): - dialog, refname, func = self.dialogs[dialog_id] - self.namespace[refname] = func(dialog) - self.dialogs.pop(dialog_id) - - def editor_rejected(self, dialog_id): - self.dialogs.pop(dialog_id) - -keeper = DialogKeeper() - - -def create_dialog(obj, obj_name): - """Creates the editor dialog and returns a tuple (dialog, func) where func - is the function to be called with the dialog instance as argument, after - quitting the dialog box - - The role of this intermediate function is to allow easy monkey-patching. - (uschmitt suggested this indirection here so that he can monkey patch - oedit to show eMZed related data) - """ - # Local import - conv_func = lambda data: data - readonly = not is_known_type(obj) - if isinstance(obj, np.ndarray) and np.ndarray is not FakeObject: - dialog = ArrayEditor() - if not dialog.setup_and_check(obj, title=obj_name, - readonly=readonly): - return - elif (isinstance(obj, PIL.Image.Image) and PIL.Image is not FakeObject - and np.ndarray is not FakeObject): - dialog = ArrayEditor() - data = np.array(obj) - if not dialog.setup_and_check(data, title=obj_name, - readonly=readonly): - return - conv_func = lambda data: PIL.Image.fromarray(data, mode=obj.mode) - elif (isinstance(obj, (pd.DataFrame, pd.Series)) and - pd.DataFrame is not FakeObject): - dialog = DataFrameEditor() - if not dialog.setup_and_check(obj): - return - elif is_text_string(obj): - dialog = TextEditor(obj, title=obj_name, readonly=readonly) - else: - dialog = CollectionsEditor() - dialog.setup(obj, title=obj_name, readonly=readonly) - - def end_func(dialog): - return conv_func(dialog.get_value()) - - return dialog, end_func - - -def oedit(obj, modal=True, namespace=None, app=None): - """Edit the object 'obj' in a GUI-based editor and return the edited copy - (if Cancel is pressed, return None) - - The object 'obj' is a container - - Supported container types: - dict, list, set, tuple, str/unicode or numpy.array - - (instantiate a new QApplication if necessary, - so it can be called directly from the interpreter) - """ - if modal: - obj_name = '' - else: - assert is_text_string(obj) - obj_name = obj - if namespace is None: - namespace = globals() - keeper.set_namespace(namespace) - obj = namespace[obj_name] - # keep QApplication reference alive in the Python interpreter: - namespace['__qapp__'] = app - - result = create_dialog(obj, obj_name) - if result is None: - return - dialog, end_func = result - - if modal: - if dialog.exec_(): - return end_func(dialog) - else: - keeper.create_dialog(dialog, obj_name, end_func) - import os - if os.name == 'nt' and app: - app.exec_() - - -#============================================================================== -# Tests -#============================================================================== -def test(): - """Run object editor test""" - # Local import - from spyder.utils.qthelpers import qapplication - app = qapplication() # analysis:ignore - - data = np.random.randint(1, 256, size=(100, 100)).astype('uint8') - image = PIL.Image.fromarray(data) - example = {'str': 'kjkj kj k j j kj k jkj', - 'list': [1, 3, 4, 'kjkj', None], - 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, - 'dict': {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]}, - 'float': 1.2233, - 'array': np.random.rand(10, 10), - 'image': image, - 'date': datetime.date(1945, 5, 8), - 'datetime': datetime.datetime(1945, 5, 8), - } - image = oedit(image) - class Foobar(object): - def __init__(self): - self.text = "toto" - foobar = Foobar() - - print(oedit(foobar, app=app)) # spyder: test-skip - print(oedit(example, app=app)) # spyder: test-skip - print(oedit(np.random.rand(10, 10), app=app)) # spyder: test-skip - print(oedit(oedit.__doc__, app=app)) # spyder: test-skip - print(example) # spyder: test-skip - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Generic object editor dialog +""" + +# Standard library imports +import datetime + +# Third party imports +from qtpy.QtCore import QObject +from spyder_kernels.utils.lazymodules import ( + FakeObject, numpy as np, pandas as pd, PIL) +from spyder_kernels.utils.nsview import is_known_type + +# Local imports +from spyder.py3compat import is_text_string +from spyder.plugins.variableexplorer.widgets.arrayeditor import ArrayEditor +from spyder.plugins.variableexplorer.widgets.dataframeeditor import ( + DataFrameEditor) +from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor +from spyder.widgets.collectionseditor import CollectionsEditor + + +class DialogKeeper(QObject): + def __init__(self): + QObject.__init__(self) + self.dialogs = {} + self.namespace = None + + def set_namespace(self, namespace): + self.namespace = namespace + + def create_dialog(self, dialog, refname, func): + self.dialogs[id(dialog)] = dialog, refname, func + dialog.accepted.connect( + lambda eid=id(dialog): self.editor_accepted(eid)) + dialog.rejected.connect( + lambda eid=id(dialog): self.editor_rejected(eid)) + dialog.show() + dialog.activateWindow() + dialog.raise_() + + def editor_accepted(self, dialog_id): + dialog, refname, func = self.dialogs[dialog_id] + self.namespace[refname] = func(dialog) + self.dialogs.pop(dialog_id) + + def editor_rejected(self, dialog_id): + self.dialogs.pop(dialog_id) + +keeper = DialogKeeper() + + +def create_dialog(obj, obj_name): + """Creates the editor dialog and returns a tuple (dialog, func) where func + is the function to be called with the dialog instance as argument, after + quitting the dialog box + + The role of this intermediate function is to allow easy monkey-patching. + (uschmitt suggested this indirection here so that he can monkey patch + oedit to show eMZed related data) + """ + # Local import + conv_func = lambda data: data + readonly = not is_known_type(obj) + if isinstance(obj, np.ndarray) and np.ndarray is not FakeObject: + dialog = ArrayEditor() + if not dialog.setup_and_check(obj, title=obj_name, + readonly=readonly): + return + elif (isinstance(obj, PIL.Image.Image) and PIL.Image is not FakeObject + and np.ndarray is not FakeObject): + dialog = ArrayEditor() + data = np.array(obj) + if not dialog.setup_and_check(data, title=obj_name, + readonly=readonly): + return + conv_func = lambda data: PIL.Image.fromarray(data, mode=obj.mode) + elif (isinstance(obj, (pd.DataFrame, pd.Series)) and + pd.DataFrame is not FakeObject): + dialog = DataFrameEditor() + if not dialog.setup_and_check(obj): + return + elif is_text_string(obj): + dialog = TextEditor(obj, title=obj_name, readonly=readonly) + else: + dialog = CollectionsEditor() + dialog.setup(obj, title=obj_name, readonly=readonly) + + def end_func(dialog): + return conv_func(dialog.get_value()) + + return dialog, end_func + + +def oedit(obj, modal=True, namespace=None, app=None): + """Edit the object 'obj' in a GUI-based editor and return the edited copy + (if Cancel is pressed, return None) + + The object 'obj' is a container + + Supported container types: + dict, list, set, tuple, str/unicode or numpy.array + + (instantiate a new QApplication if necessary, + so it can be called directly from the interpreter) + """ + if modal: + obj_name = '' + else: + assert is_text_string(obj) + obj_name = obj + if namespace is None: + namespace = globals() + keeper.set_namespace(namespace) + obj = namespace[obj_name] + # keep QApplication reference alive in the Python interpreter: + namespace['__qapp__'] = app + + result = create_dialog(obj, obj_name) + if result is None: + return + dialog, end_func = result + + if modal: + if dialog.exec_(): + return end_func(dialog) + else: + keeper.create_dialog(dialog, obj_name, end_func) + import os + if os.name == 'nt' and app: + app.exec_() + + +#============================================================================== +# Tests +#============================================================================== +def test(): + """Run object editor test""" + # Local import + from spyder.utils.qthelpers import qapplication + app = qapplication() # analysis:ignore + + data = np.random.randint(1, 256, size=(100, 100)).astype('uint8') + image = PIL.Image.fromarray(data) + example = {'str': 'kjkj kj k j j kj k jkj', + 'list': [1, 3, 4, 'kjkj', None], + 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, + 'dict': {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]}, + 'float': 1.2233, + 'array': np.random.rand(10, 10), + 'image': image, + 'date': datetime.date(1945, 5, 8), + 'datetime': datetime.datetime(1945, 5, 8), + } + image = oedit(image) + class Foobar(object): + def __init__(self): + self.text = "toto" + foobar = Foobar() + + print(oedit(foobar, app=app)) # spyder: test-skip + print(oedit(example, app=app)) # spyder: test-skip + print(oedit(np.random.rand(10, 10), app=app)) # spyder: test-skip + print(oedit(oedit.__doc__, app=app)) # spyder: test-skip + print(example) # spyder: test-skip + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/variableexplorer/widgets/texteditor.py b/spyder/plugins/variableexplorer/widgets/texteditor.py index 2cee81e9017..37e5bc52caa 100644 --- a/spyder/plugins/variableexplorer/widgets/texteditor.py +++ b/spyder/plugins/variableexplorer/widgets/texteditor.py @@ -1,147 +1,147 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Text editor dialog -""" - -# Standard library imports -from __future__ import print_function -import sys - -# Third party imports -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import (QDialog, QHBoxLayout, QPushButton, QTextEdit, - QVBoxLayout) - -# Local import -from spyder.config.base import _ -from spyder.config.gui import get_font -from spyder.py3compat import (is_binary_string, to_binary_string, - to_text_string) -from spyder.utils.icon_manager import ima -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog - - -class TextEditor(BaseDialog): - """Array Editor Dialog""" - def __init__(self, text, title='', font=None, parent=None, readonly=False): - super().__init__(parent) - - # Destroying the C++ object right after closing the dialog box, - # otherwise it may be garbage-collected in another QThread - # (e.g. the editor's analysis thread in Spyder), thus leading to - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - - self.text = None - self.btn_save_and_close = None - - # Display text as unicode if it comes as bytes, so users see - # its right representation - if is_binary_string(text): - self.is_binary = True - text = to_text_string(text, 'utf8') - else: - self.is_binary = False - - self.layout = QVBoxLayout() - self.setLayout(self.layout) - - # Text edit - self.edit = QTextEdit(parent) - self.edit.setReadOnly(readonly) - self.edit.textChanged.connect(self.text_changed) - self.edit.setPlainText(text) - if font is None: - font = get_font() - self.edit.setFont(font) - self.layout.addWidget(self.edit) - - # Buttons configuration - btn_layout = QHBoxLayout() - btn_layout.addStretch() - if not readonly: - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout.addWidget(self.btn_close) - - self.layout.addLayout(btn_layout) - - # Make the dialog act as a window - if sys.platform == 'darwin': - # See spyder-ide/spyder#12825 - self.setWindowFlags(Qt.Tool) - else: - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - - self.setWindowIcon(ima.icon('edit')) - if title: - try: - unicode_title = to_text_string(title) - except UnicodeEncodeError: - unicode_title = u'' - else: - unicode_title = u'' - - self.setWindowTitle(_("Text editor") + \ - u"%s" % (u" - " + unicode_title - if unicode_title else u"")) - - @Slot() - def text_changed(self): - """Text has changed""" - # Save text as bytes, if it was initially bytes - if self.is_binary: - self.text = to_binary_string(self.edit.toPlainText(), 'utf8') - else: - self.text = to_text_string(self.edit.toPlainText()) - if self.btn_save_and_close: - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def get_value(self): - """Return modified text""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.text - - def setup_and_check(self, value): - """Verify if TextEditor is able to display strings passed to it.""" - try: - to_text_string(value, 'utf8') - return True - except: - return False - -#============================================================================== -# Tests -#============================================================================== -def test(): - """Text editor demo""" - from spyder.utils.qthelpers import qapplication - _app = qapplication() # analysis:ignore - - text = """01234567890123456789012345678901234567890123456789012345678901234567890123456789 -dedekdh elkd ezd ekjd lekdj elkdfjelfjk e""" - dialog = TextEditor(text) - dialog.exec_() - - dlg_text = dialog.get_value() - assert text == dlg_text - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Text editor dialog +""" + +# Standard library imports +from __future__ import print_function +import sys + +# Third party imports +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import (QDialog, QHBoxLayout, QPushButton, QTextEdit, + QVBoxLayout) + +# Local import +from spyder.config.base import _ +from spyder.config.gui import get_font +from spyder.py3compat import (is_binary_string, to_binary_string, + to_text_string) +from spyder.utils.icon_manager import ima +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog + + +class TextEditor(BaseDialog): + """Array Editor Dialog""" + def __init__(self, text, title='', font=None, parent=None, readonly=False): + super().__init__(parent) + + # Destroying the C++ object right after closing the dialog box, + # otherwise it may be garbage-collected in another QThread + # (e.g. the editor's analysis thread in Spyder), thus leading to + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + + self.text = None + self.btn_save_and_close = None + + # Display text as unicode if it comes as bytes, so users see + # its right representation + if is_binary_string(text): + self.is_binary = True + text = to_text_string(text, 'utf8') + else: + self.is_binary = False + + self.layout = QVBoxLayout() + self.setLayout(self.layout) + + # Text edit + self.edit = QTextEdit(parent) + self.edit.setReadOnly(readonly) + self.edit.textChanged.connect(self.text_changed) + self.edit.setPlainText(text) + if font is None: + font = get_font() + self.edit.setFont(font) + self.layout.addWidget(self.edit) + + # Buttons configuration + btn_layout = QHBoxLayout() + btn_layout.addStretch() + if not readonly: + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout.addWidget(self.btn_close) + + self.layout.addLayout(btn_layout) + + # Make the dialog act as a window + if sys.platform == 'darwin': + # See spyder-ide/spyder#12825 + self.setWindowFlags(Qt.Tool) + else: + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + + self.setWindowIcon(ima.icon('edit')) + if title: + try: + unicode_title = to_text_string(title) + except UnicodeEncodeError: + unicode_title = u'' + else: + unicode_title = u'' + + self.setWindowTitle(_("Text editor") + \ + u"%s" % (u" - " + unicode_title + if unicode_title else u"")) + + @Slot() + def text_changed(self): + """Text has changed""" + # Save text as bytes, if it was initially bytes + if self.is_binary: + self.text = to_binary_string(self.edit.toPlainText(), 'utf8') + else: + self.text = to_text_string(self.edit.toPlainText()) + if self.btn_save_and_close: + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def get_value(self): + """Return modified text""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.text + + def setup_and_check(self, value): + """Verify if TextEditor is able to display strings passed to it.""" + try: + to_text_string(value, 'utf8') + return True + except: + return False + +#============================================================================== +# Tests +#============================================================================== +def test(): + """Text editor demo""" + from spyder.utils.qthelpers import qapplication + _app = qapplication() # analysis:ignore + + text = """01234567890123456789012345678901234567890123456789012345678901234567890123456789 +dedekdh elkd ezd ekjd lekdj elkdfjelfjk e""" + dialog = TextEditor(text) + dialog.exec_() + + dlg_text = dialog.get_value() + assert text == dlg_text + + +if __name__ == "__main__": + test() diff --git a/spyder/plugins/workingdirectory/confpage.py b/spyder/plugins/workingdirectory/confpage.py index f4377bba79c..66133682a00 100644 --- a/spyder/plugins/workingdirectory/confpage.py +++ b/spyder/plugins/workingdirectory/confpage.py @@ -1,123 +1,123 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Working Directory Plugin""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Third party imports -from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, - QVBoxLayout) - -# Local imports -from spyder.config.base import _ -from spyder.api.preferences import PluginConfigPage -from spyder.utils.misc import getcwd_or_home - - -class WorkingDirectoryConfigPage(PluginConfigPage): - - def setup_page(self): - about_label = QLabel( - _("This is the directory that will be set as the default for " - "the IPython console and Files panes.") - ) - about_label.setWordWrap(True) - - # Startup directory - startup_group = QGroupBox(_("Startup")) - startup_bg = QButtonGroup(startup_group) - startup_label = QLabel( - _("At startup, the working directory is:") - ) - startup_label.setWordWrap(True) - lastdir_radio = self.create_radiobutton( - _("The project (if open) or user home directory"), - 'startup/use_project_or_home_directory', - tip=_("The startup working dir will be root of the " - "current project if one is open, otherwise the " - "user home directory"), - button_group=startup_bg - ) - thisdir_radio = self.create_radiobutton( - _("The following directory:"), - 'startup/use_fixed_directory', - _("At startup, the current working directory will be the " - "specified path"), - button_group=startup_bg - ) - thisdir_bd = self.create_browsedir( - "", - 'startup/fixed_directory', - getcwd_or_home() - ) - thisdir_radio.toggled.connect(thisdir_bd.setEnabled) - lastdir_radio.toggled.connect(thisdir_bd.setDisabled) - thisdir_layout = QHBoxLayout() - thisdir_layout.addWidget(thisdir_radio) - thisdir_layout.addWidget(thisdir_bd) - - startup_layout = QVBoxLayout() - startup_layout.addWidget(startup_label) - startup_layout.addWidget(lastdir_radio) - startup_layout.addLayout(thisdir_layout) - startup_group.setLayout(startup_layout) - - # Console Directory - console_group = QGroupBox(_("New consoles")) - console_label = QLabel( - _("The working directory for new IPython consoles is:") - ) - console_label.setWordWrap(True) - console_bg = QButtonGroup(console_group) - console_project_radio = self.create_radiobutton( - _("The project (if open) or user home directory"), - 'console/use_project_or_home_directory', - tip=_("The working dir for new consoles will be root of the " - "project if one is open, otherwise the user home directory"), - button_group=console_bg - ) - console_cwd_radio = self.create_radiobutton( - _("The working directory of the current console"), - 'console/use_cwd', - button_group=console_bg - ) - console_dir_radio = self.create_radiobutton( - _("The following directory:"), - 'console/use_fixed_directory', - _("The directory when a new console is open will be the " - "specified path"), - button_group=console_bg - ) - console_dir_bd = self.create_browsedir( - "", - 'console/fixed_directory', - getcwd_or_home() - ) - console_dir_radio.toggled.connect(console_dir_bd.setEnabled) - console_project_radio.toggled.connect(console_dir_bd.setDisabled) - console_cwd_radio.toggled.connect(console_dir_bd.setDisabled) - console_dir_layout = QHBoxLayout() - console_dir_layout.addWidget(console_dir_radio) - console_dir_layout.addWidget(console_dir_bd) - - console_layout = QVBoxLayout() - console_layout.addWidget(console_label) - console_layout.addWidget(console_project_radio) - console_layout.addWidget(console_cwd_radio) - console_layout.addLayout(console_dir_layout) - console_group.setLayout(console_layout) - - vlayout = QVBoxLayout() - vlayout.addWidget(about_label) - vlayout.addSpacing(10) - vlayout.addWidget(startup_group) - vlayout.addWidget(console_group) - vlayout.addStretch(1) - self.setLayout(vlayout) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Working Directory Plugin""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Third party imports +from qtpy.QtWidgets import (QButtonGroup, QGroupBox, QHBoxLayout, QLabel, + QVBoxLayout) + +# Local imports +from spyder.config.base import _ +from spyder.api.preferences import PluginConfigPage +from spyder.utils.misc import getcwd_or_home + + +class WorkingDirectoryConfigPage(PluginConfigPage): + + def setup_page(self): + about_label = QLabel( + _("This is the directory that will be set as the default for " + "the IPython console and Files panes.") + ) + about_label.setWordWrap(True) + + # Startup directory + startup_group = QGroupBox(_("Startup")) + startup_bg = QButtonGroup(startup_group) + startup_label = QLabel( + _("At startup, the working directory is:") + ) + startup_label.setWordWrap(True) + lastdir_radio = self.create_radiobutton( + _("The project (if open) or user home directory"), + 'startup/use_project_or_home_directory', + tip=_("The startup working dir will be root of the " + "current project if one is open, otherwise the " + "user home directory"), + button_group=startup_bg + ) + thisdir_radio = self.create_radiobutton( + _("The following directory:"), + 'startup/use_fixed_directory', + _("At startup, the current working directory will be the " + "specified path"), + button_group=startup_bg + ) + thisdir_bd = self.create_browsedir( + "", + 'startup/fixed_directory', + getcwd_or_home() + ) + thisdir_radio.toggled.connect(thisdir_bd.setEnabled) + lastdir_radio.toggled.connect(thisdir_bd.setDisabled) + thisdir_layout = QHBoxLayout() + thisdir_layout.addWidget(thisdir_radio) + thisdir_layout.addWidget(thisdir_bd) + + startup_layout = QVBoxLayout() + startup_layout.addWidget(startup_label) + startup_layout.addWidget(lastdir_radio) + startup_layout.addLayout(thisdir_layout) + startup_group.setLayout(startup_layout) + + # Console Directory + console_group = QGroupBox(_("New consoles")) + console_label = QLabel( + _("The working directory for new IPython consoles is:") + ) + console_label.setWordWrap(True) + console_bg = QButtonGroup(console_group) + console_project_radio = self.create_radiobutton( + _("The project (if open) or user home directory"), + 'console/use_project_or_home_directory', + tip=_("The working dir for new consoles will be root of the " + "project if one is open, otherwise the user home directory"), + button_group=console_bg + ) + console_cwd_radio = self.create_radiobutton( + _("The working directory of the current console"), + 'console/use_cwd', + button_group=console_bg + ) + console_dir_radio = self.create_radiobutton( + _("The following directory:"), + 'console/use_fixed_directory', + _("The directory when a new console is open will be the " + "specified path"), + button_group=console_bg + ) + console_dir_bd = self.create_browsedir( + "", + 'console/fixed_directory', + getcwd_or_home() + ) + console_dir_radio.toggled.connect(console_dir_bd.setEnabled) + console_project_radio.toggled.connect(console_dir_bd.setDisabled) + console_cwd_radio.toggled.connect(console_dir_bd.setDisabled) + console_dir_layout = QHBoxLayout() + console_dir_layout.addWidget(console_dir_radio) + console_dir_layout.addWidget(console_dir_bd) + + console_layout = QVBoxLayout() + console_layout.addWidget(console_label) + console_layout.addWidget(console_project_radio) + console_layout.addWidget(console_cwd_radio) + console_layout.addLayout(console_dir_layout) + console_group.setLayout(console_layout) + + vlayout = QVBoxLayout() + vlayout.addWidget(about_label) + vlayout.addSpacing(10) + vlayout.addWidget(startup_group) + vlayout.addWidget(console_group) + vlayout.addStretch(1) + self.setLayout(vlayout) diff --git a/spyder/plugins/workingdirectory/container.py b/spyder/plugins/workingdirectory/container.py index 7787967fbb1..0686cdd67b3 100644 --- a/spyder/plugins/workingdirectory/container.py +++ b/spyder/plugins/workingdirectory/container.py @@ -1,332 +1,332 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Working Directory widget. -""" - -# Standard library imports -import logging -import os -import os.path as osp - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import QSize, Signal, Slot - -# Local imports -from spyder.api.config.decorators import on_conf_change -from spyder.api.translations import get_translation -from spyder.api.widgets.main_container import PluginMainContainer -from spyder.api.widgets.toolbars import ApplicationToolbar -from spyder.config.base import get_home_dir -from spyder.utils.misc import getcwd_or_home -from spyder.widgets.comboboxes import PathComboBox - - -# Localization and logging -_ = get_translation('spyder') -logger = logging.getLogger(__name__) - - -# ---- Constants -# ---------------------------------------------------------------------------- -class WorkingDirectoryActions: - Previous = 'previous_action' - Next = "next_action" - Browse = "browse_action" - Parent = "parent_action" - - -class WorkingDirectoryToolbarSections: - Main = "main_section" - - -class WorkingDirectoryToolbarItems: - PathComboBox = 'path_combo' - -# ---- Widgets -# ---------------------------------------------------------------------------- -class WorkingDirectoryToolbar(ApplicationToolbar): - ID = 'working_directory_toolbar' - - -class WorkingDirectoryComboBox(PathComboBox): - - def __init__(self, parent, adjust_to_contents=False, id_=None): - super().__init__(parent, adjust_to_contents, id_=id_) - - # Set min width - self.setMinimumWidth(140) - - def sizeHint(self): - """Recommended size when there are toolbars to the right.""" - return QSize(250, 10) - - def enterEvent(self, event): - """Set current path as the tooltip of the widget on hover.""" - self.setToolTip(self.currentText()) - - -# ---- Container -# ---------------------------------------------------------------------------- -class WorkingDirectoryContainer(PluginMainContainer): - """Container for the working directory toolbar.""" - - # Signals - sig_current_directory_changed = Signal(str) - """ - This signal is emitted when the current directory has changed. - - Parameters - ---------- - new_working_directory: str - The new new working directory path. - """ - - # ---- PluginMainContainer API - # ------------------------------------------------------------------------ - def setup(self): - # Variables - self.history = self.get_conf('history', []) - self.histindex = None - - # Widgets - title = _('Current working directory') - self.toolbar = WorkingDirectoryToolbar(self, title) - self.pathedit = WorkingDirectoryComboBox( - self, - adjust_to_contents=self.get_conf('working_dir_adjusttocontents'), - id_=WorkingDirectoryToolbarItems.PathComboBox - ) - - # Widget Setup - self.toolbar.setWindowTitle(title) - self.toolbar.setObjectName(title) - self.pathedit.setMaxCount(self.get_conf('working_dir_history')) - self.pathedit.selected_text = self.pathedit.currentText() - - # Signals - self.pathedit.open_dir.connect(self.chdir) - self.pathedit.activated[str].connect(self.chdir) - - # Actions - self.previous_action = self.create_action( - WorkingDirectoryActions.Previous, - text=_('Back'), - tip=_('Back'), - icon=self.create_icon('previous'), - triggered=self._previous_directory, - ) - self.next_action = self.create_action( - WorkingDirectoryActions.Next, - text=_('Next'), - tip=_('Next'), - icon=self.create_icon('next'), - triggered=self._next_directory, - ) - browse_action = self.create_action( - WorkingDirectoryActions.Browse, - text=_('Browse a working directory'), - tip=_('Browse a working directory'), - icon=self.create_icon('DirOpenIcon'), - triggered=self._select_directory, - ) - parent_action = self.create_action( - WorkingDirectoryActions.Parent, - text=_('Change to parent directory'), - tip=_('Change to parent directory'), - icon=self.create_icon('up'), - triggered=self._parent_directory, - ) - - for item in [self.pathedit, - browse_action, parent_action]: - self.add_item_to_toolbar( - item, - self.toolbar, - section=WorkingDirectoryToolbarSections.Main, - ) - - def update_actions(self): - self.previous_action.setEnabled( - self.histindex is not None and self.histindex > 0) - self.next_action.setEnabled( - self.histindex is not None - and self.histindex < len(self.history) - 1 - ) - - @on_conf_change(option='history') - def on_history_update(self, value): - self.history = value - - # ---- Private API - # ------------------------------------------------------------------------ - def _get_init_workdir(self): - """ - Get the working directory from our config system or return the user - home directory if none can be found. - - Returns - ------- - str: - The initial working directory. - """ - workdir = get_home_dir() - - if self.get_conf('startup/use_project_or_home_directory'): - workdir = get_home_dir() - elif self.get_conf('startup/use_fixed_directory'): - workdir = self.get_conf('startup/fixed_directory') - - # If workdir can't be found, restore default options. - if not osp.isdir(workdir): - self.set_conf('startup/use_project_or_home_directory', True) - self.set_conf('startup/use_fixed_directory', False) - workdir = get_home_dir() - - return workdir - - @Slot() - def _select_directory(self, directory=None): - """ - Select working directory. - - Parameters - ---------- - directory: str, optional - The directory to change to. - - Notes - ----- - If directory is None, a get directory dialog will be used. - """ - if directory is None: - self.sig_redirect_stdio_requested.emit(False) - directory = getexistingdirectory( - self, - _("Select directory"), - getcwd_or_home(), - ) - self.sig_redirect_stdio_requested.emit(True) - - if directory: - self.chdir(directory) - - @Slot() - def _previous_directory(self): - """Select the previous directory.""" - self.histindex -= 1 - self.chdir(directory='', browsing_history=True) - - @Slot() - def _next_directory(self): - """Select the next directory.""" - self.histindex += 1 - self.chdir(directory='', browsing_history=True) - - @Slot() - def _parent_directory(self): - """Change working directory to parent one.""" - self.chdir(osp.join(getcwd_or_home(), osp.pardir)) - - # ---- Public API - # ------------------------------------------------------------------------ - def get_workdir(self): - """ - Get the current working directory. - - Returns - ------- - str: - The current working directory. - """ - return self.pathedit.currentText() - - @Slot(str) - @Slot(str, bool) - @Slot(str, bool, bool) - def chdir(self, directory, browsing_history=False, emit=True): - """ - Set `directory` as working directory. - - Parameters - ---------- - directory: str - The new working directory. - browsing_history: bool, optional - Add the new `directory` to the browsing history. Default is False. - emit: bool, optional - Emit a signal when changing the working directory. - Default is True. - """ - if directory: - directory = osp.abspath(str(directory)) - - # Working directory history management - if browsing_history: - directory = self.history[self.histindex] - elif directory in self.history: - self.histindex = self.history.index(directory) - else: - if self.histindex is None: - self.history = [] - else: - self.history = self.history[:self.histindex + 1] - - self.history.append(directory) - self.histindex = len(self.history) - 1 - - # Changing working directory - try: - logger.debug(f'Setting cwd to {directory}') - os.chdir(directory) - self.pathedit.add_text(directory) - self.update_actions() - - if emit: - self.sig_current_directory_changed.emit(directory) - except OSError: - self.history.pop(self.histindex) - - def get_history(self): - """ - Get the current history list. - - Returns - ------- - list - List of string paths. - """ - return [str(self.pathedit.itemText(index)) for index - in range(self.pathedit.count())] - - def set_history(self, history, cli_workdir=None): - """ - Set the current history list. - - Parameters - ---------- - history: list - List of string paths. - cli_workdir: str or None - Working directory passed on the command line. - """ - self.set_conf('history', history) - if history: - self.pathedit.addItems(history) - - if cli_workdir is None: - workdir = self._get_init_workdir() - else: - logger.debug('Setting cwd passed from the command line') - workdir = cli_workdir - - # In case users pass an invalid directory on the command line - if not osp.isdir(workdir): - workdir = get_home_dir() - - self.chdir(workdir) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Working Directory widget. +""" + +# Standard library imports +import logging +import os +import os.path as osp + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import QSize, Signal, Slot + +# Local imports +from spyder.api.config.decorators import on_conf_change +from spyder.api.translations import get_translation +from spyder.api.widgets.main_container import PluginMainContainer +from spyder.api.widgets.toolbars import ApplicationToolbar +from spyder.config.base import get_home_dir +from spyder.utils.misc import getcwd_or_home +from spyder.widgets.comboboxes import PathComboBox + + +# Localization and logging +_ = get_translation('spyder') +logger = logging.getLogger(__name__) + + +# ---- Constants +# ---------------------------------------------------------------------------- +class WorkingDirectoryActions: + Previous = 'previous_action' + Next = "next_action" + Browse = "browse_action" + Parent = "parent_action" + + +class WorkingDirectoryToolbarSections: + Main = "main_section" + + +class WorkingDirectoryToolbarItems: + PathComboBox = 'path_combo' + +# ---- Widgets +# ---------------------------------------------------------------------------- +class WorkingDirectoryToolbar(ApplicationToolbar): + ID = 'working_directory_toolbar' + + +class WorkingDirectoryComboBox(PathComboBox): + + def __init__(self, parent, adjust_to_contents=False, id_=None): + super().__init__(parent, adjust_to_contents, id_=id_) + + # Set min width + self.setMinimumWidth(140) + + def sizeHint(self): + """Recommended size when there are toolbars to the right.""" + return QSize(250, 10) + + def enterEvent(self, event): + """Set current path as the tooltip of the widget on hover.""" + self.setToolTip(self.currentText()) + + +# ---- Container +# ---------------------------------------------------------------------------- +class WorkingDirectoryContainer(PluginMainContainer): + """Container for the working directory toolbar.""" + + # Signals + sig_current_directory_changed = Signal(str) + """ + This signal is emitted when the current directory has changed. + + Parameters + ---------- + new_working_directory: str + The new new working directory path. + """ + + # ---- PluginMainContainer API + # ------------------------------------------------------------------------ + def setup(self): + # Variables + self.history = self.get_conf('history', []) + self.histindex = None + + # Widgets + title = _('Current working directory') + self.toolbar = WorkingDirectoryToolbar(self, title) + self.pathedit = WorkingDirectoryComboBox( + self, + adjust_to_contents=self.get_conf('working_dir_adjusttocontents'), + id_=WorkingDirectoryToolbarItems.PathComboBox + ) + + # Widget Setup + self.toolbar.setWindowTitle(title) + self.toolbar.setObjectName(title) + self.pathedit.setMaxCount(self.get_conf('working_dir_history')) + self.pathedit.selected_text = self.pathedit.currentText() + + # Signals + self.pathedit.open_dir.connect(self.chdir) + self.pathedit.activated[str].connect(self.chdir) + + # Actions + self.previous_action = self.create_action( + WorkingDirectoryActions.Previous, + text=_('Back'), + tip=_('Back'), + icon=self.create_icon('previous'), + triggered=self._previous_directory, + ) + self.next_action = self.create_action( + WorkingDirectoryActions.Next, + text=_('Next'), + tip=_('Next'), + icon=self.create_icon('next'), + triggered=self._next_directory, + ) + browse_action = self.create_action( + WorkingDirectoryActions.Browse, + text=_('Browse a working directory'), + tip=_('Browse a working directory'), + icon=self.create_icon('DirOpenIcon'), + triggered=self._select_directory, + ) + parent_action = self.create_action( + WorkingDirectoryActions.Parent, + text=_('Change to parent directory'), + tip=_('Change to parent directory'), + icon=self.create_icon('up'), + triggered=self._parent_directory, + ) + + for item in [self.pathedit, + browse_action, parent_action]: + self.add_item_to_toolbar( + item, + self.toolbar, + section=WorkingDirectoryToolbarSections.Main, + ) + + def update_actions(self): + self.previous_action.setEnabled( + self.histindex is not None and self.histindex > 0) + self.next_action.setEnabled( + self.histindex is not None + and self.histindex < len(self.history) - 1 + ) + + @on_conf_change(option='history') + def on_history_update(self, value): + self.history = value + + # ---- Private API + # ------------------------------------------------------------------------ + def _get_init_workdir(self): + """ + Get the working directory from our config system or return the user + home directory if none can be found. + + Returns + ------- + str: + The initial working directory. + """ + workdir = get_home_dir() + + if self.get_conf('startup/use_project_or_home_directory'): + workdir = get_home_dir() + elif self.get_conf('startup/use_fixed_directory'): + workdir = self.get_conf('startup/fixed_directory') + + # If workdir can't be found, restore default options. + if not osp.isdir(workdir): + self.set_conf('startup/use_project_or_home_directory', True) + self.set_conf('startup/use_fixed_directory', False) + workdir = get_home_dir() + + return workdir + + @Slot() + def _select_directory(self, directory=None): + """ + Select working directory. + + Parameters + ---------- + directory: str, optional + The directory to change to. + + Notes + ----- + If directory is None, a get directory dialog will be used. + """ + if directory is None: + self.sig_redirect_stdio_requested.emit(False) + directory = getexistingdirectory( + self, + _("Select directory"), + getcwd_or_home(), + ) + self.sig_redirect_stdio_requested.emit(True) + + if directory: + self.chdir(directory) + + @Slot() + def _previous_directory(self): + """Select the previous directory.""" + self.histindex -= 1 + self.chdir(directory='', browsing_history=True) + + @Slot() + def _next_directory(self): + """Select the next directory.""" + self.histindex += 1 + self.chdir(directory='', browsing_history=True) + + @Slot() + def _parent_directory(self): + """Change working directory to parent one.""" + self.chdir(osp.join(getcwd_or_home(), osp.pardir)) + + # ---- Public API + # ------------------------------------------------------------------------ + def get_workdir(self): + """ + Get the current working directory. + + Returns + ------- + str: + The current working directory. + """ + return self.pathedit.currentText() + + @Slot(str) + @Slot(str, bool) + @Slot(str, bool, bool) + def chdir(self, directory, browsing_history=False, emit=True): + """ + Set `directory` as working directory. + + Parameters + ---------- + directory: str + The new working directory. + browsing_history: bool, optional + Add the new `directory` to the browsing history. Default is False. + emit: bool, optional + Emit a signal when changing the working directory. + Default is True. + """ + if directory: + directory = osp.abspath(str(directory)) + + # Working directory history management + if browsing_history: + directory = self.history[self.histindex] + elif directory in self.history: + self.histindex = self.history.index(directory) + else: + if self.histindex is None: + self.history = [] + else: + self.history = self.history[:self.histindex + 1] + + self.history.append(directory) + self.histindex = len(self.history) - 1 + + # Changing working directory + try: + logger.debug(f'Setting cwd to {directory}') + os.chdir(directory) + self.pathedit.add_text(directory) + self.update_actions() + + if emit: + self.sig_current_directory_changed.emit(directory) + except OSError: + self.history.pop(self.histindex) + + def get_history(self): + """ + Get the current history list. + + Returns + ------- + list + List of string paths. + """ + return [str(self.pathedit.itemText(index)) for index + in range(self.pathedit.count())] + + def set_history(self, history, cli_workdir=None): + """ + Set the current history list. + + Parameters + ---------- + history: list + List of string paths. + cli_workdir: str or None + Working directory passed on the command line. + """ + self.set_conf('history', history) + if history: + self.pathedit.addItems(history) + + if cli_workdir is None: + workdir = self._get_init_workdir() + else: + logger.debug('Setting cwd passed from the command line') + workdir = cli_workdir + + # In case users pass an invalid directory on the command line + if not osp.isdir(workdir): + workdir = get_home_dir() + + self.chdir(workdir) diff --git a/spyder/plugins/workingdirectory/plugin.py b/spyder/plugins/workingdirectory/plugin.py index a2872458ae2..0b8bc4f9448 100644 --- a/spyder/plugins/workingdirectory/plugin.py +++ b/spyder/plugins/workingdirectory/plugin.py @@ -1,262 +1,262 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Working Directory Plugin. -""" - -# Standard library imports -import os.path as osp - -# Third party imports -from qtpy.QtCore import Signal - -# Local imports -from spyder.api.plugins import SpyderPluginV2, Plugins -from spyder.api.plugin_registration.decorators import ( - on_plugin_available, on_plugin_teardown) -from spyder.api.translations import get_translation -from spyder.config.base import get_conf_path -from spyder.plugins.workingdirectory.confpage import WorkingDirectoryConfigPage -from spyder.plugins.workingdirectory.container import ( - WorkingDirectoryContainer) -from spyder.plugins.toolbar.api import ApplicationToolbars -from spyder.utils import encoding - -# Localization -_ = get_translation('spyder') - - -class WorkingDirectory(SpyderPluginV2): - """ - Working directory changer plugin. - """ - - NAME = 'workingdir' - REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Toolbar] - OPTIONAL = [Plugins.Editor, Plugins.Explorer, Plugins.IPythonConsole, - Plugins.Find, Plugins.Projects] - CONTAINER_CLASS = WorkingDirectoryContainer - CONF_SECTION = NAME - CONF_WIDGET_CLASS = WorkingDirectoryConfigPage - CAN_BE_DISABLED = False - CONF_FILE = False - LOG_PATH = get_conf_path(CONF_SECTION) - - # --- Signals - # ------------------------------------------------------------------------ - sig_current_directory_changed = Signal(str) - """ - This signal is emitted when the current directory has changed. - - Parameters - ---------- - new_working_directory: str - The new new working directory path. - """ - - # --- SpyderPluginV2 API - # ------------------------------------------------------------------------ - @staticmethod - def get_name(): - return _('Working directory') - - def get_description(self): - return _('Set the current working directory for various plugins.') - - def get_icon(self): - return self.create_icon('DirOpenIcon') - - def on_initialize(self): - container = self.get_container() - - container.sig_current_directory_changed.connect( - self.sig_current_directory_changed) - self.sig_current_directory_changed.connect( - lambda path, plugin=None: self.chdir(path, plugin)) - - cli_options = self.get_command_line_options() - container.set_history( - self.load_history(), - cli_options.working_directory - ) - - @on_plugin_available(plugin=Plugins.Toolbar) - def on_toolbar_available(self): - container = self.get_container() - toolbar = self.get_plugin(Plugins.Toolbar) - toolbar.add_application_toolbar(container.toolbar) - - @on_plugin_available(plugin=Plugins.Preferences) - def on_preferences_available(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.register_plugin_preferences(self) - - @on_plugin_available(plugin=Plugins.Editor) - def on_editor_available(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_dir_opened.connect(self._editor_change_dir) - - @on_plugin_available(plugin=Plugins.Explorer) - def on_explorer_available(self): - explorer = self.get_plugin(Plugins.Explorer) - self.sig_current_directory_changed.connect(self._explorer_change_dir) - explorer.sig_dir_opened.connect(self._explorer_dir_opened) - - @on_plugin_available(plugin=Plugins.IPythonConsole) - def on_ipyconsole_available(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - self.sig_current_directory_changed.connect( - ipyconsole.set_current_client_working_directory) - ipyconsole.sig_current_directory_changed.connect( - self._ipyconsole_change_dir) - - @on_plugin_available(plugin=Plugins.Projects) - def on_projects_available(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.connect(self._project_loaded) - projects.sig_project_closed[object].connect(self._project_closed) - - @on_plugin_teardown(plugin=Plugins.Toolbar) - def on_toolbar_teardown(self): - toolbar = self.get_plugin(Plugins.Toolbar) - toolbar.remove_application_toolbar( - ApplicationToolbars.WorkingDirectory) - - @on_plugin_teardown(plugin=Plugins.Preferences) - def on_preferences_teardown(self): - preferences = self.get_plugin(Plugins.Preferences) - preferences.deregister_plugin_preferences(self) - - @on_plugin_teardown(plugin=Plugins.Editor) - def on_editor_teardown(self): - editor = self.get_plugin(Plugins.Editor) - editor.sig_dir_opened.disconnect(self._editor_change_dir) - - @on_plugin_teardown(plugin=Plugins.Explorer) - def on_explorer_teardown(self): - explorer = self.get_plugin(Plugins.Explorer) - self.sig_current_directory_changed.disconnect(self._explorer_change_dir) - explorer.sig_dir_opened.disconnect(self._explorer_dir_opened) - - @on_plugin_teardown(plugin=Plugins.IPythonConsole) - def on_ipyconsole_teardown(self): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - - self.sig_current_directory_changed.disconnect( - ipyconsole.set_current_client_working_directory) - ipyconsole.sig_current_directory_changed.disconnect( - self._ipyconsole_change_dir) - - @on_plugin_teardown(plugin=Plugins.Projects) - def on_projects_teardown(self): - projects = self.get_plugin(Plugins.Projects) - projects.sig_project_loaded.disconnect(self._project_loaded) - projects.sig_project_closed[object].disconnect(self._project_closed) - - # --- Public API - # ------------------------------------------------------------------------ - def chdir(self, directory, sender_plugin=None): - """ - Change current working directory. - - Parameters - ---------- - directory: str - The new working directory to set. - sender_plugin: spyder.api.plugins.SpyderPluginsV2 - The plugin that requested this change: Default is None. - """ - explorer = self.get_plugin(Plugins.Explorer) - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - find = self.get_plugin(Plugins.Find) - - if explorer and sender_plugin != explorer: - explorer.chdir(directory, emit=False) - explorer.refresh(directory, force_current=True) - - if ipyconsole and sender_plugin != ipyconsole: - ipyconsole.set_current_client_working_directory(directory) - - if find: - find.refresh_search_directory() - - if sender_plugin is not None: - container = self.get_container() - container.chdir(directory, emit=False) - - self.save_history() - - def load_history(self, workdir=None): - """ - Load history from a text file located in Spyder configuration folder - or use `workdir` if there are no directories saved yet. - - Parameters - ---------- - workdir: str - The working directory to return. Default is None. - """ - if osp.isfile(self.LOG_PATH): - history, _ = encoding.readlines(self.LOG_PATH) - history = [name for name in history if osp.isdir(name)] - else: - if workdir is None: - workdir = self.get_container()._get_init_workdir() - - history = [workdir] - - return history - - def save_history(self): - """ - Save history to a text file located in Spyder configuration folder. - """ - history = self.get_container().get_history() - try: - encoding.writelines(history, self.LOG_PATH) - except EnvironmentError: - pass - - def get_workdir(self): - """ - Get current working directory. - - Returns - ------- - str - Current working directory. - """ - return self.get_container().get_workdir() - - # -------------------------- Private API ---------------------------------- - def _editor_change_dir(self, path): - editor = self.get_plugin(Plugins.Editor) - self.chdir(path, editor) - - def _explorer_change_dir(self, path): - explorer = self.get_plugin(Plugins.Explorer) - explorer.chdir(path, emit=False) - - def _explorer_dir_opened(self, path): - explorer = self.get_plugin(Plugins.Explorer) - self.chdir(path, explorer) - - def _ipyconsole_change_dir(self, path): - ipyconsole = self.get_plugin(Plugins.IPythonConsole) - self.chdir(path, ipyconsole) - - def _project_loaded(self, path): - projects = self.get_plugin(Plugins.Projects) - self.chdir(directory=path, sender_plugin=projects) - - def _project_closed(self, path): - projects = self.get_plugin(Plugins.Projects) - self.chdir( - directory=projects.get_last_working_dir(), - sender_plugin=projects - ) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Working Directory Plugin. +""" + +# Standard library imports +import os.path as osp + +# Third party imports +from qtpy.QtCore import Signal + +# Local imports +from spyder.api.plugins import SpyderPluginV2, Plugins +from spyder.api.plugin_registration.decorators import ( + on_plugin_available, on_plugin_teardown) +from spyder.api.translations import get_translation +from spyder.config.base import get_conf_path +from spyder.plugins.workingdirectory.confpage import WorkingDirectoryConfigPage +from spyder.plugins.workingdirectory.container import ( + WorkingDirectoryContainer) +from spyder.plugins.toolbar.api import ApplicationToolbars +from spyder.utils import encoding + +# Localization +_ = get_translation('spyder') + + +class WorkingDirectory(SpyderPluginV2): + """ + Working directory changer plugin. + """ + + NAME = 'workingdir' + REQUIRES = [Plugins.Preferences, Plugins.Console, Plugins.Toolbar] + OPTIONAL = [Plugins.Editor, Plugins.Explorer, Plugins.IPythonConsole, + Plugins.Find, Plugins.Projects] + CONTAINER_CLASS = WorkingDirectoryContainer + CONF_SECTION = NAME + CONF_WIDGET_CLASS = WorkingDirectoryConfigPage + CAN_BE_DISABLED = False + CONF_FILE = False + LOG_PATH = get_conf_path(CONF_SECTION) + + # --- Signals + # ------------------------------------------------------------------------ + sig_current_directory_changed = Signal(str) + """ + This signal is emitted when the current directory has changed. + + Parameters + ---------- + new_working_directory: str + The new new working directory path. + """ + + # --- SpyderPluginV2 API + # ------------------------------------------------------------------------ + @staticmethod + def get_name(): + return _('Working directory') + + def get_description(self): + return _('Set the current working directory for various plugins.') + + def get_icon(self): + return self.create_icon('DirOpenIcon') + + def on_initialize(self): + container = self.get_container() + + container.sig_current_directory_changed.connect( + self.sig_current_directory_changed) + self.sig_current_directory_changed.connect( + lambda path, plugin=None: self.chdir(path, plugin)) + + cli_options = self.get_command_line_options() + container.set_history( + self.load_history(), + cli_options.working_directory + ) + + @on_plugin_available(plugin=Plugins.Toolbar) + def on_toolbar_available(self): + container = self.get_container() + toolbar = self.get_plugin(Plugins.Toolbar) + toolbar.add_application_toolbar(container.toolbar) + + @on_plugin_available(plugin=Plugins.Preferences) + def on_preferences_available(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.register_plugin_preferences(self) + + @on_plugin_available(plugin=Plugins.Editor) + def on_editor_available(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_dir_opened.connect(self._editor_change_dir) + + @on_plugin_available(plugin=Plugins.Explorer) + def on_explorer_available(self): + explorer = self.get_plugin(Plugins.Explorer) + self.sig_current_directory_changed.connect(self._explorer_change_dir) + explorer.sig_dir_opened.connect(self._explorer_dir_opened) + + @on_plugin_available(plugin=Plugins.IPythonConsole) + def on_ipyconsole_available(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + self.sig_current_directory_changed.connect( + ipyconsole.set_current_client_working_directory) + ipyconsole.sig_current_directory_changed.connect( + self._ipyconsole_change_dir) + + @on_plugin_available(plugin=Plugins.Projects) + def on_projects_available(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.connect(self._project_loaded) + projects.sig_project_closed[object].connect(self._project_closed) + + @on_plugin_teardown(plugin=Plugins.Toolbar) + def on_toolbar_teardown(self): + toolbar = self.get_plugin(Plugins.Toolbar) + toolbar.remove_application_toolbar( + ApplicationToolbars.WorkingDirectory) + + @on_plugin_teardown(plugin=Plugins.Preferences) + def on_preferences_teardown(self): + preferences = self.get_plugin(Plugins.Preferences) + preferences.deregister_plugin_preferences(self) + + @on_plugin_teardown(plugin=Plugins.Editor) + def on_editor_teardown(self): + editor = self.get_plugin(Plugins.Editor) + editor.sig_dir_opened.disconnect(self._editor_change_dir) + + @on_plugin_teardown(plugin=Plugins.Explorer) + def on_explorer_teardown(self): + explorer = self.get_plugin(Plugins.Explorer) + self.sig_current_directory_changed.disconnect(self._explorer_change_dir) + explorer.sig_dir_opened.disconnect(self._explorer_dir_opened) + + @on_plugin_teardown(plugin=Plugins.IPythonConsole) + def on_ipyconsole_teardown(self): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + + self.sig_current_directory_changed.disconnect( + ipyconsole.set_current_client_working_directory) + ipyconsole.sig_current_directory_changed.disconnect( + self._ipyconsole_change_dir) + + @on_plugin_teardown(plugin=Plugins.Projects) + def on_projects_teardown(self): + projects = self.get_plugin(Plugins.Projects) + projects.sig_project_loaded.disconnect(self._project_loaded) + projects.sig_project_closed[object].disconnect(self._project_closed) + + # --- Public API + # ------------------------------------------------------------------------ + def chdir(self, directory, sender_plugin=None): + """ + Change current working directory. + + Parameters + ---------- + directory: str + The new working directory to set. + sender_plugin: spyder.api.plugins.SpyderPluginsV2 + The plugin that requested this change: Default is None. + """ + explorer = self.get_plugin(Plugins.Explorer) + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + find = self.get_plugin(Plugins.Find) + + if explorer and sender_plugin != explorer: + explorer.chdir(directory, emit=False) + explorer.refresh(directory, force_current=True) + + if ipyconsole and sender_plugin != ipyconsole: + ipyconsole.set_current_client_working_directory(directory) + + if find: + find.refresh_search_directory() + + if sender_plugin is not None: + container = self.get_container() + container.chdir(directory, emit=False) + + self.save_history() + + def load_history(self, workdir=None): + """ + Load history from a text file located in Spyder configuration folder + or use `workdir` if there are no directories saved yet. + + Parameters + ---------- + workdir: str + The working directory to return. Default is None. + """ + if osp.isfile(self.LOG_PATH): + history, _ = encoding.readlines(self.LOG_PATH) + history = [name for name in history if osp.isdir(name)] + else: + if workdir is None: + workdir = self.get_container()._get_init_workdir() + + history = [workdir] + + return history + + def save_history(self): + """ + Save history to a text file located in Spyder configuration folder. + """ + history = self.get_container().get_history() + try: + encoding.writelines(history, self.LOG_PATH) + except EnvironmentError: + pass + + def get_workdir(self): + """ + Get current working directory. + + Returns + ------- + str + Current working directory. + """ + return self.get_container().get_workdir() + + # -------------------------- Private API ---------------------------------- + def _editor_change_dir(self, path): + editor = self.get_plugin(Plugins.Editor) + self.chdir(path, editor) + + def _explorer_change_dir(self, path): + explorer = self.get_plugin(Plugins.Explorer) + explorer.chdir(path, emit=False) + + def _explorer_dir_opened(self, path): + explorer = self.get_plugin(Plugins.Explorer) + self.chdir(path, explorer) + + def _ipyconsole_change_dir(self, path): + ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.chdir(path, ipyconsole) + + def _project_loaded(self, path): + projects = self.get_plugin(Plugins.Projects) + self.chdir(directory=path, sender_plugin=projects) + + def _project_closed(self, path): + projects = self.get_plugin(Plugins.Projects) + self.chdir( + directory=projects.get_last_working_dir(), + sender_plugin=projects + ) diff --git a/spyder/py3compat.py b/spyder/py3compat.py index df96c8b8fb0..d93a71cc197 100644 --- a/spyder/py3compat.py +++ b/spyder/py3compat.py @@ -1,319 +1,319 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -spyder.py3compat ----------------- - -Transitional module providing compatibility functions intended to help -migrating from Python 2 to Python 3. - -This module should be fully compatible with: - * Python >=v2.6 - * Python 3 -""" - -from __future__ import print_function - -import operator -import os -import sys - -PY2 = sys.version[0] == '2' -PY3 = sys.version[0] == '3' -PY36_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 6 -PY38_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 8 - -#============================================================================== -# Data types -#============================================================================== -if PY2: - # Python 2 - TEXT_TYPES = (str, unicode) - INT_TYPES = (int, long) -else: - # Python 3 - TEXT_TYPES = (str,) - INT_TYPES = (int,) -NUMERIC_TYPES = tuple(list(INT_TYPES) + [float]) - - -#============================================================================== -# Renamed/Reorganized modules -#============================================================================== -if PY2: - # Python 2 - import __builtin__ as builtins - import ConfigParser as configparser - try: - import _winreg as winreg - except ImportError: - pass - from sys import maxint as maxsize - try: - import CStringIO as io - except ImportError: - import StringIO as io - try: - import cPickle as pickle - except ImportError: - import pickle - from UserDict import DictMixin as MutableMapping - from collections import MutableSequence - import thread as _thread - import repr as reprlib - import Queue - from time import clock as perf_counter - from base64 import decodestring as decodebytes -else: - # Python 3 - import builtins - import configparser - try: - import winreg - except ImportError: - pass - from sys import maxsize - import io - import pickle - from collections.abc import MutableMapping, MutableSequence - import _thread - import reprlib - import queue as Queue - from time import perf_counter - from base64 import decodebytes - - -#============================================================================== -# Strings -#============================================================================== -def to_unichr(character_code): - """ - Return the Unicode string of the character with the given Unicode code. - """ - if PY2: - return unichr(character_code) - else: - return chr(character_code) - -def is_type_text_string(obj): - """Return True if `obj` is type text string, False if it is anything else, - like an instance of a class that extends the basestring class.""" - if PY2: - # Python 2 - return type(obj) in [str, unicode] - else: - # Python 3 - return type(obj) in [str, bytes] - -def is_text_string(obj): - """Return True if `obj` is a text string, False if it is anything else, - like binary data (Python 3) or QString (Python 2, PyQt API #1)""" - if PY2: - # Python 2 - return isinstance(obj, basestring) - else: - # Python 3 - return isinstance(obj, str) - -def is_binary_string(obj): - """Return True if `obj` is a binary string, False if it is anything else""" - if PY2: - # Python 2 - return isinstance(obj, str) - else: - # Python 3 - return isinstance(obj, bytes) - -def is_string(obj): - """Return True if `obj` is a text or binary Python string object, - False if it is anything else, like a QString (Python 2, PyQt API #1)""" - return is_text_string(obj) or is_binary_string(obj) - -def is_unicode(obj): - """Return True if `obj` is unicode""" - if PY2: - # Python 2 - return isinstance(obj, unicode) - else: - # Python 3 - return isinstance(obj, str) - -def to_text_string(obj, encoding=None): - """Convert `obj` to (unicode) text string""" - if PY2: - if isinstance(obj, unicode): - return obj - # Python 2 - if encoding is None: - return unicode(obj) - else: - return unicode(obj, encoding) - else: - # Python 3 - if encoding is None: - return str(obj) - elif isinstance(obj, str): - # In case this function is not used properly, this could happen - return obj - else: - return str(obj, encoding) - -def to_binary_string(obj, encoding=None): - """Convert `obj` to binary string (bytes in Python 3, str in Python 2)""" - if PY2: - # Python 2 - if encoding is None: - return str(obj) - else: - return obj.encode(encoding) - else: - # Python 3 - return bytes(obj, 'utf-8' if encoding is None else encoding) - - -#============================================================================== -# Function attributes -#============================================================================== -def get_func_code(func): - """Return function code object""" - if PY2: - # Python 2 - return func.func_code - else: - # Python 3 - return func.__code__ - -def get_func_name(func): - """Return function name""" - if PY2: - # Python 2 - return func.func_name - else: - # Python 3 - return func.__name__ - -def get_func_defaults(func): - """Return function default argument values""" - if PY2: - # Python 2 - return func.func_defaults - else: - # Python 3 - return func.__defaults__ - - -#============================================================================== -# Special method attributes -#============================================================================== -def get_meth_func(obj): - """Return method function object""" - if PY2: - # Python 2 - return obj.im_func - else: - # Python 3 - return obj.__func__ - -def get_meth_class_inst(obj): - """Return method class instance""" - if PY2: - # Python 2 - return obj.im_self - else: - # Python 3 - return obj.__self__ - -def get_meth_class(obj): - """Return method class""" - if PY2: - # Python 2 - return obj.im_class - else: - # Python 3 - return obj.__self__.__class__ - - -#============================================================================== -# Misc. -#============================================================================== -if PY2: - # Python 2 - input = raw_input - getcwd = os.getcwdu - cmp = cmp - import string - str_lower = string.lower - from itertools import izip_longest as zip_longest -else: - # Python 3 - input = input - getcwd = os.getcwd - def cmp(a, b): - return (a > b) - (a < b) - str_lower = str.lower - from itertools import zip_longest - -def qbytearray_to_str(qba): - """Convert QByteArray object to str in a way compatible with Python 2/3""" - return str(bytes(qba.toHex().data()).decode()) - -# ============================================================================= -# Dict funcs -# ============================================================================= -if PY3: - def iterkeys(d, **kw): - return iter(d.keys(**kw)) - - def itervalues(d, **kw): - return iter(d.values(**kw)) - - def iteritems(d, **kw): - return iter(d.items(**kw)) - - def iterlists(d, **kw): - return iter(d.lists(**kw)) - - viewkeys = operator.methodcaller("keys") - - viewvalues = operator.methodcaller("values") - - viewitems = operator.methodcaller("items") -else: - def iterkeys(d, **kw): - return d.iterkeys(**kw) - - def itervalues(d, **kw): - return d.itervalues(**kw) - - def iteritems(d, **kw): - return d.iteritems(**kw) - - def iterlists(d, **kw): - return d.iterlists(**kw) - - viewkeys = operator.methodcaller("viewkeys") - - viewvalues = operator.methodcaller("viewvalues") - - viewitems = operator.methodcaller("viewitems") - - -# ============================================================================ -# Exceptions -# ============================================================================ -if PY2: - ConnectionRefusedError = ConnectionError = BrokenPipeError = OSError - TimeoutError = RuntimeError -else: - ConnectionError = ConnectionError - ConnectionRefusedError = ConnectionRefusedError - TimeoutError = TimeoutError - BrokenPipeError = BrokenPipeError - - -if __name__ == '__main__': - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +spyder.py3compat +---------------- + +Transitional module providing compatibility functions intended to help +migrating from Python 2 to Python 3. + +This module should be fully compatible with: + * Python >=v2.6 + * Python 3 +""" + +from __future__ import print_function + +import operator +import os +import sys + +PY2 = sys.version[0] == '2' +PY3 = sys.version[0] == '3' +PY36_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 6 +PY38_OR_MORE = sys.version_info[0] >= 3 and sys.version_info[1] >= 8 + +#============================================================================== +# Data types +#============================================================================== +if PY2: + # Python 2 + TEXT_TYPES = (str, unicode) + INT_TYPES = (int, long) +else: + # Python 3 + TEXT_TYPES = (str,) + INT_TYPES = (int,) +NUMERIC_TYPES = tuple(list(INT_TYPES) + [float]) + + +#============================================================================== +# Renamed/Reorganized modules +#============================================================================== +if PY2: + # Python 2 + import __builtin__ as builtins + import ConfigParser as configparser + try: + import _winreg as winreg + except ImportError: + pass + from sys import maxint as maxsize + try: + import CStringIO as io + except ImportError: + import StringIO as io + try: + import cPickle as pickle + except ImportError: + import pickle + from UserDict import DictMixin as MutableMapping + from collections import MutableSequence + import thread as _thread + import repr as reprlib + import Queue + from time import clock as perf_counter + from base64 import decodestring as decodebytes +else: + # Python 3 + import builtins + import configparser + try: + import winreg + except ImportError: + pass + from sys import maxsize + import io + import pickle + from collections.abc import MutableMapping, MutableSequence + import _thread + import reprlib + import queue as Queue + from time import perf_counter + from base64 import decodebytes + + +#============================================================================== +# Strings +#============================================================================== +def to_unichr(character_code): + """ + Return the Unicode string of the character with the given Unicode code. + """ + if PY2: + return unichr(character_code) + else: + return chr(character_code) + +def is_type_text_string(obj): + """Return True if `obj` is type text string, False if it is anything else, + like an instance of a class that extends the basestring class.""" + if PY2: + # Python 2 + return type(obj) in [str, unicode] + else: + # Python 3 + return type(obj) in [str, bytes] + +def is_text_string(obj): + """Return True if `obj` is a text string, False if it is anything else, + like binary data (Python 3) or QString (Python 2, PyQt API #1)""" + if PY2: + # Python 2 + return isinstance(obj, basestring) + else: + # Python 3 + return isinstance(obj, str) + +def is_binary_string(obj): + """Return True if `obj` is a binary string, False if it is anything else""" + if PY2: + # Python 2 + return isinstance(obj, str) + else: + # Python 3 + return isinstance(obj, bytes) + +def is_string(obj): + """Return True if `obj` is a text or binary Python string object, + False if it is anything else, like a QString (Python 2, PyQt API #1)""" + return is_text_string(obj) or is_binary_string(obj) + +def is_unicode(obj): + """Return True if `obj` is unicode""" + if PY2: + # Python 2 + return isinstance(obj, unicode) + else: + # Python 3 + return isinstance(obj, str) + +def to_text_string(obj, encoding=None): + """Convert `obj` to (unicode) text string""" + if PY2: + if isinstance(obj, unicode): + return obj + # Python 2 + if encoding is None: + return unicode(obj) + else: + return unicode(obj, encoding) + else: + # Python 3 + if encoding is None: + return str(obj) + elif isinstance(obj, str): + # In case this function is not used properly, this could happen + return obj + else: + return str(obj, encoding) + +def to_binary_string(obj, encoding=None): + """Convert `obj` to binary string (bytes in Python 3, str in Python 2)""" + if PY2: + # Python 2 + if encoding is None: + return str(obj) + else: + return obj.encode(encoding) + else: + # Python 3 + return bytes(obj, 'utf-8' if encoding is None else encoding) + + +#============================================================================== +# Function attributes +#============================================================================== +def get_func_code(func): + """Return function code object""" + if PY2: + # Python 2 + return func.func_code + else: + # Python 3 + return func.__code__ + +def get_func_name(func): + """Return function name""" + if PY2: + # Python 2 + return func.func_name + else: + # Python 3 + return func.__name__ + +def get_func_defaults(func): + """Return function default argument values""" + if PY2: + # Python 2 + return func.func_defaults + else: + # Python 3 + return func.__defaults__ + + +#============================================================================== +# Special method attributes +#============================================================================== +def get_meth_func(obj): + """Return method function object""" + if PY2: + # Python 2 + return obj.im_func + else: + # Python 3 + return obj.__func__ + +def get_meth_class_inst(obj): + """Return method class instance""" + if PY2: + # Python 2 + return obj.im_self + else: + # Python 3 + return obj.__self__ + +def get_meth_class(obj): + """Return method class""" + if PY2: + # Python 2 + return obj.im_class + else: + # Python 3 + return obj.__self__.__class__ + + +#============================================================================== +# Misc. +#============================================================================== +if PY2: + # Python 2 + input = raw_input + getcwd = os.getcwdu + cmp = cmp + import string + str_lower = string.lower + from itertools import izip_longest as zip_longest +else: + # Python 3 + input = input + getcwd = os.getcwd + def cmp(a, b): + return (a > b) - (a < b) + str_lower = str.lower + from itertools import zip_longest + +def qbytearray_to_str(qba): + """Convert QByteArray object to str in a way compatible with Python 2/3""" + return str(bytes(qba.toHex().data()).decode()) + +# ============================================================================= +# Dict funcs +# ============================================================================= +if PY3: + def iterkeys(d, **kw): + return iter(d.keys(**kw)) + + def itervalues(d, **kw): + return iter(d.values(**kw)) + + def iteritems(d, **kw): + return iter(d.items(**kw)) + + def iterlists(d, **kw): + return iter(d.lists(**kw)) + + viewkeys = operator.methodcaller("keys") + + viewvalues = operator.methodcaller("values") + + viewitems = operator.methodcaller("items") +else: + def iterkeys(d, **kw): + return d.iterkeys(**kw) + + def itervalues(d, **kw): + return d.itervalues(**kw) + + def iteritems(d, **kw): + return d.iteritems(**kw) + + def iterlists(d, **kw): + return d.iterlists(**kw) + + viewkeys = operator.methodcaller("viewkeys") + + viewvalues = operator.methodcaller("viewvalues") + + viewitems = operator.methodcaller("viewitems") + + +# ============================================================================ +# Exceptions +# ============================================================================ +if PY2: + ConnectionRefusedError = ConnectionError = BrokenPipeError = OSError + TimeoutError = RuntimeError +else: + ConnectionError = ConnectionError + ConnectionRefusedError = ConnectionRefusedError + TimeoutError = TimeoutError + BrokenPipeError = BrokenPipeError + + +if __name__ == '__main__': + pass diff --git a/spyder/requirements.py b/spyder/requirements.py index ecb2b183817..23819f075d7 100644 --- a/spyder/requirements.py +++ b/spyder/requirements.py @@ -1,63 +1,63 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Module checking Spyder installation requirements""" - -# Standard library imports -import sys -import os.path as osp - -# Third-party imports -from pkg_resources import parse_version - - -def show_warning(message): - """Show warning using Tkinter if available""" - try: - # If tkinter is installed (highly probable), show an error pop-up. - # From https://stackoverflow.com/a/17280890/438386 - import tkinter as tk - root = tk.Tk() - root.title("Spyder") - label = tk.Label(root, text=message, justify='left') - label.pack(side="top", fill="both", expand=True, padx=20, pady=20) - button = tk.Button(root, text="OK", command=lambda: root.destroy()) - button.pack(side="bottom", fill="none", expand=True) - root.mainloop() - except Exception: - pass - raise RuntimeError(message) - - -def check_path(): - """Check sys.path: is Spyder properly installed?""" - dirname = osp.abspath(osp.join(osp.dirname(__file__), osp.pardir)) - if dirname not in sys.path: - show_warning("Spyder must be installed properly " - "(e.g. from source: 'python setup.py install'),\n" - "or directory '%s' must be in PYTHONPATH " - "environment variable." % dirname) - - -def check_qt(): - """Check Qt binding requirements""" - qt_infos = dict(pyqt5=("PyQt5", "5.9"), pyside2=("PySide2", "5.12")) - try: - import qtpy - package_name, required_ver = qt_infos[qtpy.API] - actual_ver = qtpy.QT_VERSION - if (actual_ver is None or - parse_version(actual_ver) < parse_version(required_ver)): - show_warning("Please check Spyder installation requirements:\n" - "%s %s+ is required (found %s)." - % (package_name, required_ver, actual_ver)) - except ImportError: - show_warning("Failed to import qtpy.\n" - "Please check Spyder installation requirements:\n\n" - "qtpy 1.2.0+ and\n" - "%s %s+\n\n" - "are required to run Spyder." - % (qt_infos['pyqt5'])) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Module checking Spyder installation requirements""" + +# Standard library imports +import sys +import os.path as osp + +# Third-party imports +from pkg_resources import parse_version + + +def show_warning(message): + """Show warning using Tkinter if available""" + try: + # If tkinter is installed (highly probable), show an error pop-up. + # From https://stackoverflow.com/a/17280890/438386 + import tkinter as tk + root = tk.Tk() + root.title("Spyder") + label = tk.Label(root, text=message, justify='left') + label.pack(side="top", fill="both", expand=True, padx=20, pady=20) + button = tk.Button(root, text="OK", command=lambda: root.destroy()) + button.pack(side="bottom", fill="none", expand=True) + root.mainloop() + except Exception: + pass + raise RuntimeError(message) + + +def check_path(): + """Check sys.path: is Spyder properly installed?""" + dirname = osp.abspath(osp.join(osp.dirname(__file__), osp.pardir)) + if dirname not in sys.path: + show_warning("Spyder must be installed properly " + "(e.g. from source: 'python setup.py install'),\n" + "or directory '%s' must be in PYTHONPATH " + "environment variable." % dirname) + + +def check_qt(): + """Check Qt binding requirements""" + qt_infos = dict(pyqt5=("PyQt5", "5.9"), pyside2=("PySide2", "5.12")) + try: + import qtpy + package_name, required_ver = qt_infos[qtpy.API] + actual_ver = qtpy.QT_VERSION + if (actual_ver is None or + parse_version(actual_ver) < parse_version(required_ver)): + show_warning("Please check Spyder installation requirements:\n" + "%s %s+ is required (found %s)." + % (package_name, required_ver, actual_ver)) + except ImportError: + show_warning("Failed to import qtpy.\n" + "Please check Spyder installation requirements:\n\n" + "qtpy 1.2.0+ and\n" + "%s %s+\n\n" + "are required to run Spyder." + % (qt_infos['pyqt5'])) diff --git a/spyder/utils/bsdsocket.py b/spyder/utils/bsdsocket.py index 4d3c1dab954..2d9ef4d023d 100644 --- a/spyder/utils/bsdsocket.py +++ b/spyder/utils/bsdsocket.py @@ -1,183 +1,183 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""BSD socket interface communication utilities""" - -# Be extra careful here. The interface is used to communicate with subprocesses -# by redirecting output streams through a socket. Any exception in this module -# and failure to read out buffers will most likely lock up Spyder. - -import os -import socket -import struct -import threading -import errno -import traceback - -# Local imports -from spyder.config.base import get_debug_level, STDERR -DEBUG_EDITOR = get_debug_level() >= 3 -from spyder.py3compat import pickle -PICKLE_HIGHEST_PROTOCOL = 2 - - -def temp_fail_retry(error, fun, *args): - """Retry to execute function, ignoring EINTR error (interruptions)""" - while 1: - try: - return fun(*args) - except error as e: - eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR - if e.args[0] == eintr: - continue - raise - - -SZ = struct.calcsize("l") - - -def write_packet(sock, data, already_pickled=False): - """Write *data* to socket *sock*""" - if already_pickled: - sent_data = data - else: - sent_data = pickle.dumps(data, PICKLE_HIGHEST_PROTOCOL) - sent_data = struct.pack("l", len(sent_data)) + sent_data - nsend = len(sent_data) - while nsend > 0: - nsend -= temp_fail_retry(socket.error, sock.send, sent_data) - - -def read_packet(sock, timeout=None): - """ - Read data from socket *sock* - Returns None if something went wrong - """ - sock.settimeout(timeout) - dlen, data = None, None - try: - if os.name == 'nt': - # Windows implementation - datalen = sock.recv(SZ) - dlen, = struct.unpack("l", datalen) - data = b'' - while len(data) < dlen: - data += sock.recv(dlen) - else: - # Linux/MacOSX implementation - # Thanks to eborisch: - # See spyder-ide/spyder#1106. - datalen = temp_fail_retry(socket.error, sock.recv, - SZ, socket.MSG_WAITALL) - if len(datalen) == SZ: - dlen, = struct.unpack("l", datalen) - data = temp_fail_retry(socket.error, sock.recv, - dlen, socket.MSG_WAITALL) - except socket.timeout: - raise - except socket.error: - data = None - finally: - sock.settimeout(None) - if data is not None: - try: - return pickle.loads(data) - except Exception: - # Catch all exceptions to avoid locking spyder - if DEBUG_EDITOR: - traceback.print_exc(file=STDERR) - return - - -# Using a lock object to avoid communication issues described in -# spyder-ide/spyder#857. -COMMUNICATE_LOCK = threading.Lock() - -# * Old com implementation * -# See solution (1) in spyder-ide/spyder#434, comment 13: -def communicate(sock, command, settings=[]): - """Communicate with monitor""" - try: - COMMUNICATE_LOCK.acquire() - write_packet(sock, command) - for option in settings: - write_packet(sock, option) - return read_packet(sock) - finally: - COMMUNICATE_LOCK.release() - -# new com implementation: -# See solution (2) in spyder-ide/spyder#434, comment 13: -#def communicate(sock, command, settings=[], timeout=None): -# """Communicate with monitor""" -# write_packet(sock, command) -# for option in settings: -# write_packet(sock, option) -# if timeout == 0.: -# # non blocking socket is not really supported: -# # setting timeout to 0. here is equivalent (in current monitor's -# # implementation) to say 'I don't need to receive anything in return' -# return -# while True: -# output = read_packet(sock, timeout=timeout) -# if output is None: -# return -# output_command, output_data = output -# if command == output_command: -# return output_data -# elif DEBUG: -# logging.debug("###### communicate/warning /Begin ######") -# logging.debug("was expecting '%s', received '%s'" \ -# % (command, output_command)) -# logging.debug("###### communicate/warning /End ######") - - -class PacketNotReceived(object): - pass - -PACKET_NOT_RECEIVED = PacketNotReceived() - - -if __name__ == '__main__': - if not os.name == 'nt': - # socket read/write testing - client and server in one thread - - # (techtonik): the stuff below is placed into public domain - print("-- Testing standard Python socket interface --") # spyder: test-skip - - address = ("127.0.0.1", 9999) - - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.setblocking(0) - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind( address ) - server.listen(2) - - client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - client.connect( address ) - - client.send("data to be catched".encode('utf-8')) - # accepted server socket is the one we can read from - # note that it is different from server socket - accsock, addr = server.accept() - print('..got "%s" from %s' % (accsock.recv(4096), addr)) # spyder: test-skip - - # accsock.close() - # client.send("more data for recv") - #socket.error: [Errno 9] Bad file descriptor - # accsock, addr = server.accept() - #socket.error: [Errno 11] Resource temporarily unavailable - - - print("-- Testing BSD socket write_packet/read_packet --") # spyder: test-skip - - write_packet(client, "a tiny piece of data") - print('..got "%s" from read_packet()' % (read_packet(accsock))) # spyder: test-skip - - client.close() - server.close() - - print("-- Done.") # spyder: test-skip +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""BSD socket interface communication utilities""" + +# Be extra careful here. The interface is used to communicate with subprocesses +# by redirecting output streams through a socket. Any exception in this module +# and failure to read out buffers will most likely lock up Spyder. + +import os +import socket +import struct +import threading +import errno +import traceback + +# Local imports +from spyder.config.base import get_debug_level, STDERR +DEBUG_EDITOR = get_debug_level() >= 3 +from spyder.py3compat import pickle +PICKLE_HIGHEST_PROTOCOL = 2 + + +def temp_fail_retry(error, fun, *args): + """Retry to execute function, ignoring EINTR error (interruptions)""" + while 1: + try: + return fun(*args) + except error as e: + eintr = errno.WSAEINTR if os.name == 'nt' else errno.EINTR + if e.args[0] == eintr: + continue + raise + + +SZ = struct.calcsize("l") + + +def write_packet(sock, data, already_pickled=False): + """Write *data* to socket *sock*""" + if already_pickled: + sent_data = data + else: + sent_data = pickle.dumps(data, PICKLE_HIGHEST_PROTOCOL) + sent_data = struct.pack("l", len(sent_data)) + sent_data + nsend = len(sent_data) + while nsend > 0: + nsend -= temp_fail_retry(socket.error, sock.send, sent_data) + + +def read_packet(sock, timeout=None): + """ + Read data from socket *sock* + Returns None if something went wrong + """ + sock.settimeout(timeout) + dlen, data = None, None + try: + if os.name == 'nt': + # Windows implementation + datalen = sock.recv(SZ) + dlen, = struct.unpack("l", datalen) + data = b'' + while len(data) < dlen: + data += sock.recv(dlen) + else: + # Linux/MacOSX implementation + # Thanks to eborisch: + # See spyder-ide/spyder#1106. + datalen = temp_fail_retry(socket.error, sock.recv, + SZ, socket.MSG_WAITALL) + if len(datalen) == SZ: + dlen, = struct.unpack("l", datalen) + data = temp_fail_retry(socket.error, sock.recv, + dlen, socket.MSG_WAITALL) + except socket.timeout: + raise + except socket.error: + data = None + finally: + sock.settimeout(None) + if data is not None: + try: + return pickle.loads(data) + except Exception: + # Catch all exceptions to avoid locking spyder + if DEBUG_EDITOR: + traceback.print_exc(file=STDERR) + return + + +# Using a lock object to avoid communication issues described in +# spyder-ide/spyder#857. +COMMUNICATE_LOCK = threading.Lock() + +# * Old com implementation * +# See solution (1) in spyder-ide/spyder#434, comment 13: +def communicate(sock, command, settings=[]): + """Communicate with monitor""" + try: + COMMUNICATE_LOCK.acquire() + write_packet(sock, command) + for option in settings: + write_packet(sock, option) + return read_packet(sock) + finally: + COMMUNICATE_LOCK.release() + +# new com implementation: +# See solution (2) in spyder-ide/spyder#434, comment 13: +#def communicate(sock, command, settings=[], timeout=None): +# """Communicate with monitor""" +# write_packet(sock, command) +# for option in settings: +# write_packet(sock, option) +# if timeout == 0.: +# # non blocking socket is not really supported: +# # setting timeout to 0. here is equivalent (in current monitor's +# # implementation) to say 'I don't need to receive anything in return' +# return +# while True: +# output = read_packet(sock, timeout=timeout) +# if output is None: +# return +# output_command, output_data = output +# if command == output_command: +# return output_data +# elif DEBUG: +# logging.debug("###### communicate/warning /Begin ######") +# logging.debug("was expecting '%s', received '%s'" \ +# % (command, output_command)) +# logging.debug("###### communicate/warning /End ######") + + +class PacketNotReceived(object): + pass + +PACKET_NOT_RECEIVED = PacketNotReceived() + + +if __name__ == '__main__': + if not os.name == 'nt': + # socket read/write testing - client and server in one thread + + # (techtonik): the stuff below is placed into public domain + print("-- Testing standard Python socket interface --") # spyder: test-skip + + address = ("127.0.0.1", 9999) + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setblocking(0) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind( address ) + server.listen(2) + + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client.connect( address ) + + client.send("data to be catched".encode('utf-8')) + # accepted server socket is the one we can read from + # note that it is different from server socket + accsock, addr = server.accept() + print('..got "%s" from %s' % (accsock.recv(4096), addr)) # spyder: test-skip + + # accsock.close() + # client.send("more data for recv") + #socket.error: [Errno 9] Bad file descriptor + # accsock, addr = server.accept() + #socket.error: [Errno 11] Resource temporarily unavailable + + + print("-- Testing BSD socket write_packet/read_packet --") # spyder: test-skip + + write_packet(client, "a tiny piece of data") + print('..got "%s" from read_packet()' % (read_packet(accsock))) # spyder: test-skip + + client.close() + server.close() + + print("-- Done.") # spyder: test-skip diff --git a/spyder/utils/conda.py b/spyder/utils/conda.py index 198909612f6..2c43c37dc0e 100644 --- a/spyder/utils/conda.py +++ b/spyder/utils/conda.py @@ -1,164 +1,164 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Conda/anaconda utilities.""" - -# Standard library imports -import json -import os -import os.path as osp -import sys - -from spyder.utils.programs import find_program, run_program, run_shell_command -from spyder.config.base import get_spyder_umamba_path - -WINDOWS = os.name == 'nt' -CONDA_ENV_LIST_CACHE = {} - - -def add_quotes(path): - """Return quotes if needed for spaces on path.""" - quotes = '"' if ' ' in path and '"' not in path else '' - return '{quotes}{path}{quotes}'.format(quotes=quotes, path=path) - - -def is_conda_env(prefix=None, pyexec=None): - """Check if prefix or python executable are in a conda environment.""" - if pyexec is not None: - pyexec = pyexec.replace('\\', '/') - - if (prefix is None and pyexec is None) or (prefix and pyexec): - raise ValueError('Only `prefix` or `pyexec` should be provided!') - - if pyexec and prefix is None: - prefix = get_conda_env_path(pyexec).replace('\\', '/') - - return os.path.exists(os.path.join(prefix, 'conda-meta')) - - -def get_conda_root_prefix(pyexec=None, quote=False): - """ - Return conda prefix from pyexec path - - If `quote` is True, then quotes are added if spaces are found in the path. - """ - if pyexec is None: - conda_env_prefix = sys.prefix - else: - conda_env_prefix = get_conda_env_path(pyexec) - - conda_env_prefix = conda_env_prefix.replace('\\', '/') - env_key = '/envs/' - - if conda_env_prefix.rfind(env_key) != -1: - root_prefix = conda_env_prefix.split(env_key)[0] - else: - root_prefix = conda_env_prefix - - if quote: - root_prefix = add_quotes(root_prefix) - - return root_prefix - - -def get_conda_activation_script(quote=False): - """ - Return full path to conda activation script. - - If `quote` is True, then quotes are added if spaces are found in the path. - """ - # Use micromamba bundled with Spyder installers or find conda exe - exe = get_spyder_umamba_path() or find_conda() - - if osp.basename(exe).startswith('micromamba'): - # For Micromamba, use the executable - script_path = exe - else: - # Conda activation script is relative to executable - conda_exe_root = osp.dirname(osp.dirname(exe)) - if WINDOWS: - activate = 'Scripts/activate' - else: - activate = 'bin/activate' - script_path = osp.join(conda_exe_root, activate) - - script_path = script_path.replace('\\', '/') - - if quote: - script_path = add_quotes(script_path) - - return script_path - - -def get_conda_env_path(pyexec, quote=False): - """ - Return the full path to the conda environment from give python executable. - - If `quote` is True, then quotes are added if spaces are found in the path. - """ - pyexec = pyexec.replace('\\', '/') - if os.name == 'nt': - conda_env = os.path.dirname(pyexec) - else: - conda_env = os.path.dirname(os.path.dirname(pyexec)) - - if quote: - conda_env = add_quotes(conda_env) - - return conda_env - - -def find_conda(): - """Find conda executable.""" - # First try the environment variables - conda = os.environ.get('CONDA_EXE') or os.environ.get('MAMBA_EXE') - if conda is None: - # Try searching for the executable - conda_exec = 'conda.bat' if WINDOWS else 'conda' - conda = find_program(conda_exec) - return conda - - -def get_list_conda_envs(): - """Return the list of all conda envs found in the system.""" - global CONDA_ENV_LIST_CACHE - - env_list = {} - conda = find_conda() - if conda is None: - return env_list - - cmdstr = ' '.join([conda, 'env', 'list', '--json']) - try: - out, __ = run_shell_command(cmdstr, env={}).communicate() - out = out.decode() - out = json.loads(out) - except Exception: - out = {'envs': []} - - for env in out['envs']: - name = env.split(osp.sep)[-1] - path = osp.join(env, 'python.exe') if WINDOWS else osp.join( - env, 'bin', 'python') - - try: - version, __ = run_program(path, ['--version']).communicate() - version = version.decode() - except Exception: - version = '' - - name = ('base' if name.lower().startswith('anaconda') or - name.lower().startswith('miniconda') else name) - name = 'conda: {}'.format(name) - env_list[name] = (path, version.strip()) - - CONDA_ENV_LIST_CACHE = env_list - return env_list - - -def get_list_conda_envs_cache(): - """Return a cache of envs to avoid computing them again.""" - return CONDA_ENV_LIST_CACHE +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Conda/anaconda utilities.""" + +# Standard library imports +import json +import os +import os.path as osp +import sys + +from spyder.utils.programs import find_program, run_program, run_shell_command +from spyder.config.base import get_spyder_umamba_path + +WINDOWS = os.name == 'nt' +CONDA_ENV_LIST_CACHE = {} + + +def add_quotes(path): + """Return quotes if needed for spaces on path.""" + quotes = '"' if ' ' in path and '"' not in path else '' + return '{quotes}{path}{quotes}'.format(quotes=quotes, path=path) + + +def is_conda_env(prefix=None, pyexec=None): + """Check if prefix or python executable are in a conda environment.""" + if pyexec is not None: + pyexec = pyexec.replace('\\', '/') + + if (prefix is None and pyexec is None) or (prefix and pyexec): + raise ValueError('Only `prefix` or `pyexec` should be provided!') + + if pyexec and prefix is None: + prefix = get_conda_env_path(pyexec).replace('\\', '/') + + return os.path.exists(os.path.join(prefix, 'conda-meta')) + + +def get_conda_root_prefix(pyexec=None, quote=False): + """ + Return conda prefix from pyexec path + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + if pyexec is None: + conda_env_prefix = sys.prefix + else: + conda_env_prefix = get_conda_env_path(pyexec) + + conda_env_prefix = conda_env_prefix.replace('\\', '/') + env_key = '/envs/' + + if conda_env_prefix.rfind(env_key) != -1: + root_prefix = conda_env_prefix.split(env_key)[0] + else: + root_prefix = conda_env_prefix + + if quote: + root_prefix = add_quotes(root_prefix) + + return root_prefix + + +def get_conda_activation_script(quote=False): + """ + Return full path to conda activation script. + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + # Use micromamba bundled with Spyder installers or find conda exe + exe = get_spyder_umamba_path() or find_conda() + + if osp.basename(exe).startswith('micromamba'): + # For Micromamba, use the executable + script_path = exe + else: + # Conda activation script is relative to executable + conda_exe_root = osp.dirname(osp.dirname(exe)) + if WINDOWS: + activate = 'Scripts/activate' + else: + activate = 'bin/activate' + script_path = osp.join(conda_exe_root, activate) + + script_path = script_path.replace('\\', '/') + + if quote: + script_path = add_quotes(script_path) + + return script_path + + +def get_conda_env_path(pyexec, quote=False): + """ + Return the full path to the conda environment from give python executable. + + If `quote` is True, then quotes are added if spaces are found in the path. + """ + pyexec = pyexec.replace('\\', '/') + if os.name == 'nt': + conda_env = os.path.dirname(pyexec) + else: + conda_env = os.path.dirname(os.path.dirname(pyexec)) + + if quote: + conda_env = add_quotes(conda_env) + + return conda_env + + +def find_conda(): + """Find conda executable.""" + # First try the environment variables + conda = os.environ.get('CONDA_EXE') or os.environ.get('MAMBA_EXE') + if conda is None: + # Try searching for the executable + conda_exec = 'conda.bat' if WINDOWS else 'conda' + conda = find_program(conda_exec) + return conda + + +def get_list_conda_envs(): + """Return the list of all conda envs found in the system.""" + global CONDA_ENV_LIST_CACHE + + env_list = {} + conda = find_conda() + if conda is None: + return env_list + + cmdstr = ' '.join([conda, 'env', 'list', '--json']) + try: + out, __ = run_shell_command(cmdstr, env={}).communicate() + out = out.decode() + out = json.loads(out) + except Exception: + out = {'envs': []} + + for env in out['envs']: + name = env.split(osp.sep)[-1] + path = osp.join(env, 'python.exe') if WINDOWS else osp.join( + env, 'bin', 'python') + + try: + version, __ = run_program(path, ['--version']).communicate() + version = version.decode() + except Exception: + version = '' + + name = ('base' if name.lower().startswith('anaconda') or + name.lower().startswith('miniconda') else name) + name = 'conda: {}'.format(name) + env_list[name] = (path, version.strip()) + + CONDA_ENV_LIST_CACHE = env_list + return env_list + + +def get_list_conda_envs_cache(): + """Return a cache of envs to avoid computing them again.""" + return CONDA_ENV_LIST_CACHE diff --git a/spyder/utils/debug.py b/spyder/utils/debug.py index 9a63aabd0c2..655a350dfe7 100644 --- a/spyder/utils/debug.py +++ b/spyder/utils/debug.py @@ -1,146 +1,146 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Debug utilities that are independent of Spyder code. - -See spyder.config.base for other helpers. -""" - -from __future__ import print_function - -import inspect -import traceback -import time - -from spyder.py3compat import PY2 - - -def log_time(fd): - timestr = "Logging time: %s" % time.ctime(time.time()) - print("="*len(timestr), file=fd) - print(timestr, file=fd) - print("="*len(timestr), file=fd) - print("", file=fd) - -def log_last_error(fname, context=None): - """Log last error in filename *fname* -- *context*: string (optional)""" - fd = open(fname, 'a') - log_time(fd) - if context: - print("Context", file=fd) - print("-------", file=fd) - print("", file=fd) - if PY2: - print(u' '.join(context).encode('utf-8').strip(), file=fd) - else: - print(context, file=fd) - print("", file=fd) - print("Traceback", file=fd) - print("---------", file=fd) - print("", file=fd) - traceback.print_exc(file=fd) - print("", file=fd) - print("", file=fd) - -def log_dt(fname, context, t0): - fd = open(fname, 'a') - log_time(fd) - print("%s: %d ms" % (context, 10*round(1e2*(time.time()-t0))), file=fd) - print("", file=fd) - print("", file=fd) - -def caller_name(skip=2): - """ - Get name of a caller in the format module.class.method - - `skip` specifies how many levels of call stack to skip for caller's name. - skip=1 means "who calls me", skip=2 "who calls my caller" etc. - - An empty string is returned if skipped levels exceed stack height - """ - stack = inspect.stack() - start = 0 + skip - if len(stack) < start + 1: - return '' - parentframe = stack[start][0] - - name = [] - module = inspect.getmodule(parentframe) - # `modname` can be None when frame is executed directly in console - # TODO(techtonik): consider using __main__ - if module: - name.append(module.__name__) - # detect classname - if 'self' in parentframe.f_locals: - # I don't know any way to detect call from the object method - # XXX: there seems to be no way to detect static method call - it will - # be just a function call - name.append(parentframe.f_locals['self'].__class__.__name__) - codename = parentframe.f_code.co_name - if codename != '': # top level usually - name.append( codename ) # function or a method - del parentframe - return ".".join(name) - -def get_class_that_defined(method): - for cls in inspect.getmro(method.im_class): - if method.__name__ in cls.__dict__: - return cls.__name__ - -def log_methods_calls(fname, some_class, prefix=None): - """ - Hack `some_class` to log all method calls into `fname` file. - If `prefix` format is not set, each log entry is prefixed with: - --[ asked / called / defined ] -- - asked - name of `some_class` - called - name of class for which a method is called - defined - name of class where method is defined - - Must be used carefully, because it monkeypatches __getattribute__ call. - - Example: log_methods_calls('log.log', ShellBaseWidget) - """ - # test if file is writable - open(fname, 'a').close() - FILENAME = fname - CLASS = some_class - - PREFIX = "--[ %(asked)s / %(called)s / %(defined)s ]--" - if prefix != None: - PREFIX = prefix - MAXWIDTH = {'o_O': 10} # hack with editable closure dict, to align names - - def format_prefix(method, methodobj): - """ - --[ ShellBase / Internal / BaseEdit ]------- get_position - """ - classnames = { - 'asked': CLASS.__name__, - 'called': methodobj.__class__.__name__, - 'defined': get_class_that_defined(method) - } - line = PREFIX % classnames - MAXWIDTH['o_O'] = max(len(line), MAXWIDTH['o_O']) - return line.ljust(MAXWIDTH['o_O'], '-') - - import types - def __getattribute__(self, name): - attr = object.__getattribute__(self, name) - if type(attr) is not types.MethodType: - return attr - else: - def newfunc(*args, **kwargs): - log = open(FILENAME, 'a') - prefix = format_prefix(attr, self) - log.write('%s %s\n' % (prefix, name)) - log.close() - result = attr(*args, **kwargs) - return result - return newfunc - - some_class.__getattribute__ = __getattribute__ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Debug utilities that are independent of Spyder code. + +See spyder.config.base for other helpers. +""" + +from __future__ import print_function + +import inspect +import traceback +import time + +from spyder.py3compat import PY2 + + +def log_time(fd): + timestr = "Logging time: %s" % time.ctime(time.time()) + print("="*len(timestr), file=fd) + print(timestr, file=fd) + print("="*len(timestr), file=fd) + print("", file=fd) + +def log_last_error(fname, context=None): + """Log last error in filename *fname* -- *context*: string (optional)""" + fd = open(fname, 'a') + log_time(fd) + if context: + print("Context", file=fd) + print("-------", file=fd) + print("", file=fd) + if PY2: + print(u' '.join(context).encode('utf-8').strip(), file=fd) + else: + print(context, file=fd) + print("", file=fd) + print("Traceback", file=fd) + print("---------", file=fd) + print("", file=fd) + traceback.print_exc(file=fd) + print("", file=fd) + print("", file=fd) + +def log_dt(fname, context, t0): + fd = open(fname, 'a') + log_time(fd) + print("%s: %d ms" % (context, 10*round(1e2*(time.time()-t0))), file=fd) + print("", file=fd) + print("", file=fd) + +def caller_name(skip=2): + """ + Get name of a caller in the format module.class.method + + `skip` specifies how many levels of call stack to skip for caller's name. + skip=1 means "who calls me", skip=2 "who calls my caller" etc. + + An empty string is returned if skipped levels exceed stack height + """ + stack = inspect.stack() + start = 0 + skip + if len(stack) < start + 1: + return '' + parentframe = stack[start][0] + + name = [] + module = inspect.getmodule(parentframe) + # `modname` can be None when frame is executed directly in console + # TODO(techtonik): consider using __main__ + if module: + name.append(module.__name__) + # detect classname + if 'self' in parentframe.f_locals: + # I don't know any way to detect call from the object method + # XXX: there seems to be no way to detect static method call - it will + # be just a function call + name.append(parentframe.f_locals['self'].__class__.__name__) + codename = parentframe.f_code.co_name + if codename != '': # top level usually + name.append( codename ) # function or a method + del parentframe + return ".".join(name) + +def get_class_that_defined(method): + for cls in inspect.getmro(method.im_class): + if method.__name__ in cls.__dict__: + return cls.__name__ + +def log_methods_calls(fname, some_class, prefix=None): + """ + Hack `some_class` to log all method calls into `fname` file. + If `prefix` format is not set, each log entry is prefixed with: + --[ asked / called / defined ] -- + asked - name of `some_class` + called - name of class for which a method is called + defined - name of class where method is defined + + Must be used carefully, because it monkeypatches __getattribute__ call. + + Example: log_methods_calls('log.log', ShellBaseWidget) + """ + # test if file is writable + open(fname, 'a').close() + FILENAME = fname + CLASS = some_class + + PREFIX = "--[ %(asked)s / %(called)s / %(defined)s ]--" + if prefix != None: + PREFIX = prefix + MAXWIDTH = {'o_O': 10} # hack with editable closure dict, to align names + + def format_prefix(method, methodobj): + """ + --[ ShellBase / Internal / BaseEdit ]------- get_position + """ + classnames = { + 'asked': CLASS.__name__, + 'called': methodobj.__class__.__name__, + 'defined': get_class_that_defined(method) + } + line = PREFIX % classnames + MAXWIDTH['o_O'] = max(len(line), MAXWIDTH['o_O']) + return line.ljust(MAXWIDTH['o_O'], '-') + + import types + def __getattribute__(self, name): + attr = object.__getattribute__(self, name) + if type(attr) is not types.MethodType: + return attr + else: + def newfunc(*args, **kwargs): + log = open(FILENAME, 'a') + prefix = format_prefix(attr, self) + log.write('%s %s\n' % (prefix, name)) + log.close() + result = attr(*args, **kwargs) + return result + return newfunc + + some_class.__getattribute__ = __getattribute__ diff --git a/spyder/utils/encoding.py b/spyder/utils/encoding.py index 4bdb74182f8..43a7de9862d 100644 --- a/spyder/utils/encoding.py +++ b/spyder/utils/encoding.py @@ -1,327 +1,327 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Text encoding utilities, text file I/O - -Functions 'get_coding', 'decode', 'encode' and 'to_unicode' come from Eric4 -source code (Utilities/__init___.py) Copyright © 2003-2009 Detlev Offenbach -""" - -# Standard library imports -from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF32 -import tempfile -import locale -import re -import os -import os.path as osp -import sys -import time -import errno - -# Third-party imports -from chardet.universaldetector import UniversalDetector -from atomicwrites import atomic_write - -# Local imports -from spyder.py3compat import (is_string, to_text_string, is_binary_string, - is_unicode, PY2) -from spyder.utils.external.binaryornot.check import is_binary - -if PY2: - import pathlib2 as pathlib -else: - import pathlib - - -PREFERRED_ENCODING = locale.getpreferredencoding() - -def transcode(text, input=PREFERRED_ENCODING, output=PREFERRED_ENCODING): - """Transcode a text string""" - try: - return text.decode("cp437").encode("cp1252") - except UnicodeError: - try: - return text.decode("cp437").encode(output) - except UnicodeError: - return text - -#------------------------------------------------------------------------------ -# Functions for encoding and decoding bytes that come from -# the *file system*. -#------------------------------------------------------------------------------ - -# The default encoding for file paths and environment variables should be set -# to match the default encoding that the OS is using. -def getfilesystemencoding(): - """ - Query the filesystem for the encoding used to encode filenames - and environment variables. - """ - encoding = sys.getfilesystemencoding() - if encoding is None: - # Must be Linux or Unix and nl_langinfo(CODESET) failed. - encoding = PREFERRED_ENCODING - return encoding - -FS_ENCODING = getfilesystemencoding() - -def to_unicode_from_fs(string): - """ - Return a unicode version of string decoded using the file system encoding. - """ - if not is_string(string): # string is a QString - string = to_text_string(string.toUtf8(), 'utf-8') - else: - if is_binary_string(string): - try: - unic = string.decode(FS_ENCODING) - except (UnicodeError, TypeError): - pass - else: - return unic - return string - -def to_fs_from_unicode(unic): - """ - Return a byte string version of unic encoded using the file - system encoding. - """ - if is_unicode(unic): - try: - string = unic.encode(FS_ENCODING) - except (UnicodeError, TypeError): - pass - else: - return string - return unic - -#------------------------------------------------------------------------------ -# Functions for encoding and decoding *text data* itself, usually originating -# from or destined for the *contents* of a file. -#------------------------------------------------------------------------------ - -# Codecs for working with files and text. -CODING_RE = re.compile(r"coding[:=]\s*([-\w_.]+)") -CODECS = ['utf-8', 'iso8859-1', 'iso8859-15', 'ascii', 'koi8-r', 'cp1251', - 'koi8-u', 'iso8859-2', 'iso8859-3', 'iso8859-4', 'iso8859-5', - 'iso8859-6', 'iso8859-7', 'iso8859-8', 'iso8859-9', - 'iso8859-10', 'iso8859-13', 'iso8859-14', 'latin-1', - 'utf-16'] - - -def get_coding(text, force_chardet=False): - """ - Function to get the coding of a text. - @param text text to inspect (string) - @return coding string - """ - if not force_chardet: - for line in text.splitlines()[:2]: - try: - result = CODING_RE.search(to_text_string(line)) - except UnicodeDecodeError: - # This could fail because to_text_string assume the text - # is utf8-like and we don't know the encoding to give - # it to to_text_string - pass - else: - if result: - codec = result.group(1) - # sometimes we find a false encoding that can - # result in errors - if codec in CODECS: - return codec - - # Fallback using chardet - if is_binary_string(text): - detector = UniversalDetector() - for line in text.splitlines()[:2]: - detector.feed(line) - if detector.done: break - - detector.close() - return detector.result['encoding'] - - return None - -def decode(text): - """ - Function to decode a text. - @param text text to decode (string) - @return decoded text and encoding - """ - try: - if text.startswith(BOM_UTF8): - # UTF-8 with BOM - return to_text_string(text[len(BOM_UTF8):], 'utf-8'), 'utf-8-bom' - elif text.startswith(BOM_UTF16): - # UTF-16 with BOM - return to_text_string(text[len(BOM_UTF16):], 'utf-16'), 'utf-16' - elif text.startswith(BOM_UTF32): - # UTF-32 with BOM - return to_text_string(text[len(BOM_UTF32):], 'utf-32'), 'utf-32' - coding = get_coding(text) - if coding: - return to_text_string(text, coding), coding - except (UnicodeError, LookupError): - pass - # Assume UTF-8 - try: - return to_text_string(text, 'utf-8'), 'utf-8-guessed' - except (UnicodeError, LookupError): - pass - # Assume Latin-1 (behaviour before 3.7.1) - return to_text_string(text, "latin-1"), 'latin-1-guessed' - -def encode(text, orig_coding): - """ - Function to encode a text. - @param text text to encode (string) - @param orig_coding type of the original coding (string) - @return encoded text and encoding - """ - if orig_coding == 'utf-8-bom': - return BOM_UTF8 + text.encode("utf-8"), 'utf-8-bom' - - # Try saving with original encoding - if orig_coding: - try: - return text.encode(orig_coding), orig_coding - except (UnicodeError, LookupError): - pass - - # Try declared coding spec - coding = get_coding(text) - if coding: - try: - return text.encode(coding), coding - except (UnicodeError, LookupError): - raise RuntimeError("Incorrect encoding (%s)" % coding) - if orig_coding and orig_coding.endswith('-default') or \ - orig_coding.endswith('-guessed'): - coding = orig_coding.replace("-default", "") - coding = orig_coding.replace("-guessed", "") - try: - return text.encode(coding), coding - except (UnicodeError, LookupError): - pass - - # Save as UTF-8 without BOM - return text.encode('utf-8'), 'utf-8' - -def to_unicode(string): - """Convert a string to unicode""" - if not is_unicode(string): - for codec in CODECS: - try: - unic = to_text_string(string, codec) - except UnicodeError: - pass - except TypeError: - break - else: - return unic - return string - - -def write(text, filename, encoding='utf-8', mode='wb'): - """ - Write 'text' to file ('filename') assuming 'encoding' in an atomic way - Return (eventually new) encoding - """ - text, encoding = encode(text, encoding) - - if os.name == 'nt': - try: - absolute_path_filename = pathlib.Path(filename).resolve() - if absolute_path_filename.exists(): - absolute_filename = to_text_string(absolute_path_filename) - else: - absolute_filename = osp.realpath(filename) - except (OSError, RuntimeError): - absolute_filename = osp.realpath(filename) - else: - absolute_filename = osp.realpath(filename) - - if 'a' in mode: - with open(absolute_filename, mode) as textfile: - textfile.write(text) - else: - # Based in the solution at untitaker/python-atomicwrites#42. - # Needed to fix file permissions overwriting. - # See spyder-ide/spyder#9381. - try: - file_stat = os.stat(absolute_filename) - original_mode = file_stat.st_mode - creation = file_stat.st_atime - except OSError: # Change to FileNotFoundError for PY3 - # Creating a new file, emulate what os.open() does - umask = os.umask(0) - os.umask(umask) - # Set base permission of a file to standard permissions. - # See #spyder-ide/spyder#14112. - original_mode = 0o666 & ~umask - creation = time.time() - try: - # fixes issues with scripts in Dropbox leaving - # temporary files in the folder, see spyder-ide/spyder#13041 - tempfolder = None - if 'dropbox' in absolute_filename.lower(): - tempfolder = tempfile.gettempdir() - with atomic_write(absolute_filename, overwrite=True, - mode=mode, dir=tempfolder) as textfile: - textfile.write(text) - except OSError as error: - # Some filesystems don't support the option to sync directories - # See untitaker/python-atomicwrites#17 - if error.errno != errno.EINVAL: - with open(absolute_filename, mode) as textfile: - textfile.write(text) - try: - os.chmod(absolute_filename, original_mode) - file_stat = os.stat(absolute_filename) - # Preserve creation timestamps - os.utime(absolute_filename, (creation, file_stat.st_mtime)) - except OSError: - # Prevent error when chmod/utime is not allowed - # See spyder-ide/spyder#11308 - pass - return encoding - - -def writelines(lines, filename, encoding='utf-8', mode='wb'): - """ - Write 'lines' to file ('filename') assuming 'encoding' - Return (eventually new) encoding - """ - return write(os.linesep.join(lines), filename, encoding, mode) - -def read(filename, encoding='utf-8'): - """ - Read text from file ('filename') - Return text and encoding - """ - text, encoding = decode( open(filename, 'rb').read() ) - return text, encoding - -def readlines(filename, encoding='utf-8'): - """ - Read lines from file ('filename') - Return lines and encoding - """ - text, encoding = read(filename, encoding) - return text.split(os.linesep), encoding - - -def is_text_file(filename): - """ - Test if the given path is a text-like file. - """ - try: - return not is_binary(filename) - except (OSError, IOError): - return False +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Text encoding utilities, text file I/O + +Functions 'get_coding', 'decode', 'encode' and 'to_unicode' come from Eric4 +source code (Utilities/__init___.py) Copyright © 2003-2009 Detlev Offenbach +""" + +# Standard library imports +from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF32 +import tempfile +import locale +import re +import os +import os.path as osp +import sys +import time +import errno + +# Third-party imports +from chardet.universaldetector import UniversalDetector +from atomicwrites import atomic_write + +# Local imports +from spyder.py3compat import (is_string, to_text_string, is_binary_string, + is_unicode, PY2) +from spyder.utils.external.binaryornot.check import is_binary + +if PY2: + import pathlib2 as pathlib +else: + import pathlib + + +PREFERRED_ENCODING = locale.getpreferredencoding() + +def transcode(text, input=PREFERRED_ENCODING, output=PREFERRED_ENCODING): + """Transcode a text string""" + try: + return text.decode("cp437").encode("cp1252") + except UnicodeError: + try: + return text.decode("cp437").encode(output) + except UnicodeError: + return text + +#------------------------------------------------------------------------------ +# Functions for encoding and decoding bytes that come from +# the *file system*. +#------------------------------------------------------------------------------ + +# The default encoding for file paths and environment variables should be set +# to match the default encoding that the OS is using. +def getfilesystemencoding(): + """ + Query the filesystem for the encoding used to encode filenames + and environment variables. + """ + encoding = sys.getfilesystemencoding() + if encoding is None: + # Must be Linux or Unix and nl_langinfo(CODESET) failed. + encoding = PREFERRED_ENCODING + return encoding + +FS_ENCODING = getfilesystemencoding() + +def to_unicode_from_fs(string): + """ + Return a unicode version of string decoded using the file system encoding. + """ + if not is_string(string): # string is a QString + string = to_text_string(string.toUtf8(), 'utf-8') + else: + if is_binary_string(string): + try: + unic = string.decode(FS_ENCODING) + except (UnicodeError, TypeError): + pass + else: + return unic + return string + +def to_fs_from_unicode(unic): + """ + Return a byte string version of unic encoded using the file + system encoding. + """ + if is_unicode(unic): + try: + string = unic.encode(FS_ENCODING) + except (UnicodeError, TypeError): + pass + else: + return string + return unic + +#------------------------------------------------------------------------------ +# Functions for encoding and decoding *text data* itself, usually originating +# from or destined for the *contents* of a file. +#------------------------------------------------------------------------------ + +# Codecs for working with files and text. +CODING_RE = re.compile(r"coding[:=]\s*([-\w_.]+)") +CODECS = ['utf-8', 'iso8859-1', 'iso8859-15', 'ascii', 'koi8-r', 'cp1251', + 'koi8-u', 'iso8859-2', 'iso8859-3', 'iso8859-4', 'iso8859-5', + 'iso8859-6', 'iso8859-7', 'iso8859-8', 'iso8859-9', + 'iso8859-10', 'iso8859-13', 'iso8859-14', 'latin-1', + 'utf-16'] + + +def get_coding(text, force_chardet=False): + """ + Function to get the coding of a text. + @param text text to inspect (string) + @return coding string + """ + if not force_chardet: + for line in text.splitlines()[:2]: + try: + result = CODING_RE.search(to_text_string(line)) + except UnicodeDecodeError: + # This could fail because to_text_string assume the text + # is utf8-like and we don't know the encoding to give + # it to to_text_string + pass + else: + if result: + codec = result.group(1) + # sometimes we find a false encoding that can + # result in errors + if codec in CODECS: + return codec + + # Fallback using chardet + if is_binary_string(text): + detector = UniversalDetector() + for line in text.splitlines()[:2]: + detector.feed(line) + if detector.done: break + + detector.close() + return detector.result['encoding'] + + return None + +def decode(text): + """ + Function to decode a text. + @param text text to decode (string) + @return decoded text and encoding + """ + try: + if text.startswith(BOM_UTF8): + # UTF-8 with BOM + return to_text_string(text[len(BOM_UTF8):], 'utf-8'), 'utf-8-bom' + elif text.startswith(BOM_UTF16): + # UTF-16 with BOM + return to_text_string(text[len(BOM_UTF16):], 'utf-16'), 'utf-16' + elif text.startswith(BOM_UTF32): + # UTF-32 with BOM + return to_text_string(text[len(BOM_UTF32):], 'utf-32'), 'utf-32' + coding = get_coding(text) + if coding: + return to_text_string(text, coding), coding + except (UnicodeError, LookupError): + pass + # Assume UTF-8 + try: + return to_text_string(text, 'utf-8'), 'utf-8-guessed' + except (UnicodeError, LookupError): + pass + # Assume Latin-1 (behaviour before 3.7.1) + return to_text_string(text, "latin-1"), 'latin-1-guessed' + +def encode(text, orig_coding): + """ + Function to encode a text. + @param text text to encode (string) + @param orig_coding type of the original coding (string) + @return encoded text and encoding + """ + if orig_coding == 'utf-8-bom': + return BOM_UTF8 + text.encode("utf-8"), 'utf-8-bom' + + # Try saving with original encoding + if orig_coding: + try: + return text.encode(orig_coding), orig_coding + except (UnicodeError, LookupError): + pass + + # Try declared coding spec + coding = get_coding(text) + if coding: + try: + return text.encode(coding), coding + except (UnicodeError, LookupError): + raise RuntimeError("Incorrect encoding (%s)" % coding) + if orig_coding and orig_coding.endswith('-default') or \ + orig_coding.endswith('-guessed'): + coding = orig_coding.replace("-default", "") + coding = orig_coding.replace("-guessed", "") + try: + return text.encode(coding), coding + except (UnicodeError, LookupError): + pass + + # Save as UTF-8 without BOM + return text.encode('utf-8'), 'utf-8' + +def to_unicode(string): + """Convert a string to unicode""" + if not is_unicode(string): + for codec in CODECS: + try: + unic = to_text_string(string, codec) + except UnicodeError: + pass + except TypeError: + break + else: + return unic + return string + + +def write(text, filename, encoding='utf-8', mode='wb'): + """ + Write 'text' to file ('filename') assuming 'encoding' in an atomic way + Return (eventually new) encoding + """ + text, encoding = encode(text, encoding) + + if os.name == 'nt': + try: + absolute_path_filename = pathlib.Path(filename).resolve() + if absolute_path_filename.exists(): + absolute_filename = to_text_string(absolute_path_filename) + else: + absolute_filename = osp.realpath(filename) + except (OSError, RuntimeError): + absolute_filename = osp.realpath(filename) + else: + absolute_filename = osp.realpath(filename) + + if 'a' in mode: + with open(absolute_filename, mode) as textfile: + textfile.write(text) + else: + # Based in the solution at untitaker/python-atomicwrites#42. + # Needed to fix file permissions overwriting. + # See spyder-ide/spyder#9381. + try: + file_stat = os.stat(absolute_filename) + original_mode = file_stat.st_mode + creation = file_stat.st_atime + except OSError: # Change to FileNotFoundError for PY3 + # Creating a new file, emulate what os.open() does + umask = os.umask(0) + os.umask(umask) + # Set base permission of a file to standard permissions. + # See #spyder-ide/spyder#14112. + original_mode = 0o666 & ~umask + creation = time.time() + try: + # fixes issues with scripts in Dropbox leaving + # temporary files in the folder, see spyder-ide/spyder#13041 + tempfolder = None + if 'dropbox' in absolute_filename.lower(): + tempfolder = tempfile.gettempdir() + with atomic_write(absolute_filename, overwrite=True, + mode=mode, dir=tempfolder) as textfile: + textfile.write(text) + except OSError as error: + # Some filesystems don't support the option to sync directories + # See untitaker/python-atomicwrites#17 + if error.errno != errno.EINVAL: + with open(absolute_filename, mode) as textfile: + textfile.write(text) + try: + os.chmod(absolute_filename, original_mode) + file_stat = os.stat(absolute_filename) + # Preserve creation timestamps + os.utime(absolute_filename, (creation, file_stat.st_mtime)) + except OSError: + # Prevent error when chmod/utime is not allowed + # See spyder-ide/spyder#11308 + pass + return encoding + + +def writelines(lines, filename, encoding='utf-8', mode='wb'): + """ + Write 'lines' to file ('filename') assuming 'encoding' + Return (eventually new) encoding + """ + return write(os.linesep.join(lines), filename, encoding, mode) + +def read(filename, encoding='utf-8'): + """ + Read text from file ('filename') + Return text and encoding + """ + text, encoding = decode( open(filename, 'rb').read() ) + return text, encoding + +def readlines(filename, encoding='utf-8'): + """ + Read lines from file ('filename') + Return lines and encoding + """ + text, encoding = read(filename, encoding) + return text.split(os.linesep), encoding + + +def is_text_file(filename): + """ + Test if the given path is a text-like file. + """ + try: + return not is_binary(filename) + except (OSError, IOError): + return False diff --git a/spyder/utils/environ.py b/spyder/utils/environ.py index a011fc2c4c9..50c53b19f83 100644 --- a/spyder/utils/environ.py +++ b/spyder/utils/environ.py @@ -1,186 +1,186 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Environment variable utilities. -""" - -# Standard library imports -import os - -# Third party imports -from qtpy.QtWidgets import QDialog, QMessageBox - -# Local imports -from spyder.config.base import _ -from spyder.widgets.collectionseditor import CollectionsEditor -from spyder.py3compat import PY2, iteritems, to_text_string, to_binary_string -from spyder.utils.icon_manager import ima -from spyder.utils.encoding import to_unicode_from_fs - - -def envdict2listdict(envdict): - """Dict --> Dict of lists""" - sep = os.path.pathsep - for key in envdict: - if sep in envdict[key]: - envdict[key] = [path.strip() for path in envdict[key].split(sep)] - return envdict - - -def listdict2envdict(listdict): - """Dict of lists --> Dict""" - for key in listdict: - if isinstance(listdict[key], list): - listdict[key] = os.path.pathsep.join(listdict[key]) - return listdict - - -def clean_env(env_vars): - """ - Remove non-ascii entries from a dictionary of environments variables. - - The values will be converted to strings or bytes (on Python 2). If an - exception is raised, an empty string will be used. - """ - new_env_vars = env_vars.copy() - for key, var in iteritems(env_vars): - if PY2: - # Try to convert vars first to utf-8. - try: - unicode_var = to_text_string(var) - except UnicodeDecodeError: - # If that fails, try to use the file system - # encoding because one of our vars is our - # PYTHONPATH, and that contains file system - # directories - try: - unicode_var = to_unicode_from_fs(var) - except Exception: - # If that also fails, make the var empty - # to be able to start Spyder. - # See https://stackoverflow.com/q/44506900/438386 - # for details. - unicode_var = '' - new_env_vars[key] = to_binary_string(unicode_var, encoding='utf-8') - else: - new_env_vars[key] = to_text_string(var) - - return new_env_vars - - -class RemoteEnvDialog(CollectionsEditor): - """Remote process environment variables dialog.""" - - def __init__(self, environ, parent=None): - super(RemoteEnvDialog, self).__init__(parent) - try: - self.setup( - envdict2listdict(environ), - title=_("Environment variables"), - readonly=True, - icon=ima.icon('environ') - ) - except Exception as e: - QMessageBox.warning( - parent, - _("Warning"), - _("An error occurred while trying to show your " - "environment variables. The error was

    " - "{0}").format(e), - QMessageBox.Ok - ) - - -class EnvDialog(RemoteEnvDialog): - """Environment variables Dialog""" - def __init__(self, parent=None): - RemoteEnvDialog.__init__(self, dict(os.environ), parent=parent) - - -# For Windows only -try: - from spyder.py3compat import winreg - - def get_user_env(): - """Return HKCU (current user) environment variables""" - reg = dict() - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") - for index in range(0, winreg.QueryInfoKey(key)[1]): - try: - value = winreg.EnumValue(key, index) - reg[value[0]] = value[1] - except: - break - return envdict2listdict(reg) - - def set_user_env(reg, parent=None): - """Set HKCU (current user) environment variables""" - reg = listdict2envdict(reg) - types = dict() - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") - for name in reg: - try: - _x, types[name] = winreg.QueryValueEx(key, name) - except WindowsError: - types[name] = winreg.REG_EXPAND_SZ - key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, - winreg.KEY_SET_VALUE) - for name in reg: - winreg.SetValueEx(key, name, 0, types[name], reg[name]) - try: - from win32gui import SendMessageTimeout - from win32con import (HWND_BROADCAST, WM_SETTINGCHANGE, - SMTO_ABORTIFHUNG) - SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, - "Environment", SMTO_ABORTIFHUNG, 5000) - except Exception: - QMessageBox.warning(parent, _("Warning"), - _("Module pywin32 was not found.
    " - "Please restart this Windows session " - "(not the computer) for changes to take effect.")) - - class WinUserEnvDialog(CollectionsEditor): - """Windows User Environment Variables Editor""" - def __init__(self, parent=None): - super(WinUserEnvDialog, self).__init__(parent) - self.setup(get_user_env(), - title=r"HKEY_CURRENT_USER\Environment") - if parent is None: - parent = self - QMessageBox.warning(parent, _("Warning"), - _("If you accept changes, " - "this will modify the current user environment " - "variables directly in Windows registry. " - "Use it with precautions, at your own risks.
    " - "
    Note that for changes to take effect, you will " - "need to restart the parent process of this applica" - "tion (simply restart Spyder if you have executed it " - "from a Windows shortcut, otherwise restart any " - "application from which you may have executed it, " - "like Python(x,y) Home for example)")) - - def accept(self): - """Reimplement Qt method""" - set_user_env(listdict2envdict(self.get_value()), parent=self) - QDialog.accept(self) - -except Exception: - pass - -def main(): - """Run Windows environment variable editor""" - from spyder.utils.qthelpers import qapplication - app = qapplication() - if os.name == 'nt': - dialog = WinUserEnvDialog() - else: - dialog = EnvDialog() - dialog.show() - app.exec_() - -if __name__ == "__main__": - main() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Environment variable utilities. +""" + +# Standard library imports +import os + +# Third party imports +from qtpy.QtWidgets import QDialog, QMessageBox + +# Local imports +from spyder.config.base import _ +from spyder.widgets.collectionseditor import CollectionsEditor +from spyder.py3compat import PY2, iteritems, to_text_string, to_binary_string +from spyder.utils.icon_manager import ima +from spyder.utils.encoding import to_unicode_from_fs + + +def envdict2listdict(envdict): + """Dict --> Dict of lists""" + sep = os.path.pathsep + for key in envdict: + if sep in envdict[key]: + envdict[key] = [path.strip() for path in envdict[key].split(sep)] + return envdict + + +def listdict2envdict(listdict): + """Dict of lists --> Dict""" + for key in listdict: + if isinstance(listdict[key], list): + listdict[key] = os.path.pathsep.join(listdict[key]) + return listdict + + +def clean_env(env_vars): + """ + Remove non-ascii entries from a dictionary of environments variables. + + The values will be converted to strings or bytes (on Python 2). If an + exception is raised, an empty string will be used. + """ + new_env_vars = env_vars.copy() + for key, var in iteritems(env_vars): + if PY2: + # Try to convert vars first to utf-8. + try: + unicode_var = to_text_string(var) + except UnicodeDecodeError: + # If that fails, try to use the file system + # encoding because one of our vars is our + # PYTHONPATH, and that contains file system + # directories + try: + unicode_var = to_unicode_from_fs(var) + except Exception: + # If that also fails, make the var empty + # to be able to start Spyder. + # See https://stackoverflow.com/q/44506900/438386 + # for details. + unicode_var = '' + new_env_vars[key] = to_binary_string(unicode_var, encoding='utf-8') + else: + new_env_vars[key] = to_text_string(var) + + return new_env_vars + + +class RemoteEnvDialog(CollectionsEditor): + """Remote process environment variables dialog.""" + + def __init__(self, environ, parent=None): + super(RemoteEnvDialog, self).__init__(parent) + try: + self.setup( + envdict2listdict(environ), + title=_("Environment variables"), + readonly=True, + icon=ima.icon('environ') + ) + except Exception as e: + QMessageBox.warning( + parent, + _("Warning"), + _("An error occurred while trying to show your " + "environment variables. The error was

    " + "{0}").format(e), + QMessageBox.Ok + ) + + +class EnvDialog(RemoteEnvDialog): + """Environment variables Dialog""" + def __init__(self, parent=None): + RemoteEnvDialog.__init__(self, dict(os.environ), parent=parent) + + +# For Windows only +try: + from spyder.py3compat import winreg + + def get_user_env(): + """Return HKCU (current user) environment variables""" + reg = dict() + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") + for index in range(0, winreg.QueryInfoKey(key)[1]): + try: + value = winreg.EnumValue(key, index) + reg[value[0]] = value[1] + except: + break + return envdict2listdict(reg) + + def set_user_env(reg, parent=None): + """Set HKCU (current user) environment variables""" + reg = listdict2envdict(reg) + types = dict() + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") + for name in reg: + try: + _x, types[name] = winreg.QueryValueEx(key, name) + except WindowsError: + types[name] = winreg.REG_EXPAND_SZ + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, + winreg.KEY_SET_VALUE) + for name in reg: + winreg.SetValueEx(key, name, 0, types[name], reg[name]) + try: + from win32gui import SendMessageTimeout + from win32con import (HWND_BROADCAST, WM_SETTINGCHANGE, + SMTO_ABORTIFHUNG) + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, + "Environment", SMTO_ABORTIFHUNG, 5000) + except Exception: + QMessageBox.warning(parent, _("Warning"), + _("Module pywin32 was not found.
    " + "Please restart this Windows session " + "(not the computer) for changes to take effect.")) + + class WinUserEnvDialog(CollectionsEditor): + """Windows User Environment Variables Editor""" + def __init__(self, parent=None): + super(WinUserEnvDialog, self).__init__(parent) + self.setup(get_user_env(), + title=r"HKEY_CURRENT_USER\Environment") + if parent is None: + parent = self + QMessageBox.warning(parent, _("Warning"), + _("If you accept changes, " + "this will modify the current user environment " + "variables directly in Windows registry. " + "Use it with precautions, at your own risks.
    " + "
    Note that for changes to take effect, you will " + "need to restart the parent process of this applica" + "tion (simply restart Spyder if you have executed it " + "from a Windows shortcut, otherwise restart any " + "application from which you may have executed it, " + "like Python(x,y) Home for example)")) + + def accept(self): + """Reimplement Qt method""" + set_user_env(listdict2envdict(self.get_value()), parent=self) + QDialog.accept(self) + +except Exception: + pass + +def main(): + """Run Windows environment variable editor""" + from spyder.utils.qthelpers import qapplication + app = qapplication() + if os.name == 'nt': + dialog = WinUserEnvDialog() + else: + dialog = EnvDialog() + dialog.show() + app.exec_() + +if __name__ == "__main__": + main() diff --git a/spyder/utils/external/lockfile.py b/spyder/utils/external/lockfile.py index 0b43cbfddf5..41bf0f7b095 100644 --- a/spyder/utils/external/lockfile.py +++ b/spyder/utils/external/lockfile.py @@ -1,251 +1,251 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2005 Divmod, Inc. -# Copyright (c) 2008-2011 Twisted Matrix Laboratories -# Copyright (c) 2012- Spyder Project Contributors -# -# Distributed under the terms of the MIT (Expat) License -# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) -# ----------------------------------------------------------------------------- - -""" -Filesystem-based interprocess mutex. - -Taken from the Twisted project. -Distributed under the MIT (Expat) license. - -Changes by the Spyder Team to the original module: - * Rewrite kill Windows function to make it more reliable. - * Detect if the process that owns the lock is an Spyder one. - -Adapted from src/twisted/python/lockfile.py of the -`Twisted project `_. -""" - -__metaclass__ = type - -import errno, os -from time import time as _uniquefloat - -from spyder.py3compat import PY2, to_binary_string -from spyder.utils.programs import is_spyder_process - -def unique(): - if PY2: - return str(long(_uniquefloat() * 1000)) - else: - return str(int(_uniquefloat() * 1000)) - -from os import rename -if not os.name == 'nt': - from os import kill - from os import symlink - from os import readlink - from os import remove as rmlink - _windows = False -else: - _windows = True - - import ctypes - from ctypes import wintypes - - # https://docs.microsoft.com/en-us/windows/desktop/ProcThread/process-security-and-access-rights - PROCESS_QUERY_INFORMATION = 0x400 - - # GetExitCodeProcess uses a special exit code to indicate that the - # process is still running. - STILL_ACTIVE = 259 - - def _is_pid_running(pid): - """Taken from https://www.madebuild.org/blog/?p=30""" - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) - if handle == 0: - return False - - # If the process exited recently, a pid may still exist for the - # handle. So, check if we can get the exit code. - exit_code = wintypes.DWORD() - retval = kernel32.GetExitCodeProcess(handle, - ctypes.byref(exit_code)) - is_running = (retval == 0) - kernel32.CloseHandle(handle) - - # See if we couldn't get the exit code or the exit code indicates - # that the process is still running. - return is_running or exit_code.value == STILL_ACTIVE - - def kill(pid, signal): # analysis:ignore - if not _is_pid_running(pid): - raise OSError(errno.ESRCH, None) - else: - return - - _open = open - - # XXX Implement an atomic thingamajig for win32 - def symlink(value, filename): #analysis:ignore - newlinkname = filename+"."+unique()+'.newlink' - newvalname = os.path.join(newlinkname, "symlink") - os.mkdir(newlinkname) - f = _open(newvalname, 'wb') - f.write(to_binary_string(value)) - f.flush() - f.close() - try: - rename(newlinkname, filename) - except: - # This is needed to avoid an error when we don't - # have permissions to write in ~/.spyder - # See issues 6319 and 9093 - try: - os.remove(newvalname) - os.rmdir(newlinkname) - except (IOError, OSError): - return - raise - - def readlink(filename): #analysis:ignore - try: - fObj = _open(os.path.join(filename, 'symlink'), 'rb') - except IOError as e: - if e.errno == errno.ENOENT or e.errno == errno.EIO: - raise OSError(e.errno, None) - raise - else: - result = fObj.read().decode() - fObj.close() - return result - - def rmlink(filename): #analysis:ignore - os.remove(os.path.join(filename, 'symlink')) - os.rmdir(filename) - - - -class FilesystemLock: - """ - A mutex. - - This relies on the filesystem property that creating - a symlink is an atomic operation and that it will - fail if the symlink already exists. Deleting the - symlink will release the lock. - - @ivar name: The name of the file associated with this lock. - - @ivar clean: Indicates whether this lock was released cleanly by its - last owner. Only meaningful after C{lock} has been called and - returns True. - - @ivar locked: Indicates whether the lock is currently held by this - object. - """ - - clean = None - locked = False - - def __init__(self, name): - self.name = name - - def lock(self): - """ - Acquire this lock. - - @rtype: C{bool} - @return: True if the lock is acquired, false otherwise. - - @raise: Any exception os.symlink() may raise, other than - EEXIST. - """ - clean = True - while True: - try: - symlink(str(os.getpid()), self.name) - except OSError as e: - if _windows and e.errno in (errno.EACCES, errno.EIO): - # The lock is in the middle of being deleted because we're - # on Windows where lock removal isn't atomic. Give up, we - # don't know how long this is going to take. - return False - if e.errno == errno.EEXIST: - try: - pid = readlink(self.name) - except OSError as e: - if e.errno == errno.ENOENT: - # The lock has vanished, try to claim it in the - # next iteration through the loop. - continue - raise - except IOError as e: - if _windows and e.errno == errno.EACCES: - # The lock is in the middle of being - # deleted because we're on Windows where - # lock removal isn't atomic. Give up, we - # don't know how long this is going to - # take. - return False - raise - try: - if kill is not None: - kill(int(pid), 0) - if not is_spyder_process(int(pid)): - raise(OSError(errno.ESRCH, 'No such process')) - except OSError as e: - if e.errno == errno.ESRCH: - # The owner has vanished, try to claim it in the - # next iteration through the loop. - try: - rmlink(self.name) - except OSError as e: - if e.errno == errno.ENOENT: - # Another process cleaned up the lock. - # Race them to acquire it in the next - # iteration through the loop. - continue - raise - clean = False - continue - raise - return False - raise - self.locked = True - self.clean = clean - return True - - def unlock(self): - """ - Release this lock. - - This deletes the directory with the given name. - - @raise: Any exception os.readlink() may raise, or - ValueError if the lock is not owned by this process. - """ - pid = readlink(self.name) - if int(pid) != os.getpid(): - raise ValueError("Lock %r not owned by this process" % (self.name,)) - rmlink(self.name) - self.locked = False - - -def isLocked(name): - """Determine if the lock of the given name is held or not. - - @type name: C{str} - @param name: The filesystem path to the lock to test - - @rtype: C{bool} - @return: True if the lock is held, False otherwise. - """ - l = FilesystemLock(name) - result = None - try: - result = l.lock() - finally: - if result: - l.unlock() - return not result - - -__all__ = ['FilesystemLock', 'isLocked'] +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2005 Divmod, Inc. +# Copyright (c) 2008-2011 Twisted Matrix Laboratories +# Copyright (c) 2012- Spyder Project Contributors +# +# Distributed under the terms of the MIT (Expat) License +# (see LICENSE.txt in this directory and NOTICE.txt in the root for details) +# ----------------------------------------------------------------------------- + +""" +Filesystem-based interprocess mutex. + +Taken from the Twisted project. +Distributed under the MIT (Expat) license. + +Changes by the Spyder Team to the original module: + * Rewrite kill Windows function to make it more reliable. + * Detect if the process that owns the lock is an Spyder one. + +Adapted from src/twisted/python/lockfile.py of the +`Twisted project `_. +""" + +__metaclass__ = type + +import errno, os +from time import time as _uniquefloat + +from spyder.py3compat import PY2, to_binary_string +from spyder.utils.programs import is_spyder_process + +def unique(): + if PY2: + return str(long(_uniquefloat() * 1000)) + else: + return str(int(_uniquefloat() * 1000)) + +from os import rename +if not os.name == 'nt': + from os import kill + from os import symlink + from os import readlink + from os import remove as rmlink + _windows = False +else: + _windows = True + + import ctypes + from ctypes import wintypes + + # https://docs.microsoft.com/en-us/windows/desktop/ProcThread/process-security-and-access-rights + PROCESS_QUERY_INFORMATION = 0x400 + + # GetExitCodeProcess uses a special exit code to indicate that the + # process is still running. + STILL_ACTIVE = 259 + + def _is_pid_running(pid): + """Taken from https://www.madebuild.org/blog/?p=30""" + kernel32 = ctypes.windll.kernel32 + handle = kernel32.OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) + if handle == 0: + return False + + # If the process exited recently, a pid may still exist for the + # handle. So, check if we can get the exit code. + exit_code = wintypes.DWORD() + retval = kernel32.GetExitCodeProcess(handle, + ctypes.byref(exit_code)) + is_running = (retval == 0) + kernel32.CloseHandle(handle) + + # See if we couldn't get the exit code or the exit code indicates + # that the process is still running. + return is_running or exit_code.value == STILL_ACTIVE + + def kill(pid, signal): # analysis:ignore + if not _is_pid_running(pid): + raise OSError(errno.ESRCH, None) + else: + return + + _open = open + + # XXX Implement an atomic thingamajig for win32 + def symlink(value, filename): #analysis:ignore + newlinkname = filename+"."+unique()+'.newlink' + newvalname = os.path.join(newlinkname, "symlink") + os.mkdir(newlinkname) + f = _open(newvalname, 'wb') + f.write(to_binary_string(value)) + f.flush() + f.close() + try: + rename(newlinkname, filename) + except: + # This is needed to avoid an error when we don't + # have permissions to write in ~/.spyder + # See issues 6319 and 9093 + try: + os.remove(newvalname) + os.rmdir(newlinkname) + except (IOError, OSError): + return + raise + + def readlink(filename): #analysis:ignore + try: + fObj = _open(os.path.join(filename, 'symlink'), 'rb') + except IOError as e: + if e.errno == errno.ENOENT or e.errno == errno.EIO: + raise OSError(e.errno, None) + raise + else: + result = fObj.read().decode() + fObj.close() + return result + + def rmlink(filename): #analysis:ignore + os.remove(os.path.join(filename, 'symlink')) + os.rmdir(filename) + + + +class FilesystemLock: + """ + A mutex. + + This relies on the filesystem property that creating + a symlink is an atomic operation and that it will + fail if the symlink already exists. Deleting the + symlink will release the lock. + + @ivar name: The name of the file associated with this lock. + + @ivar clean: Indicates whether this lock was released cleanly by its + last owner. Only meaningful after C{lock} has been called and + returns True. + + @ivar locked: Indicates whether the lock is currently held by this + object. + """ + + clean = None + locked = False + + def __init__(self, name): + self.name = name + + def lock(self): + """ + Acquire this lock. + + @rtype: C{bool} + @return: True if the lock is acquired, false otherwise. + + @raise: Any exception os.symlink() may raise, other than + EEXIST. + """ + clean = True + while True: + try: + symlink(str(os.getpid()), self.name) + except OSError as e: + if _windows and e.errno in (errno.EACCES, errno.EIO): + # The lock is in the middle of being deleted because we're + # on Windows where lock removal isn't atomic. Give up, we + # don't know how long this is going to take. + return False + if e.errno == errno.EEXIST: + try: + pid = readlink(self.name) + except OSError as e: + if e.errno == errno.ENOENT: + # The lock has vanished, try to claim it in the + # next iteration through the loop. + continue + raise + except IOError as e: + if _windows and e.errno == errno.EACCES: + # The lock is in the middle of being + # deleted because we're on Windows where + # lock removal isn't atomic. Give up, we + # don't know how long this is going to + # take. + return False + raise + try: + if kill is not None: + kill(int(pid), 0) + if not is_spyder_process(int(pid)): + raise(OSError(errno.ESRCH, 'No such process')) + except OSError as e: + if e.errno == errno.ESRCH: + # The owner has vanished, try to claim it in the + # next iteration through the loop. + try: + rmlink(self.name) + except OSError as e: + if e.errno == errno.ENOENT: + # Another process cleaned up the lock. + # Race them to acquire it in the next + # iteration through the loop. + continue + raise + clean = False + continue + raise + return False + raise + self.locked = True + self.clean = clean + return True + + def unlock(self): + """ + Release this lock. + + This deletes the directory with the given name. + + @raise: Any exception os.readlink() may raise, or + ValueError if the lock is not owned by this process. + """ + pid = readlink(self.name) + if int(pid) != os.getpid(): + raise ValueError("Lock %r not owned by this process" % (self.name,)) + rmlink(self.name) + self.locked = False + + +def isLocked(name): + """Determine if the lock of the given name is held or not. + + @type name: C{str} + @param name: The filesystem path to the lock to test + + @rtype: C{bool} + @return: True if the lock is held, False otherwise. + """ + l = FilesystemLock(name) + result = None + try: + result = l.lock() + finally: + if result: + l.unlock() + return not result + + +__all__ = ['FilesystemLock', 'isLocked'] diff --git a/spyder/utils/introspection/module_completion.py b/spyder/utils/introspection/module_completion.py index 7cf0545b3db..785617594e8 100644 --- a/spyder/utils/introspection/module_completion.py +++ b/spyder/utils/introspection/module_completion.py @@ -1,75 +1,75 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright (c) 2010-2011 The IPython Development Team -# Copyright (c) 2011- Spyder Project Contributors -# -# Distributed under the terms of the Modified BSD License -# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). -# ----------------------------------------------------------------------------- - -""" -Module completion auxiliary functions. -""" - -import pkgutil - -from pickleshare import PickleShareDB - -from spyder.config.base import get_conf_path - - -# List of preferred modules -PREFERRED_MODULES = ['numpy', 'scipy', 'sympy', 'pandas', 'networkx', - 'statsmodels', 'matplotlib', 'sklearn', 'skimage', - 'mpmath', 'os', 'pillow', 'OpenGL', 'array', 'audioop', - 'binascii', 'cPickle', 'cStringIO', 'cmath', - 'collections', 'datetime', 'errno', 'exceptions', 'gc', - 'importlib', 'itertools', 'math', 'mmap', - 'msvcrt', 'nt', 'operator', 'ast', 'signal', - 'sys', 'threading', 'time', 'wx', 'zipimport', - 'zlib', 'pytest', 'PyQt4', 'PyQt5', 'PySide', - 'PySide2', 'os.path'] - - -def get_submodules(mod): - """Get all submodules of a given module""" - def catch_exceptions(module): - pass - try: - m = __import__(mod) - submodules = [mod] - submods = pkgutil.walk_packages(m.__path__, m.__name__ + '.', - catch_exceptions) - for sm in submods: - sm_name = sm[1] - submodules.append(sm_name) - except ImportError: - return [] - except: - return [mod] - - return submodules - - -def get_preferred_submodules(): - """ - Get all submodules of the main scientific modules and others of our - interest - """ - # Path to the modules database - modules_path = get_conf_path('db') - - # Modules database - modules_db = PickleShareDB(modules_path) - - if 'submodules' in modules_db: - return modules_db['submodules'] - - submodules = [] - - for m in PREFERRED_MODULES: - submods = get_submodules(m) - submodules += submods - - modules_db['submodules'] = submodules - return submodules +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright (c) 2010-2011 The IPython Development Team +# Copyright (c) 2011- Spyder Project Contributors +# +# Distributed under the terms of the Modified BSD License +# (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). +# ----------------------------------------------------------------------------- + +""" +Module completion auxiliary functions. +""" + +import pkgutil + +from pickleshare import PickleShareDB + +from spyder.config.base import get_conf_path + + +# List of preferred modules +PREFERRED_MODULES = ['numpy', 'scipy', 'sympy', 'pandas', 'networkx', + 'statsmodels', 'matplotlib', 'sklearn', 'skimage', + 'mpmath', 'os', 'pillow', 'OpenGL', 'array', 'audioop', + 'binascii', 'cPickle', 'cStringIO', 'cmath', + 'collections', 'datetime', 'errno', 'exceptions', 'gc', + 'importlib', 'itertools', 'math', 'mmap', + 'msvcrt', 'nt', 'operator', 'ast', 'signal', + 'sys', 'threading', 'time', 'wx', 'zipimport', + 'zlib', 'pytest', 'PyQt4', 'PyQt5', 'PySide', + 'PySide2', 'os.path'] + + +def get_submodules(mod): + """Get all submodules of a given module""" + def catch_exceptions(module): + pass + try: + m = __import__(mod) + submodules = [mod] + submods = pkgutil.walk_packages(m.__path__, m.__name__ + '.', + catch_exceptions) + for sm in submods: + sm_name = sm[1] + submodules.append(sm_name) + except ImportError: + return [] + except: + return [mod] + + return submodules + + +def get_preferred_submodules(): + """ + Get all submodules of the main scientific modules and others of our + interest + """ + # Path to the modules database + modules_path = get_conf_path('db') + + # Modules database + modules_db = PickleShareDB(modules_path) + + if 'submodules' in modules_db: + return modules_db['submodules'] + + submodules = [] + + for m in PREFERRED_MODULES: + submods = get_submodules(m) + submodules += submods + + modules_db['submodules'] = submodules + return submodules diff --git a/spyder/utils/introspection/rope_patch.py b/spyder/utils/introspection/rope_patch.py index 7301030ffa9..bac6656ee42 100644 --- a/spyder/utils/introspection/rope_patch.py +++ b/spyder/utils/introspection/rope_patch.py @@ -1,211 +1,211 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Patching rope: - -[1] For compatibility with Spyder's standalone version, built with py2exe or - cx_Freeze - -[2] For better performance, see this thread: - https://groups.google.com/forum/#!topic/rope-dev/V95XMfICU3o - -[3] To avoid considering folders without __init__.py as Python packages, thus - avoiding side effects as non-working introspection features on a Python - module or package when a folder in current directory has the same name. - See this thread: - https://groups.google.com/forum/#!topic/rope-dev/kkxLWmJo5hg - -[4] To avoid rope adding a 2 spaces indent to every docstring it gets, because - it breaks the work of Sphinx on the Help plugin. Also, to better - control how to get calltips and docstrings of forced builtin objects. - -[5] To make matplotlib return its docstrings in proper rst, instead of a mix - of rst and plain text. -""" - -def apply(): - """Monkey patching rope - - See [1], [2], [3], [4] and [5] in module docstring.""" - from spyder.utils.programs import is_module_installed - if is_module_installed('rope', '<0.9.4'): - import rope - raise ImportError("rope %s can't be patched" % rope.VERSION) - - # [1] Patching project.Project for compatibility with py2exe/cx_Freeze - # distributions - from spyder.config.base import is_py2exe_or_cx_Freeze - if is_py2exe_or_cx_Freeze(): - from rope.base import project - class PatchedProject(project.Project): - def _default_config(self): - # py2exe/cx_Freeze distribution - from spyder.config.base import get_module_source_path - fname = get_module_source_path('spyder', - 'default_config.py') - return open(fname, 'rb').read() - project.Project = PatchedProject - - # Patching pycore.PyCore... - from rope.base import pycore - class PatchedPyCore(pycore.PyCore): - # [2] ...so that forced builtin modules (i.e. modules that were - # declared as 'extension_modules' in rope preferences) will be indeed - # recognized as builtins by rope, as expected - # - # This patch is included in rope 0.9.4+ but applying it anyway is ok - def get_module(self, name, folder=None): - """Returns a `PyObject` if the module was found.""" - # check if this is a builtin module - pymod = self._builtin_module(name) - if pymod is not None: - return pymod - module = self.find_module(name, folder) - if module is None: - raise pycore.ModuleNotFoundError( - 'Module %s not found' % name) - return self.resource_to_pyobject(module) - # [3] ...to avoid considering folders without __init__.py as Python - # packages - def _find_module_in_folder(self, folder, modname): - module = folder - packages = modname.split('.') - for pkg in packages[:-1]: - if module.is_folder() and module.has_child(pkg): - module = module.get_child(pkg) - else: - return None - if module.is_folder(): - if module.has_child(packages[-1]) and \ - module.get_child(packages[-1]).is_folder() and \ - module.get_child(packages[-1]).has_child('__init__.py'): - return module.get_child(packages[-1]) - elif module.has_child(packages[-1] + '.py') and \ - not module.get_child(packages[-1] + '.py').is_folder(): - return module.get_child(packages[-1] + '.py') - pycore.PyCore = PatchedPyCore - - # [2] Patching BuiltinName for the go to definition feature to simply work - # with forced builtins - from rope.base import builtins, libutils, pyobjects - import inspect - import os.path as osp - class PatchedBuiltinName(builtins.BuiltinName): - def _pycore(self): - p = self.pyobject - while p.parent is not None: - p = p.parent - if isinstance(p, builtins.BuiltinModule) and p.pycore is not None: - return p.pycore - def get_definition_location(self): - if not inspect.isbuiltin(self.pyobject): - _lines, lineno = inspect.getsourcelines(self.pyobject.builtin) - path = inspect.getfile(self.pyobject.builtin) - if path.endswith('pyc') and osp.isfile(path[:-1]): - path = path[:-1] - pycore = self._pycore() - if pycore and pycore.project: - resource = libutils.path_to_resource(pycore.project, path) - module = pyobjects.PyModule(pycore, None, resource) - return (module, lineno) - return (None, None) - builtins.BuiltinName = PatchedBuiltinName - - # [4] Patching several PyDocExtractor methods: - # 1. get_doc: - # To force rope to return the docstring of any object which has one, even - # if it's not an instance of AbstractFunction, AbstractClass, or - # AbstractModule. - # Also, to use utils.dochelpers.getdoc to get docs from forced builtins. - # - # 2. _get_class_docstring and _get_single_function_docstring: - # To not let rope add a 2 spaces indentation to every docstring, which was - # breaking our rich text mode. The only value that we are modifying is the - # 'indents' keyword of those methods, from 2 to 0. - # - # 3. get_calltip - # To easily get calltips of forced builtins - from rope.contrib import codeassist - from spyder_kernels.utils.dochelpers import getdoc - from rope.base import exceptions - class PatchedPyDocExtractor(codeassist.PyDocExtractor): - def get_builtin_doc(self, pyobject): - buitin = pyobject.builtin - return getdoc(buitin) - - def get_doc(self, pyobject): - if hasattr(pyobject, 'builtin'): - doc = self.get_builtin_doc(pyobject) - return doc - elif isinstance(pyobject, builtins.BuiltinModule): - docstring = pyobject.get_doc() - if docstring is not None: - docstring = self._trim_docstring(docstring) - else: - docstring = '' - # TODO: Add a module_name key, so that the name could appear - # on the OI text filed but not be used by sphinx to render - # the page - doc = {'name': '', - 'argspec': '', - 'note': '', - 'docstring': docstring - } - return doc - elif isinstance(pyobject, pyobjects.AbstractFunction): - return self._get_function_docstring(pyobject) - elif isinstance(pyobject, pyobjects.AbstractClass): - return self._get_class_docstring(pyobject) - elif isinstance(pyobject, pyobjects.AbstractModule): - return self._trim_docstring(pyobject.get_doc()) - elif pyobject.get_doc() is not None: # Spyder patch - return self._trim_docstring(pyobject.get_doc()) - return None - - def get_calltip(self, pyobject, ignore_unknown=False, remove_self=False): - if hasattr(pyobject, 'builtin'): - doc = self.get_builtin_doc(pyobject) - return doc['name'] + doc['argspec'] - try: - if isinstance(pyobject, pyobjects.AbstractClass): - pyobject = pyobject['__init__'].get_object() - if not isinstance(pyobject, pyobjects.AbstractFunction): - pyobject = pyobject['__call__'].get_object() - except exceptions.AttributeNotFoundError: - return None - if ignore_unknown and not isinstance(pyobject, pyobjects.PyFunction): - return - if isinstance(pyobject, pyobjects.AbstractFunction): - result = self._get_function_signature(pyobject, add_module=True) - if remove_self and self._is_method(pyobject): - return result.replace('(self)', '()').replace('(self, ', '(') - return result - - def _get_class_docstring(self, pyclass): - contents = self._trim_docstring(pyclass.get_doc(), indents=0) - supers = [super.get_name() for super in pyclass.get_superclasses()] - doc = 'class %s(%s):\n\n' % (pyclass.get_name(), ', '.join(supers)) + contents - - if '__init__' in pyclass: - init = pyclass['__init__'].get_object() - if isinstance(init, pyobjects.AbstractFunction): - doc += '\n\n' + self._get_single_function_docstring(init) - return doc - - def _get_single_function_docstring(self, pyfunction): - docs = pyfunction.get_doc() - docs = self._trim_docstring(docs, indents=0) - return docs - codeassist.PyDocExtractor = PatchedPyDocExtractor - - - # [5] Get the right matplotlib docstrings for Help - try: - import matplotlib as mpl - mpl.rcParams['docstring.hardcopy'] = True - except: - pass +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Patching rope: + +[1] For compatibility with Spyder's standalone version, built with py2exe or + cx_Freeze + +[2] For better performance, see this thread: + https://groups.google.com/forum/#!topic/rope-dev/V95XMfICU3o + +[3] To avoid considering folders without __init__.py as Python packages, thus + avoiding side effects as non-working introspection features on a Python + module or package when a folder in current directory has the same name. + See this thread: + https://groups.google.com/forum/#!topic/rope-dev/kkxLWmJo5hg + +[4] To avoid rope adding a 2 spaces indent to every docstring it gets, because + it breaks the work of Sphinx on the Help plugin. Also, to better + control how to get calltips and docstrings of forced builtin objects. + +[5] To make matplotlib return its docstrings in proper rst, instead of a mix + of rst and plain text. +""" + +def apply(): + """Monkey patching rope + + See [1], [2], [3], [4] and [5] in module docstring.""" + from spyder.utils.programs import is_module_installed + if is_module_installed('rope', '<0.9.4'): + import rope + raise ImportError("rope %s can't be patched" % rope.VERSION) + + # [1] Patching project.Project for compatibility with py2exe/cx_Freeze + # distributions + from spyder.config.base import is_py2exe_or_cx_Freeze + if is_py2exe_or_cx_Freeze(): + from rope.base import project + class PatchedProject(project.Project): + def _default_config(self): + # py2exe/cx_Freeze distribution + from spyder.config.base import get_module_source_path + fname = get_module_source_path('spyder', + 'default_config.py') + return open(fname, 'rb').read() + project.Project = PatchedProject + + # Patching pycore.PyCore... + from rope.base import pycore + class PatchedPyCore(pycore.PyCore): + # [2] ...so that forced builtin modules (i.e. modules that were + # declared as 'extension_modules' in rope preferences) will be indeed + # recognized as builtins by rope, as expected + # + # This patch is included in rope 0.9.4+ but applying it anyway is ok + def get_module(self, name, folder=None): + """Returns a `PyObject` if the module was found.""" + # check if this is a builtin module + pymod = self._builtin_module(name) + if pymod is not None: + return pymod + module = self.find_module(name, folder) + if module is None: + raise pycore.ModuleNotFoundError( + 'Module %s not found' % name) + return self.resource_to_pyobject(module) + # [3] ...to avoid considering folders without __init__.py as Python + # packages + def _find_module_in_folder(self, folder, modname): + module = folder + packages = modname.split('.') + for pkg in packages[:-1]: + if module.is_folder() and module.has_child(pkg): + module = module.get_child(pkg) + else: + return None + if module.is_folder(): + if module.has_child(packages[-1]) and \ + module.get_child(packages[-1]).is_folder() and \ + module.get_child(packages[-1]).has_child('__init__.py'): + return module.get_child(packages[-1]) + elif module.has_child(packages[-1] + '.py') and \ + not module.get_child(packages[-1] + '.py').is_folder(): + return module.get_child(packages[-1] + '.py') + pycore.PyCore = PatchedPyCore + + # [2] Patching BuiltinName for the go to definition feature to simply work + # with forced builtins + from rope.base import builtins, libutils, pyobjects + import inspect + import os.path as osp + class PatchedBuiltinName(builtins.BuiltinName): + def _pycore(self): + p = self.pyobject + while p.parent is not None: + p = p.parent + if isinstance(p, builtins.BuiltinModule) and p.pycore is not None: + return p.pycore + def get_definition_location(self): + if not inspect.isbuiltin(self.pyobject): + _lines, lineno = inspect.getsourcelines(self.pyobject.builtin) + path = inspect.getfile(self.pyobject.builtin) + if path.endswith('pyc') and osp.isfile(path[:-1]): + path = path[:-1] + pycore = self._pycore() + if pycore and pycore.project: + resource = libutils.path_to_resource(pycore.project, path) + module = pyobjects.PyModule(pycore, None, resource) + return (module, lineno) + return (None, None) + builtins.BuiltinName = PatchedBuiltinName + + # [4] Patching several PyDocExtractor methods: + # 1. get_doc: + # To force rope to return the docstring of any object which has one, even + # if it's not an instance of AbstractFunction, AbstractClass, or + # AbstractModule. + # Also, to use utils.dochelpers.getdoc to get docs from forced builtins. + # + # 2. _get_class_docstring and _get_single_function_docstring: + # To not let rope add a 2 spaces indentation to every docstring, which was + # breaking our rich text mode. The only value that we are modifying is the + # 'indents' keyword of those methods, from 2 to 0. + # + # 3. get_calltip + # To easily get calltips of forced builtins + from rope.contrib import codeassist + from spyder_kernels.utils.dochelpers import getdoc + from rope.base import exceptions + class PatchedPyDocExtractor(codeassist.PyDocExtractor): + def get_builtin_doc(self, pyobject): + buitin = pyobject.builtin + return getdoc(buitin) + + def get_doc(self, pyobject): + if hasattr(pyobject, 'builtin'): + doc = self.get_builtin_doc(pyobject) + return doc + elif isinstance(pyobject, builtins.BuiltinModule): + docstring = pyobject.get_doc() + if docstring is not None: + docstring = self._trim_docstring(docstring) + else: + docstring = '' + # TODO: Add a module_name key, so that the name could appear + # on the OI text filed but not be used by sphinx to render + # the page + doc = {'name': '', + 'argspec': '', + 'note': '', + 'docstring': docstring + } + return doc + elif isinstance(pyobject, pyobjects.AbstractFunction): + return self._get_function_docstring(pyobject) + elif isinstance(pyobject, pyobjects.AbstractClass): + return self._get_class_docstring(pyobject) + elif isinstance(pyobject, pyobjects.AbstractModule): + return self._trim_docstring(pyobject.get_doc()) + elif pyobject.get_doc() is not None: # Spyder patch + return self._trim_docstring(pyobject.get_doc()) + return None + + def get_calltip(self, pyobject, ignore_unknown=False, remove_self=False): + if hasattr(pyobject, 'builtin'): + doc = self.get_builtin_doc(pyobject) + return doc['name'] + doc['argspec'] + try: + if isinstance(pyobject, pyobjects.AbstractClass): + pyobject = pyobject['__init__'].get_object() + if not isinstance(pyobject, pyobjects.AbstractFunction): + pyobject = pyobject['__call__'].get_object() + except exceptions.AttributeNotFoundError: + return None + if ignore_unknown and not isinstance(pyobject, pyobjects.PyFunction): + return + if isinstance(pyobject, pyobjects.AbstractFunction): + result = self._get_function_signature(pyobject, add_module=True) + if remove_self and self._is_method(pyobject): + return result.replace('(self)', '()').replace('(self, ', '(') + return result + + def _get_class_docstring(self, pyclass): + contents = self._trim_docstring(pyclass.get_doc(), indents=0) + supers = [super.get_name() for super in pyclass.get_superclasses()] + doc = 'class %s(%s):\n\n' % (pyclass.get_name(), ', '.join(supers)) + contents + + if '__init__' in pyclass: + init = pyclass['__init__'].get_object() + if isinstance(init, pyobjects.AbstractFunction): + doc += '\n\n' + self._get_single_function_docstring(init) + return doc + + def _get_single_function_docstring(self, pyfunction): + docs = pyfunction.get_doc() + docs = self._trim_docstring(docs, indents=0) + return docs + codeassist.PyDocExtractor = PatchedPyDocExtractor + + + # [5] Get the right matplotlib docstrings for Help + try: + import matplotlib as mpl + mpl.rcParams['docstring.hardcopy'] = True + except: + pass diff --git a/spyder/utils/misc.py b/spyder/utils/misc.py index 2bc6922d69a..681534b0eb1 100644 --- a/spyder/utils/misc.py +++ b/spyder/utils/misc.py @@ -1,292 +1,292 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Miscellaneous utilities""" - -import functools -import logging -import os -import os.path as osp -import re -import sys -import stat -import socket - -from spyder.py3compat import getcwd -from spyder.config.base import get_home_dir - - -logger = logging.getLogger(__name__) - - -def __remove_pyc_pyo(fname): - """Eventually remove .pyc and .pyo files associated to a Python script""" - if osp.splitext(fname)[1] == '.py': - for ending in ('c', 'o'): - if osp.exists(fname + ending): - os.remove(fname + ending) - - -def rename_file(source, dest): - """ - Rename file from *source* to *dest* - If file is a Python script, also rename .pyc and .pyo files if any - """ - os.rename(source, dest) - __remove_pyc_pyo(source) - - -def remove_file(fname): - """ - Remove file *fname* - If file is a Python script, also rename .pyc and .pyo files if any - """ - os.remove(fname) - __remove_pyc_pyo(fname) - - -def move_file(source, dest): - """ - Move file from *source* to *dest* - If file is a Python script, also rename .pyc and .pyo files if any - """ - import shutil - shutil.copy(source, dest) - remove_file(source) - - -def onerror(function, path, excinfo): - """Error handler for `shutil.rmtree`. - - If the error is due to an access error (read-only file), it - attempts to add write permission and then retries. - If the error is for another reason, it re-raises the error. - - Usage: `shutil.rmtree(path, onerror=onerror)""" - if not os.access(path, os.W_OK): - # Is the error an access error? - os.chmod(path, stat.S_IWUSR) - function(path) - else: - raise - - -def select_port(default_port=20128): - """Find and return a non used port""" - import socket - while True: - try: - sock = socket.socket(socket.AF_INET, - socket.SOCK_STREAM, - socket.IPPROTO_TCP) -# sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(("127.0.0.1", default_port)) - except socket.error as _msg: # analysis:ignore - default_port += 1 - else: - break - finally: - sock.close() - sock = None - return default_port - - -def count_lines(path, extensions=None, excluded_dirnames=None): - """Return number of source code lines for all filenames in subdirectories - of *path* with names ending with *extensions* - Directory names *excluded_dirnames* will be ignored""" - if extensions is None: - extensions = ['.py', '.pyw', '.ipy', '.enaml', '.c', '.h', '.cpp', - '.hpp', '.inc', '.', '.hh', '.hxx', '.cc', '.cxx', - '.cl', '.f', '.for', '.f77', '.f90', '.f95', '.f2k', - '.f03', '.f08'] - if excluded_dirnames is None: - excluded_dirnames = ['build', 'dist', '.hg', '.svn'] - - def get_filelines(path): - dfiles, dlines = 0, 0 - if osp.splitext(path)[1] in extensions: - dfiles = 1 - with open(path, 'rb') as textfile: - dlines = len(textfile.read().strip().splitlines()) - return dfiles, dlines - lines = 0 - files = 0 - if osp.isdir(path): - for dirpath, dirnames, filenames in os.walk(path): - for d in dirnames[:]: - if d in excluded_dirnames: - dirnames.remove(d) - if excluded_dirnames is None or \ - osp.dirname(dirpath) not in excluded_dirnames: - for fname in filenames: - dfiles, dlines = get_filelines(osp.join(dirpath, fname)) - files += dfiles - lines += dlines - else: - dfiles, dlines = get_filelines(path) - files += dfiles - lines += dlines - return files, lines - - -def remove_backslashes(path): - """Remove backslashes in *path* - - For Windows platforms only. - Returns the path unchanged on other platforms. - - This is especially useful when formatting path strings on - Windows platforms for which folder paths may contain backslashes - and provoke unicode decoding errors in Python 3 (or in Python 2 - when future 'unicode_literals' symbol has been imported).""" - if os.name == 'nt': - # Removing trailing single backslash - if path.endswith('\\') and not path.endswith('\\\\'): - path = path[:-1] - # Replacing backslashes by slashes - path = path.replace('\\', '/') - path = path.replace('/\'', '\\\'') - return path - - -def get_error_match(text): - """Return error match""" - import re - return re.match(r' File "(.*)", line (\d*)', text) - - -def get_python_executable(): - """Return path to Spyder Python executable""" - executable = sys.executable.replace("pythonw.exe", "python.exe") - if executable.endswith("spyder.exe"): - # py2exe distribution - executable = "python.exe" - return executable - - -def monkeypatch_method(cls, patch_name): - # This function's code was inspired from the following thread: - # "[Python-Dev] Monkeypatching idioms -- elegant or ugly?" - # by Robert Brewer - # (Tue Jan 15 19:13:25 CET 2008) - """ - Add the decorated method to the given class; replace as needed. - - If the named method already exists on the given class, it will - be replaced, and a reference to the old method is created as - cls._old. If the "_old__" attribute - already exists, KeyError is raised. - """ - def decorator(func): - fname = func.__name__ - old_func = getattr(cls, fname, None) - if old_func is not None: - # Add the old func to a list of old funcs. - old_ref = "_old_%s_%s" % (patch_name, fname) - - old_attr = getattr(cls, old_ref, None) - if old_attr is None: - setattr(cls, old_ref, old_func) - else: - raise KeyError("%s.%s already exists." - % (cls.__name__, old_ref)) - setattr(cls, fname, func) - return func - return decorator - - -def is_python_script(fname): - """Is it a valid Python script?""" - return osp.isfile(fname) and fname.endswith(('.py', '.pyw', '.ipy')) - - -def abspardir(path): - """Return absolute parent dir""" - return osp.abspath(osp.join(path, os.pardir)) - - -def get_common_path(pathlist): - """Return common path for all paths in pathlist""" - common = osp.normpath(osp.commonprefix(pathlist)) - if len(common) > 1: - if not osp.isdir(common): - return abspardir(common) - else: - for path in pathlist: - if not osp.isdir(osp.join(common, path[len(common) + 1:])): - # `common` is not the real common prefix - return abspardir(common) - else: - return osp.abspath(common) - - -def memoize(obj): - """ - Memoize objects to trade memory for execution speed - - Use a limited size cache to store the value, which takes into account - The calling args and kwargs - - See https://wiki.python.org/moin/PythonDecoratorLibrary#Memoize - """ - cache = obj.cache = {} - - @functools.wraps(obj) - def memoizer(*args, **kwargs): - key = str(args) + str(kwargs) - if key not in cache: - cache[key] = obj(*args, **kwargs) - # only keep the most recent 100 entries - if len(cache) > 100: - cache.popitem(last=False) - return cache[key] - return memoizer - - -def getcwd_or_home(): - """Safe version of getcwd that will fallback to home user dir. - - This will catch the error raised when the current working directory - was removed for an external program. - """ - try: - return getcwd() - except OSError: - logger.debug("WARNING: Current working directory was deleted, " - "falling back to home dirertory") - return get_home_dir() - - -def regexp_error_msg(pattern): - """ - Return None if the pattern is a valid regular expression or - a string describing why the pattern is invalid. - """ - try: - re.compile(pattern) - except re.error as e: - return str(e) - return None - - -def check_connection_port(address, port): - """Verify if `port` is available in `address`.""" - # Create a TCP socket - s = socket.socket() - s.settimeout(2) - logger.debug("Attempting to connect to {} on port {}".format( - address, port)) - try: - s.connect((address, port)) - logger.debug("Connected to {} on port {}".format(address, port)) - return True - except socket.error as e: - logger.debug("Connection to {} on port {} failed: {}".format( - address, port, e)) - return False - finally: - s.close() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Miscellaneous utilities""" + +import functools +import logging +import os +import os.path as osp +import re +import sys +import stat +import socket + +from spyder.py3compat import getcwd +from spyder.config.base import get_home_dir + + +logger = logging.getLogger(__name__) + + +def __remove_pyc_pyo(fname): + """Eventually remove .pyc and .pyo files associated to a Python script""" + if osp.splitext(fname)[1] == '.py': + for ending in ('c', 'o'): + if osp.exists(fname + ending): + os.remove(fname + ending) + + +def rename_file(source, dest): + """ + Rename file from *source* to *dest* + If file is a Python script, also rename .pyc and .pyo files if any + """ + os.rename(source, dest) + __remove_pyc_pyo(source) + + +def remove_file(fname): + """ + Remove file *fname* + If file is a Python script, also rename .pyc and .pyo files if any + """ + os.remove(fname) + __remove_pyc_pyo(fname) + + +def move_file(source, dest): + """ + Move file from *source* to *dest* + If file is a Python script, also rename .pyc and .pyo files if any + """ + import shutil + shutil.copy(source, dest) + remove_file(source) + + +def onerror(function, path, excinfo): + """Error handler for `shutil.rmtree`. + + If the error is due to an access error (read-only file), it + attempts to add write permission and then retries. + If the error is for another reason, it re-raises the error. + + Usage: `shutil.rmtree(path, onerror=onerror)""" + if not os.access(path, os.W_OK): + # Is the error an access error? + os.chmod(path, stat.S_IWUSR) + function(path) + else: + raise + + +def select_port(default_port=20128): + """Find and return a non used port""" + import socket + while True: + try: + sock = socket.socket(socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP) +# sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", default_port)) + except socket.error as _msg: # analysis:ignore + default_port += 1 + else: + break + finally: + sock.close() + sock = None + return default_port + + +def count_lines(path, extensions=None, excluded_dirnames=None): + """Return number of source code lines for all filenames in subdirectories + of *path* with names ending with *extensions* + Directory names *excluded_dirnames* will be ignored""" + if extensions is None: + extensions = ['.py', '.pyw', '.ipy', '.enaml', '.c', '.h', '.cpp', + '.hpp', '.inc', '.', '.hh', '.hxx', '.cc', '.cxx', + '.cl', '.f', '.for', '.f77', '.f90', '.f95', '.f2k', + '.f03', '.f08'] + if excluded_dirnames is None: + excluded_dirnames = ['build', 'dist', '.hg', '.svn'] + + def get_filelines(path): + dfiles, dlines = 0, 0 + if osp.splitext(path)[1] in extensions: + dfiles = 1 + with open(path, 'rb') as textfile: + dlines = len(textfile.read().strip().splitlines()) + return dfiles, dlines + lines = 0 + files = 0 + if osp.isdir(path): + for dirpath, dirnames, filenames in os.walk(path): + for d in dirnames[:]: + if d in excluded_dirnames: + dirnames.remove(d) + if excluded_dirnames is None or \ + osp.dirname(dirpath) not in excluded_dirnames: + for fname in filenames: + dfiles, dlines = get_filelines(osp.join(dirpath, fname)) + files += dfiles + lines += dlines + else: + dfiles, dlines = get_filelines(path) + files += dfiles + lines += dlines + return files, lines + + +def remove_backslashes(path): + """Remove backslashes in *path* + + For Windows platforms only. + Returns the path unchanged on other platforms. + + This is especially useful when formatting path strings on + Windows platforms for which folder paths may contain backslashes + and provoke unicode decoding errors in Python 3 (or in Python 2 + when future 'unicode_literals' symbol has been imported).""" + if os.name == 'nt': + # Removing trailing single backslash + if path.endswith('\\') and not path.endswith('\\\\'): + path = path[:-1] + # Replacing backslashes by slashes + path = path.replace('\\', '/') + path = path.replace('/\'', '\\\'') + return path + + +def get_error_match(text): + """Return error match""" + import re + return re.match(r' File "(.*)", line (\d*)', text) + + +def get_python_executable(): + """Return path to Spyder Python executable""" + executable = sys.executable.replace("pythonw.exe", "python.exe") + if executable.endswith("spyder.exe"): + # py2exe distribution + executable = "python.exe" + return executable + + +def monkeypatch_method(cls, patch_name): + # This function's code was inspired from the following thread: + # "[Python-Dev] Monkeypatching idioms -- elegant or ugly?" + # by Robert Brewer + # (Tue Jan 15 19:13:25 CET 2008) + """ + Add the decorated method to the given class; replace as needed. + + If the named method already exists on the given class, it will + be replaced, and a reference to the old method is created as + cls._old. If the "_old__" attribute + already exists, KeyError is raised. + """ + def decorator(func): + fname = func.__name__ + old_func = getattr(cls, fname, None) + if old_func is not None: + # Add the old func to a list of old funcs. + old_ref = "_old_%s_%s" % (patch_name, fname) + + old_attr = getattr(cls, old_ref, None) + if old_attr is None: + setattr(cls, old_ref, old_func) + else: + raise KeyError("%s.%s already exists." + % (cls.__name__, old_ref)) + setattr(cls, fname, func) + return func + return decorator + + +def is_python_script(fname): + """Is it a valid Python script?""" + return osp.isfile(fname) and fname.endswith(('.py', '.pyw', '.ipy')) + + +def abspardir(path): + """Return absolute parent dir""" + return osp.abspath(osp.join(path, os.pardir)) + + +def get_common_path(pathlist): + """Return common path for all paths in pathlist""" + common = osp.normpath(osp.commonprefix(pathlist)) + if len(common) > 1: + if not osp.isdir(common): + return abspardir(common) + else: + for path in pathlist: + if not osp.isdir(osp.join(common, path[len(common) + 1:])): + # `common` is not the real common prefix + return abspardir(common) + else: + return osp.abspath(common) + + +def memoize(obj): + """ + Memoize objects to trade memory for execution speed + + Use a limited size cache to store the value, which takes into account + The calling args and kwargs + + See https://wiki.python.org/moin/PythonDecoratorLibrary#Memoize + """ + cache = obj.cache = {} + + @functools.wraps(obj) + def memoizer(*args, **kwargs): + key = str(args) + str(kwargs) + if key not in cache: + cache[key] = obj(*args, **kwargs) + # only keep the most recent 100 entries + if len(cache) > 100: + cache.popitem(last=False) + return cache[key] + return memoizer + + +def getcwd_or_home(): + """Safe version of getcwd that will fallback to home user dir. + + This will catch the error raised when the current working directory + was removed for an external program. + """ + try: + return getcwd() + except OSError: + logger.debug("WARNING: Current working directory was deleted, " + "falling back to home dirertory") + return get_home_dir() + + +def regexp_error_msg(pattern): + """ + Return None if the pattern is a valid regular expression or + a string describing why the pattern is invalid. + """ + try: + re.compile(pattern) + except re.error as e: + return str(e) + return None + + +def check_connection_port(address, port): + """Verify if `port` is available in `address`.""" + # Create a TCP socket + s = socket.socket() + s.settimeout(2) + logger.debug("Attempting to connect to {} on port {}".format( + address, port)) + try: + s.connect((address, port)) + logger.debug("Connected to {} on port {}".format(address, port)) + return True + except socket.error as e: + logger.debug("Connection to {} on port {} failed: {}".format( + address, port, e)) + return False + finally: + s.close() diff --git a/spyder/utils/programs.py b/spyder/utils/programs.py index 27e52fe8108..3b96cb8a851 100644 --- a/spyder/utils/programs.py +++ b/spyder/utils/programs.py @@ -1,1069 +1,1069 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Running programs utilities.""" - -from __future__ import print_function - -# Standard library imports -from ast import literal_eval -from getpass import getuser -from textwrap import dedent -import glob -import importlib -import itertools -import os -import os.path as osp -import re -import subprocess -import sys -import tempfile -import threading -import time - -# Third party imports -import pkg_resources -from pkg_resources import parse_version -import psutil - -# Local imports -from spyder.config.base import (running_under_pytest, get_home_dir, - running_in_mac_app) -from spyder.py3compat import is_text_string, to_text_string -from spyder.utils import encoding -from spyder.utils.misc import get_python_executable - -HERE = osp.abspath(osp.dirname(__file__)) - - -class ProgramError(Exception): - pass - - -def get_temp_dir(suffix=None): - """ - Return temporary Spyder directory, checking previously that it exists. - """ - to_join = [tempfile.gettempdir()] - - if os.name == 'nt': - to_join.append('spyder') - else: - username = encoding.to_unicode_from_fs(getuser()) - to_join.append('spyder-' + username) - - tempdir = osp.join(*to_join) - - if not osp.isdir(tempdir): - os.mkdir(tempdir) - - if suffix is not None: - to_join.append(suffix) - - tempdir = osp.join(*to_join) - - if not osp.isdir(tempdir): - os.mkdir(tempdir) - - return tempdir - - -def is_program_installed(basename): - """ - Return program absolute path if installed in PATH. - Otherwise, return None. - - Also searches specific platform dependent paths that are not already in - PATH. This permits general use without assuming user profiles are - sourced (e.g. .bash_Profile), such as when login shells are not used to - launch Spyder. - - On macOS systems, a .app is considered installed if it exists. - """ - home = get_home_dir() - req_paths = [] - if sys.platform == 'darwin': - if basename.endswith('.app') and osp.exists(basename): - return basename - - pyenv = [ - osp.join('/usr', 'local', 'bin'), - osp.join(home, '.pyenv', 'bin') - ] - - # Prioritize Anaconda before Miniconda; local before global. - a = [osp.join(home, 'opt'), '/opt'] - b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] - conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] - - req_paths.extend(pyenv + conda) - - elif sys.platform.startswith('linux'): - pyenv = [ - osp.join('/usr', 'local', 'bin'), - osp.join(home, '.pyenv', 'bin') - ] - - a = [home, '/opt'] - b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] - conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] - - req_paths.extend(pyenv + conda) - - elif os.name == 'nt': - pyenv = [osp.join(home, '.pyenv', 'pyenv-win', 'bin')] - - a = [home, 'C:\\', osp.join('C:\\', 'ProgramData')] - b = ['Anaconda', 'Miniconda', 'Anaconda3', 'Miniconda3'] - conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] - - req_paths.extend(pyenv + conda) - - for path in os.environ['PATH'].split(os.pathsep) + req_paths: - abspath = osp.join(path, basename) - if osp.isfile(abspath): - return abspath - - -def find_program(basename): - """ - Find program in PATH and return absolute path - - Try adding .exe or .bat to basename on Windows platforms - (return None if not found) - """ - names = [basename] - if os.name == 'nt': - # Windows platforms - extensions = ('.exe', '.bat', '.cmd') - if not basename.endswith(extensions): - names = [basename+ext for ext in extensions]+[basename] - for name in names: - path = is_program_installed(name) - if path: - return path - - -def get_full_command_for_program(path): - """ - Return the list of tokens necessary to open the program - at a given path. - - On macOS systems, this function prefixes .app paths with - 'open -a', which is necessary to run the application. - - On all other OS's, this function has no effect. - - :str path: The path of the program to run. - :return: The list of tokens necessary to run the program. - """ - if sys.platform == 'darwin' and path.endswith('.app'): - return ['open', '-a', path] - return [path] - - -def alter_subprocess_kwargs_by_platform(**kwargs): - """ - Given a dict, populate kwargs to create a generally - useful default setup for running subprocess processes - on different platforms. For example, `close_fds` is - set on posix and creation of a new console window is - disabled on Windows. - - This function will alter the given kwargs and return - the modified dict. - """ - kwargs.setdefault('close_fds', os.name == 'posix') - if os.name == 'nt': - CONSOLE_CREATION_FLAGS = 0 # Default value - # See: https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863%28v=vs.85%29.aspx - CREATE_NO_WINDOW = 0x08000000 - # We "or" them together - CONSOLE_CREATION_FLAGS |= CREATE_NO_WINDOW - kwargs.setdefault('creationflags', CONSOLE_CREATION_FLAGS) - - # ensure Windows subprocess environment has SYSTEMROOT - if kwargs.get('env') is not None: - # Is SYSTEMROOT, SYSTEMDRIVE in env? case insensitive - for env_var in ['SYSTEMROOT', 'SYSTEMDRIVE']: - if env_var not in map(str.upper, kwargs['env'].keys()): - # Add from os.environ - for k, v in os.environ.items(): - if env_var == k.upper(): - kwargs['env'].update({k: v}) - break # don't risk multiple values - else: - # linux and macOS - if kwargs.get('env') is not None: - if 'HOME' not in kwargs['env']: - kwargs['env'].update({'HOME': get_home_dir()}) - - return kwargs - - -def run_shell_command(cmdstr, **subprocess_kwargs): - """ - Execute the given shell command. - - Note that *args and **kwargs will be passed to the subprocess call. - - If 'shell' is given in subprocess_kwargs it must be True, - otherwise ProgramError will be raised. - . - If 'executable' is not given in subprocess_kwargs, it will - be set to the value of the SHELL environment variable. - - Note that stdin, stdout and stderr will be set by default - to PIPE unless specified in subprocess_kwargs. - - :str cmdstr: The string run as a shell command. - :subprocess_kwargs: These will be passed to subprocess.Popen. - """ - if 'shell' in subprocess_kwargs and not subprocess_kwargs['shell']: - raise ProgramError( - 'The "shell" kwarg may be omitted, but if ' - 'provided it must be True.') - else: - subprocess_kwargs['shell'] = True - - # Don't pass SHELL to subprocess on Windows because it makes this - # fumction fail in Git Bash (where SHELL is declared; other Windows - # shells don't set it). - if not os.name == 'nt': - if 'executable' not in subprocess_kwargs: - subprocess_kwargs['executable'] = os.getenv('SHELL') - - for stream in ['stdin', 'stdout', 'stderr']: - subprocess_kwargs.setdefault(stream, subprocess.PIPE) - subprocess_kwargs = alter_subprocess_kwargs_by_platform( - **subprocess_kwargs) - return subprocess.Popen(cmdstr, **subprocess_kwargs) - - -def run_program(program, args=None, **subprocess_kwargs): - """ - Run program in a separate process. - - NOTE: returns the process object created by - `subprocess.Popen()`. This can be used with - `proc.communicate()` for example. - - If 'shell' appears in the kwargs, it must be False, - otherwise ProgramError will be raised. - - If only the program name is given and not the full path, - a lookup will be performed to find the program. If the - lookup fails, ProgramError will be raised. - - Note that stdin, stdout and stderr will be set by default - to PIPE unless specified in subprocess_kwargs. - - :str program: The name of the program to run. - :list args: The program arguments. - :subprocess_kwargs: These will be passed to subprocess.Popen. - """ - if 'shell' in subprocess_kwargs and subprocess_kwargs['shell']: - raise ProgramError( - "This function is only for non-shell programs, " - "use run_shell_command() instead.") - fullcmd = find_program(program) - if not fullcmd: - raise ProgramError("Program %s was not found" % program) - # As per subprocess, we make a complete list of prog+args - fullcmd = get_full_command_for_program(fullcmd) + (args or []) - for stream in ['stdin', 'stdout', 'stderr']: - subprocess_kwargs.setdefault(stream, subprocess.PIPE) - subprocess_kwargs = alter_subprocess_kwargs_by_platform( - **subprocess_kwargs) - return subprocess.Popen(fullcmd, **subprocess_kwargs) - - -def parse_linux_desktop_entry(fpath): - """Load data from desktop entry with xdg specification.""" - from xdg.DesktopEntry import DesktopEntry - - try: - entry = DesktopEntry(fpath) - entry_data = {} - entry_data['name'] = entry.getName() - entry_data['icon_path'] = entry.getIcon() - entry_data['exec'] = entry.getExec() - entry_data['type'] = entry.getType() - entry_data['hidden'] = entry.getHidden() - entry_data['fpath'] = fpath - except Exception: - entry_data = { - 'name': '', - 'icon_path': '', - 'hidden': '', - 'exec': '', - 'type': '', - 'fpath': fpath - } - - return entry_data - - -def _get_mac_application_icon_path(app_bundle_path): - """Parse mac application bundle and return path for *.icns file.""" - import plistlib - contents_path = info_path = os.path.join(app_bundle_path, 'Contents') - info_path = os.path.join(contents_path, 'Info.plist') - - pl = {} - if os.path.isfile(info_path): - try: - # readPlist is deprecated but needed for py27 compat - pl = plistlib.readPlist(info_path) - except Exception: - pass - - icon_file = pl.get('CFBundleIconFile') - icon_path = None - if icon_file: - icon_path = os.path.join(contents_path, 'Resources', icon_file) - - # Some app bundles seem to list the icon name without extension - if not icon_path.endswith('.icns'): - icon_path = icon_path + '.icns' - - if not os.path.isfile(icon_path): - icon_path = None - - return icon_path - - -def get_username(): - """Return current session username.""" - if os.name == 'nt': - username = os.getlogin() - else: - import pwd - username = pwd.getpwuid(os.getuid())[0] - - return username - - -def _get_win_reg_info(key_path, hive, flag, subkeys): - """ - See: https://stackoverflow.com/q/53132434 - """ - import winreg - - reg = winreg.ConnectRegistry(None, hive) - software_list = [] - try: - key = winreg.OpenKey(reg, key_path, 0, winreg.KEY_READ | flag) - count_subkey = winreg.QueryInfoKey(key)[0] - - for index in range(count_subkey): - software = {} - try: - subkey_name = winreg.EnumKey(key, index) - if not (subkey_name.startswith('{') - and subkey_name.endswith('}')): - software['key'] = subkey_name - subkey = winreg.OpenKey(key, subkey_name) - for property in subkeys: - try: - value = winreg.QueryValueEx(subkey, property)[0] - software[property] = value - except EnvironmentError: - software[property] = '' - software_list.append(software) - except EnvironmentError: - continue - except Exception: - pass - - return software_list - - -def _clean_win_application_path(path): - """Normalize windows path and remove extra quotes.""" - path = path.replace('\\', '/').lower() - # Check for quotes at start and end - if path[0] == '"' and path[-1] == '"': - path = literal_eval(path) - return path - - -def _get_win_applications(): - """Return all system installed windows applications.""" - import winreg - - # See: - # https://docs.microsoft.com/en-us/windows/desktop/shell/app-registration - key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths' - - # Hive and flags - hfs = [ - (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY), - (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY), - (winreg.HKEY_CURRENT_USER, 0), - ] - subkeys = [None] - sort_key = 'key' - app_paths = {} - _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] - software_list = itertools.chain(*_apps) - for software in sorted(software_list, key=lambda x: x[sort_key]): - if software[None]: - key = software['key'].capitalize().replace('.exe', '') - expanded_fpath = os.path.expandvars(software[None]) - expanded_fpath = _clean_win_application_path(expanded_fpath) - app_paths[key] = expanded_fpath - - # See: - # https://www.blog.pythonlibrary.org/2010/03/03/finding-installed-software-using-python/ - # https://stackoverflow.com/q/53132434 - key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' - subkeys = ['DisplayName', 'InstallLocation', 'DisplayIcon'] - sort_key = 'DisplayName' - apps = {} - _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] - software_list = itertools.chain(*_apps) - for software in sorted(software_list, key=lambda x: x[sort_key]): - location = software['InstallLocation'] - name = software['DisplayName'] - icon = software['DisplayIcon'] - key = software['key'] - if name and icon: - icon = icon.replace('"', '') - icon = icon.split(',')[0] - - if location == '' and icon: - location = os.path.dirname(icon) - - if not os.path.isfile(icon): - icon = '' - - if location and os.path.isdir(location): - files = [f for f in os.listdir(location) - if os.path.isfile(os.path.join(location, f))] - if files: - for fname in files: - fn_low = fname.lower() - valid_file = fn_low.endswith(('.exe', '.com', '.bat')) - if valid_file and not fn_low.startswith('unins'): - fpath = os.path.join(location, fname) - expanded_fpath = os.path.expandvars(fpath) - expanded_fpath = _clean_win_application_path( - expanded_fpath) - apps[name + ' (' + fname + ')'] = expanded_fpath - # Join data - values = list(zip(*apps.values()))[-1] - for name, fpath in app_paths.items(): - if fpath not in values: - apps[name] = fpath - - return apps - - -def _get_linux_applications(): - """Return all system installed linux applications.""" - # See: - # https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html - # https://askubuntu.com/q/433609 - apps = {} - desktop_app_paths = [ - '/usr/share/**/*.desktop', - '~/.local/share/**/*.desktop', - ] - all_entries_data = [] - for path in desktop_app_paths: - fpaths = glob.glob(path) - for fpath in fpaths: - entry_data = parse_linux_desktop_entry(fpath) - all_entries_data.append(entry_data) - - for entry_data in sorted(all_entries_data, key=lambda x: x['name']): - if not entry_data['hidden'] and entry_data['type'] == 'Application': - apps[entry_data['name']] = entry_data['fpath'] - - return apps - - -def _get_mac_applications(): - """Return all system installed osx applications.""" - apps = {} - app_folders = [ - '/**/*.app', - '/Users/{}/**/*.app'.format(get_username()) - ] - - fpaths = [] - for path in app_folders: - fpaths += glob.glob(path) - - for fpath in fpaths: - if os.path.isdir(fpath): - name = os.path.basename(fpath).split('.app')[0] - apps[name] = fpath - - return apps - - -def get_application_icon(fpath): - """Return application icon or default icon if not found.""" - from qtpy.QtGui import QIcon - from spyder.utils.icon_manager import ima - - if os.path.isfile(fpath) or os.path.isdir(fpath): - icon = ima.icon('no_match') - if sys.platform == 'darwin': - icon_path = _get_mac_application_icon_path(fpath) - if icon_path and os.path.isfile(icon_path): - icon = QIcon(icon_path) - elif os.name == 'nt': - pass - else: - entry_data = parse_linux_desktop_entry(fpath) - icon_path = entry_data['icon_path'] - if icon_path: - if os.path.isfile(icon_path): - icon = QIcon(icon_path) - else: - icon = QIcon.fromTheme(icon_path) - else: - icon = ima.icon('help') - - return icon - - -def get_installed_applications(): - """ - Return all system installed applications. - - The return value is a list of tuples where the first item is the icon path - and the second item is the program executable path. - """ - apps = {} - if sys.platform == 'darwin': - apps = _get_mac_applications() - elif os.name == 'nt': - apps = _get_win_applications() - else: - apps = _get_linux_applications() - - if sys.platform == 'darwin': - apps = {key: val for (key, val) in apps.items() if osp.isdir(val)} - else: - apps = {key: val for (key, val) in apps.items() if osp.isfile(val)} - - return apps - - -def open_files_with_application(app_path, fnames): - """ - Generalized method for opening files with a specific application. - - Returns a dictionary of the command used and the return code. - A code equal to 0 means the application executed successfully. - """ - return_codes = {} - - if os.name == 'nt': - fnames = [fname.replace('\\', '/') for fname in fnames] - - if sys.platform == 'darwin': - if not (app_path.endswith('.app') and os.path.isdir(app_path)): - raise ValueError('`app_path` must point to a valid OSX ' - 'application!') - cmd = ['open', '-a', app_path] + fnames - try: - return_code = subprocess.call(cmd) - except Exception: - return_code = 1 - return_codes[' '.join(cmd)] = return_code - elif os.name == 'nt': - if not (app_path.endswith(('.exe', '.bat', '.com', '.cmd')) - and os.path.isfile(app_path)): - raise ValueError('`app_path` must point to a valid Windows ' - 'executable!') - cmd = [app_path] + fnames - try: - return_code = subprocess.call(cmd) - except OSError: - return_code = 1 - return_codes[' '.join(cmd)] = return_code - else: - if not (app_path.endswith('.desktop') and os.path.isfile(app_path)): - raise ValueError('`app_path` must point to a valid Linux ' - 'application!') - - entry = parse_linux_desktop_entry(app_path) - app_path = entry['exec'] - multi = [] - extra = [] - if len(fnames) == 1: - fname = fnames[0] - if '%u' in app_path: - cmd = app_path.replace('%u', fname) - elif '%f' in app_path: - cmd = app_path.replace('%f', fname) - elif '%U' in app_path: - cmd = app_path.replace('%U', fname) - elif '%F' in app_path: - cmd = app_path.replace('%F', fname) - else: - cmd = app_path - extra = fnames - elif len(fnames) > 1: - if '%U' in app_path: - cmd = app_path.replace('%U', ' '.join(fnames)) - elif '%F' in app_path: - cmd = app_path.replace('%F', ' '.join(fnames)) - if '%u' in app_path: - for fname in fnames: - multi.append(app_path.replace('%u', fname)) - elif '%f' in app_path: - for fname in fnames: - multi.append(app_path.replace('%f', fname)) - else: - cmd = app_path - extra = fnames - - if multi: - for cmd in multi: - try: - return_code = subprocess.call([cmd], shell=True) - except Exception: - return_code = 1 - return_codes[cmd] = return_code - else: - try: - return_code = subprocess.call([cmd] + extra, shell=True) - except Exception: - return_code = 1 - return_codes[cmd] = return_code - - return return_codes - - -def python_script_exists(package=None, module=None): - """ - Return absolute path if Python script exists (otherwise, return None) - package=None -> module is in sys.path (standard library modules) - """ - assert module is not None - if package is None: - spec = importlib.util.find_spec(module) - if spec: - path = spec.origin - else: - path = None - else: - spec = importlib.util.find_spec(package) - if spec: - path = osp.join(spec.origin, module)+'.py' - else: - path = None - if path: - if not osp.isfile(path): - path += 'w' - if osp.isfile(path): - return path - - -def run_python_script(package=None, module=None, args=[], p_args=[]): - """ - Run Python script in a separate process - package=None -> module is in sys.path (standard library modules) - """ - assert module is not None - assert isinstance(args, (tuple, list)) and isinstance(p_args, (tuple, list)) - path = python_script_exists(package, module) - run_program(sys.executable, p_args + [path] + args) - - -def shell_split(text): - """ - Split the string `text` using shell-like syntax - - This avoids breaking single/double-quoted strings (e.g. containing - strings with spaces). This function is almost equivalent to the shlex.split - function (see standard library `shlex`) except that it is supporting - unicode strings (shlex does not support unicode until Python 2.7.3). - """ - assert is_text_string(text) # in case a QString is passed... - pattern = r'(\s+|(?': - return parse_version(actver) > parse_version(version) - elif cmp_op == '>=': - return parse_version(actver) >= parse_version(version) - elif cmp_op == '=': - return parse_version(actver) == parse_version(version) - elif cmp_op == '<': - return parse_version(actver) < parse_version(version) - elif cmp_op == '<=': - return parse_version(actver) <= parse_version(version) - else: - return False - except TypeError: - return True - - -def get_module_version(module_name): - """Return module version or None if version can't be retrieved.""" - mod = __import__(module_name) - ver = getattr(mod, '__version__', getattr(mod, 'VERSION', None)) - if not ver: - ver = get_package_version(module_name) - return ver - - -def get_package_version(package_name): - """Return package version or None if version can't be retrieved.""" - - # When support for Python 3.7 and below is dropped, this can be replaced - # with the built-in importlib.metadata.version - try: - ver = pkg_resources.get_distribution(package_name).version - return ver - except pkg_resources.DistributionNotFound: - return None - - -def is_module_installed(module_name, version=None, interpreter=None, - distribution_name=None): - """ - Return True if module ``module_name`` is installed - - If ``version`` is not None, checks that the module's installed version is - consistent with ``version``. The module must have an attribute named - '__version__' or 'VERSION'. - - version may start with =, >=, > or < to specify the exact requirement ; - multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0') - - If ``interpreter`` is not None, checks if a module is installed with a - given ``version`` in the ``interpreter``'s environment. Otherwise checks - in Spyder's environment. - - ``distribution_name`` is the distribution name of a package. For instance, - for pylsp_black that name is python_lsp_black. - """ - if interpreter is not None: - if is_python_interpreter(interpreter): - cmd = dedent(""" - try: - import {} as mod - except Exception: - print('No Module') # spyder: test-skip - print(getattr(mod, '__version__', getattr(mod, 'VERSION', None))) # spyder: test-skip - """).format(module_name) - try: - # use clean environment - proc = run_program(interpreter, ['-c', cmd], env={}) - stdout, stderr = proc.communicate() - stdout = stdout.decode().strip() - except Exception: - return False - - if 'No Module' in stdout: - return False - elif stdout != 'None': - # the module is installed and it has a version attribute - module_version = stdout - else: - module_version = None - else: - # Try to not take a wrong decision if interpreter check fails - return True - else: - # interpreter is None, just get module version in Spyder environment - try: - module_version = get_module_version(module_name) - except Exception: - # Module is not installed - return False - - # This can happen if a package was not uninstalled correctly. For - # instance, if it's __pycache__ main directory is left behind. - try: - mod = __import__(module_name) - if not getattr(mod, '__file__', None): - return False - except Exception: - pass - - # Try to get the module version from its distribution name. For - # instance, pylsp_black doesn't have a version but that can be - # obtained from its distribution, called python_lsp_black. - if not module_version and distribution_name: - module_version = get_package_version(distribution_name) - - if version is None: - return True - else: - if ';' in version: - versions = version.split(';') - else: - versions = [version] - - output = True - for _ver in versions: - match = re.search(r'[0-9]', _ver) - assert match is not None, "Invalid version number" - symb = _ver[:match.start()] - if not symb: - symb = '=' - assert symb in ('>=', '>', '=', '<', '<='),\ - "Invalid version condition '%s'" % symb - ver = _ver[match.start():] - output = output and check_version(module_version, ver, symb) - return output - - -def is_python_interpreter_valid_name(filename): - """Check that the python interpreter file has a valid name.""" - pattern = r'.*python(\d\.?\d*)?(w)?(.exe)?$' - if re.match(pattern, filename, flags=re.I) is None: - return False - else: - return True - - -def is_python_interpreter(filename): - """Evaluate whether a file is a python interpreter or not.""" - # Must be imported here to avoid circular import - from spyder.utils.conda import is_conda_env - - real_filename = os.path.realpath(filename) # To follow symlink if existent - - if (not osp.isfile(real_filename) or - not is_python_interpreter_valid_name(real_filename)): - return False - - # File exists and has valid name - is_text_file = encoding.is_text_file(real_filename) - - if is_pythonw(real_filename): - if os.name == 'nt': - # pythonw is a binary on Windows - if not is_text_file: - return True - else: - return False - elif sys.platform == 'darwin': - # pythonw is a text file in Anaconda but a binary in - # the system - if is_conda_env(pyexec=real_filename) and is_text_file: - return True - elif not is_text_file: - return True - else: - return False - else: - # There's no pythonw in other systems - return False - elif is_text_file: - # At this point we can't have a text file - return False - else: - return check_python_help(real_filename) - - -def is_pythonw(filename): - """Check that the python interpreter has 'pythonw'.""" - pattern = r'.*python(\d\.?\d*)?w(.exe)?$' - if re.match(pattern, filename, flags=re.I) is None: - return False - else: - return True - - -def check_python_help(filename): - """Check that the python interpreter can compile and provide the zen.""" - try: - proc = run_program(filename, ['-c', 'import this'], env={}) - stdout, _ = proc.communicate() - stdout = to_text_string(stdout) - valid_lines = [ - 'Beautiful is better than ugly.', - 'Explicit is better than implicit.', - 'Simple is better than complex.', - 'Complex is better than complicated.', - ] - if all(line in stdout for line in valid_lines): - return True - else: - return False - except Exception: - return False - - -def is_spyder_process(pid): - """ - Test whether given PID belongs to a Spyder process. - - This is checked by testing the first three command line arguments. This - function returns a bool. If there is no process with this PID or its - command line cannot be accessed (perhaps because the process is owned by - another user), then the function returns False. - """ - try: - p = psutil.Process(int(pid)) - - # Valid names for main script - names = set(['spyder', 'spyder3', 'spyder.exe', 'spyder3.exe', - 'bootstrap.py', 'spyder-script.py', 'Spyder.launch.pyw']) - if running_under_pytest(): - names.add('runtests.py') - - # Check the first three command line arguments - arguments = set(os.path.basename(arg) for arg in p.cmdline()[:3]) - conditions = [names & arguments] - return any(conditions) - except (psutil.NoSuchProcess, psutil.AccessDenied): - return False - - -def get_interpreter_info(path): - """Return version information of the selected Python interpreter.""" - try: - out, __ = run_program(path, ['-V']).communicate() - out = out.decode() - except Exception: - out = '' - return out.strip() - - -def find_git(): - """Find git executable in the system.""" - if sys.platform == 'darwin': - proc = subprocess.run( - osp.join(HERE, "check-git.sh"), capture_output=True) - if proc.returncode != 0: - return None - return find_program('git') - else: - return find_program('git') +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Running programs utilities.""" + +from __future__ import print_function + +# Standard library imports +from ast import literal_eval +from getpass import getuser +from textwrap import dedent +import glob +import importlib +import itertools +import os +import os.path as osp +import re +import subprocess +import sys +import tempfile +import threading +import time + +# Third party imports +import pkg_resources +from pkg_resources import parse_version +import psutil + +# Local imports +from spyder.config.base import (running_under_pytest, get_home_dir, + running_in_mac_app) +from spyder.py3compat import is_text_string, to_text_string +from spyder.utils import encoding +from spyder.utils.misc import get_python_executable + +HERE = osp.abspath(osp.dirname(__file__)) + + +class ProgramError(Exception): + pass + + +def get_temp_dir(suffix=None): + """ + Return temporary Spyder directory, checking previously that it exists. + """ + to_join = [tempfile.gettempdir()] + + if os.name == 'nt': + to_join.append('spyder') + else: + username = encoding.to_unicode_from_fs(getuser()) + to_join.append('spyder-' + username) + + tempdir = osp.join(*to_join) + + if not osp.isdir(tempdir): + os.mkdir(tempdir) + + if suffix is not None: + to_join.append(suffix) + + tempdir = osp.join(*to_join) + + if not osp.isdir(tempdir): + os.mkdir(tempdir) + + return tempdir + + +def is_program_installed(basename): + """ + Return program absolute path if installed in PATH. + Otherwise, return None. + + Also searches specific platform dependent paths that are not already in + PATH. This permits general use without assuming user profiles are + sourced (e.g. .bash_Profile), such as when login shells are not used to + launch Spyder. + + On macOS systems, a .app is considered installed if it exists. + """ + home = get_home_dir() + req_paths = [] + if sys.platform == 'darwin': + if basename.endswith('.app') and osp.exists(basename): + return basename + + pyenv = [ + osp.join('/usr', 'local', 'bin'), + osp.join(home, '.pyenv', 'bin') + ] + + # Prioritize Anaconda before Miniconda; local before global. + a = [osp.join(home, 'opt'), '/opt'] + b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] + conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] + + req_paths.extend(pyenv + conda) + + elif sys.platform.startswith('linux'): + pyenv = [ + osp.join('/usr', 'local', 'bin'), + osp.join(home, '.pyenv', 'bin') + ] + + a = [home, '/opt'] + b = ['anaconda', 'miniconda', 'anaconda3', 'miniconda3'] + conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] + + req_paths.extend(pyenv + conda) + + elif os.name == 'nt': + pyenv = [osp.join(home, '.pyenv', 'pyenv-win', 'bin')] + + a = [home, 'C:\\', osp.join('C:\\', 'ProgramData')] + b = ['Anaconda', 'Miniconda', 'Anaconda3', 'Miniconda3'] + conda = [osp.join(*p, 'condabin') for p in itertools.product(a, b)] + + req_paths.extend(pyenv + conda) + + for path in os.environ['PATH'].split(os.pathsep) + req_paths: + abspath = osp.join(path, basename) + if osp.isfile(abspath): + return abspath + + +def find_program(basename): + """ + Find program in PATH and return absolute path + + Try adding .exe or .bat to basename on Windows platforms + (return None if not found) + """ + names = [basename] + if os.name == 'nt': + # Windows platforms + extensions = ('.exe', '.bat', '.cmd') + if not basename.endswith(extensions): + names = [basename+ext for ext in extensions]+[basename] + for name in names: + path = is_program_installed(name) + if path: + return path + + +def get_full_command_for_program(path): + """ + Return the list of tokens necessary to open the program + at a given path. + + On macOS systems, this function prefixes .app paths with + 'open -a', which is necessary to run the application. + + On all other OS's, this function has no effect. + + :str path: The path of the program to run. + :return: The list of tokens necessary to run the program. + """ + if sys.platform == 'darwin' and path.endswith('.app'): + return ['open', '-a', path] + return [path] + + +def alter_subprocess_kwargs_by_platform(**kwargs): + """ + Given a dict, populate kwargs to create a generally + useful default setup for running subprocess processes + on different platforms. For example, `close_fds` is + set on posix and creation of a new console window is + disabled on Windows. + + This function will alter the given kwargs and return + the modified dict. + """ + kwargs.setdefault('close_fds', os.name == 'posix') + if os.name == 'nt': + CONSOLE_CREATION_FLAGS = 0 # Default value + # See: https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863%28v=vs.85%29.aspx + CREATE_NO_WINDOW = 0x08000000 + # We "or" them together + CONSOLE_CREATION_FLAGS |= CREATE_NO_WINDOW + kwargs.setdefault('creationflags', CONSOLE_CREATION_FLAGS) + + # ensure Windows subprocess environment has SYSTEMROOT + if kwargs.get('env') is not None: + # Is SYSTEMROOT, SYSTEMDRIVE in env? case insensitive + for env_var in ['SYSTEMROOT', 'SYSTEMDRIVE']: + if env_var not in map(str.upper, kwargs['env'].keys()): + # Add from os.environ + for k, v in os.environ.items(): + if env_var == k.upper(): + kwargs['env'].update({k: v}) + break # don't risk multiple values + else: + # linux and macOS + if kwargs.get('env') is not None: + if 'HOME' not in kwargs['env']: + kwargs['env'].update({'HOME': get_home_dir()}) + + return kwargs + + +def run_shell_command(cmdstr, **subprocess_kwargs): + """ + Execute the given shell command. + + Note that *args and **kwargs will be passed to the subprocess call. + + If 'shell' is given in subprocess_kwargs it must be True, + otherwise ProgramError will be raised. + . + If 'executable' is not given in subprocess_kwargs, it will + be set to the value of the SHELL environment variable. + + Note that stdin, stdout and stderr will be set by default + to PIPE unless specified in subprocess_kwargs. + + :str cmdstr: The string run as a shell command. + :subprocess_kwargs: These will be passed to subprocess.Popen. + """ + if 'shell' in subprocess_kwargs and not subprocess_kwargs['shell']: + raise ProgramError( + 'The "shell" kwarg may be omitted, but if ' + 'provided it must be True.') + else: + subprocess_kwargs['shell'] = True + + # Don't pass SHELL to subprocess on Windows because it makes this + # fumction fail in Git Bash (where SHELL is declared; other Windows + # shells don't set it). + if not os.name == 'nt': + if 'executable' not in subprocess_kwargs: + subprocess_kwargs['executable'] = os.getenv('SHELL') + + for stream in ['stdin', 'stdout', 'stderr']: + subprocess_kwargs.setdefault(stream, subprocess.PIPE) + subprocess_kwargs = alter_subprocess_kwargs_by_platform( + **subprocess_kwargs) + return subprocess.Popen(cmdstr, **subprocess_kwargs) + + +def run_program(program, args=None, **subprocess_kwargs): + """ + Run program in a separate process. + + NOTE: returns the process object created by + `subprocess.Popen()`. This can be used with + `proc.communicate()` for example. + + If 'shell' appears in the kwargs, it must be False, + otherwise ProgramError will be raised. + + If only the program name is given and not the full path, + a lookup will be performed to find the program. If the + lookup fails, ProgramError will be raised. + + Note that stdin, stdout and stderr will be set by default + to PIPE unless specified in subprocess_kwargs. + + :str program: The name of the program to run. + :list args: The program arguments. + :subprocess_kwargs: These will be passed to subprocess.Popen. + """ + if 'shell' in subprocess_kwargs and subprocess_kwargs['shell']: + raise ProgramError( + "This function is only for non-shell programs, " + "use run_shell_command() instead.") + fullcmd = find_program(program) + if not fullcmd: + raise ProgramError("Program %s was not found" % program) + # As per subprocess, we make a complete list of prog+args + fullcmd = get_full_command_for_program(fullcmd) + (args or []) + for stream in ['stdin', 'stdout', 'stderr']: + subprocess_kwargs.setdefault(stream, subprocess.PIPE) + subprocess_kwargs = alter_subprocess_kwargs_by_platform( + **subprocess_kwargs) + return subprocess.Popen(fullcmd, **subprocess_kwargs) + + +def parse_linux_desktop_entry(fpath): + """Load data from desktop entry with xdg specification.""" + from xdg.DesktopEntry import DesktopEntry + + try: + entry = DesktopEntry(fpath) + entry_data = {} + entry_data['name'] = entry.getName() + entry_data['icon_path'] = entry.getIcon() + entry_data['exec'] = entry.getExec() + entry_data['type'] = entry.getType() + entry_data['hidden'] = entry.getHidden() + entry_data['fpath'] = fpath + except Exception: + entry_data = { + 'name': '', + 'icon_path': '', + 'hidden': '', + 'exec': '', + 'type': '', + 'fpath': fpath + } + + return entry_data + + +def _get_mac_application_icon_path(app_bundle_path): + """Parse mac application bundle and return path for *.icns file.""" + import plistlib + contents_path = info_path = os.path.join(app_bundle_path, 'Contents') + info_path = os.path.join(contents_path, 'Info.plist') + + pl = {} + if os.path.isfile(info_path): + try: + # readPlist is deprecated but needed for py27 compat + pl = plistlib.readPlist(info_path) + except Exception: + pass + + icon_file = pl.get('CFBundleIconFile') + icon_path = None + if icon_file: + icon_path = os.path.join(contents_path, 'Resources', icon_file) + + # Some app bundles seem to list the icon name without extension + if not icon_path.endswith('.icns'): + icon_path = icon_path + '.icns' + + if not os.path.isfile(icon_path): + icon_path = None + + return icon_path + + +def get_username(): + """Return current session username.""" + if os.name == 'nt': + username = os.getlogin() + else: + import pwd + username = pwd.getpwuid(os.getuid())[0] + + return username + + +def _get_win_reg_info(key_path, hive, flag, subkeys): + """ + See: https://stackoverflow.com/q/53132434 + """ + import winreg + + reg = winreg.ConnectRegistry(None, hive) + software_list = [] + try: + key = winreg.OpenKey(reg, key_path, 0, winreg.KEY_READ | flag) + count_subkey = winreg.QueryInfoKey(key)[0] + + for index in range(count_subkey): + software = {} + try: + subkey_name = winreg.EnumKey(key, index) + if not (subkey_name.startswith('{') + and subkey_name.endswith('}')): + software['key'] = subkey_name + subkey = winreg.OpenKey(key, subkey_name) + for property in subkeys: + try: + value = winreg.QueryValueEx(subkey, property)[0] + software[property] = value + except EnvironmentError: + software[property] = '' + software_list.append(software) + except EnvironmentError: + continue + except Exception: + pass + + return software_list + + +def _clean_win_application_path(path): + """Normalize windows path and remove extra quotes.""" + path = path.replace('\\', '/').lower() + # Check for quotes at start and end + if path[0] == '"' and path[-1] == '"': + path = literal_eval(path) + return path + + +def _get_win_applications(): + """Return all system installed windows applications.""" + import winreg + + # See: + # https://docs.microsoft.com/en-us/windows/desktop/shell/app-registration + key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths' + + # Hive and flags + hfs = [ + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY), + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY), + (winreg.HKEY_CURRENT_USER, 0), + ] + subkeys = [None] + sort_key = 'key' + app_paths = {} + _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] + software_list = itertools.chain(*_apps) + for software in sorted(software_list, key=lambda x: x[sort_key]): + if software[None]: + key = software['key'].capitalize().replace('.exe', '') + expanded_fpath = os.path.expandvars(software[None]) + expanded_fpath = _clean_win_application_path(expanded_fpath) + app_paths[key] = expanded_fpath + + # See: + # https://www.blog.pythonlibrary.org/2010/03/03/finding-installed-software-using-python/ + # https://stackoverflow.com/q/53132434 + key_path = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' + subkeys = ['DisplayName', 'InstallLocation', 'DisplayIcon'] + sort_key = 'DisplayName' + apps = {} + _apps = [_get_win_reg_info(key_path, hf[0], hf[1], subkeys) for hf in hfs] + software_list = itertools.chain(*_apps) + for software in sorted(software_list, key=lambda x: x[sort_key]): + location = software['InstallLocation'] + name = software['DisplayName'] + icon = software['DisplayIcon'] + key = software['key'] + if name and icon: + icon = icon.replace('"', '') + icon = icon.split(',')[0] + + if location == '' and icon: + location = os.path.dirname(icon) + + if not os.path.isfile(icon): + icon = '' + + if location and os.path.isdir(location): + files = [f for f in os.listdir(location) + if os.path.isfile(os.path.join(location, f))] + if files: + for fname in files: + fn_low = fname.lower() + valid_file = fn_low.endswith(('.exe', '.com', '.bat')) + if valid_file and not fn_low.startswith('unins'): + fpath = os.path.join(location, fname) + expanded_fpath = os.path.expandvars(fpath) + expanded_fpath = _clean_win_application_path( + expanded_fpath) + apps[name + ' (' + fname + ')'] = expanded_fpath + # Join data + values = list(zip(*apps.values()))[-1] + for name, fpath in app_paths.items(): + if fpath not in values: + apps[name] = fpath + + return apps + + +def _get_linux_applications(): + """Return all system installed linux applications.""" + # See: + # https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html + # https://askubuntu.com/q/433609 + apps = {} + desktop_app_paths = [ + '/usr/share/**/*.desktop', + '~/.local/share/**/*.desktop', + ] + all_entries_data = [] + for path in desktop_app_paths: + fpaths = glob.glob(path) + for fpath in fpaths: + entry_data = parse_linux_desktop_entry(fpath) + all_entries_data.append(entry_data) + + for entry_data in sorted(all_entries_data, key=lambda x: x['name']): + if not entry_data['hidden'] and entry_data['type'] == 'Application': + apps[entry_data['name']] = entry_data['fpath'] + + return apps + + +def _get_mac_applications(): + """Return all system installed osx applications.""" + apps = {} + app_folders = [ + '/**/*.app', + '/Users/{}/**/*.app'.format(get_username()) + ] + + fpaths = [] + for path in app_folders: + fpaths += glob.glob(path) + + for fpath in fpaths: + if os.path.isdir(fpath): + name = os.path.basename(fpath).split('.app')[0] + apps[name] = fpath + + return apps + + +def get_application_icon(fpath): + """Return application icon or default icon if not found.""" + from qtpy.QtGui import QIcon + from spyder.utils.icon_manager import ima + + if os.path.isfile(fpath) or os.path.isdir(fpath): + icon = ima.icon('no_match') + if sys.platform == 'darwin': + icon_path = _get_mac_application_icon_path(fpath) + if icon_path and os.path.isfile(icon_path): + icon = QIcon(icon_path) + elif os.name == 'nt': + pass + else: + entry_data = parse_linux_desktop_entry(fpath) + icon_path = entry_data['icon_path'] + if icon_path: + if os.path.isfile(icon_path): + icon = QIcon(icon_path) + else: + icon = QIcon.fromTheme(icon_path) + else: + icon = ima.icon('help') + + return icon + + +def get_installed_applications(): + """ + Return all system installed applications. + + The return value is a list of tuples where the first item is the icon path + and the second item is the program executable path. + """ + apps = {} + if sys.platform == 'darwin': + apps = _get_mac_applications() + elif os.name == 'nt': + apps = _get_win_applications() + else: + apps = _get_linux_applications() + + if sys.platform == 'darwin': + apps = {key: val for (key, val) in apps.items() if osp.isdir(val)} + else: + apps = {key: val for (key, val) in apps.items() if osp.isfile(val)} + + return apps + + +def open_files_with_application(app_path, fnames): + """ + Generalized method for opening files with a specific application. + + Returns a dictionary of the command used and the return code. + A code equal to 0 means the application executed successfully. + """ + return_codes = {} + + if os.name == 'nt': + fnames = [fname.replace('\\', '/') for fname in fnames] + + if sys.platform == 'darwin': + if not (app_path.endswith('.app') and os.path.isdir(app_path)): + raise ValueError('`app_path` must point to a valid OSX ' + 'application!') + cmd = ['open', '-a', app_path] + fnames + try: + return_code = subprocess.call(cmd) + except Exception: + return_code = 1 + return_codes[' '.join(cmd)] = return_code + elif os.name == 'nt': + if not (app_path.endswith(('.exe', '.bat', '.com', '.cmd')) + and os.path.isfile(app_path)): + raise ValueError('`app_path` must point to a valid Windows ' + 'executable!') + cmd = [app_path] + fnames + try: + return_code = subprocess.call(cmd) + except OSError: + return_code = 1 + return_codes[' '.join(cmd)] = return_code + else: + if not (app_path.endswith('.desktop') and os.path.isfile(app_path)): + raise ValueError('`app_path` must point to a valid Linux ' + 'application!') + + entry = parse_linux_desktop_entry(app_path) + app_path = entry['exec'] + multi = [] + extra = [] + if len(fnames) == 1: + fname = fnames[0] + if '%u' in app_path: + cmd = app_path.replace('%u', fname) + elif '%f' in app_path: + cmd = app_path.replace('%f', fname) + elif '%U' in app_path: + cmd = app_path.replace('%U', fname) + elif '%F' in app_path: + cmd = app_path.replace('%F', fname) + else: + cmd = app_path + extra = fnames + elif len(fnames) > 1: + if '%U' in app_path: + cmd = app_path.replace('%U', ' '.join(fnames)) + elif '%F' in app_path: + cmd = app_path.replace('%F', ' '.join(fnames)) + if '%u' in app_path: + for fname in fnames: + multi.append(app_path.replace('%u', fname)) + elif '%f' in app_path: + for fname in fnames: + multi.append(app_path.replace('%f', fname)) + else: + cmd = app_path + extra = fnames + + if multi: + for cmd in multi: + try: + return_code = subprocess.call([cmd], shell=True) + except Exception: + return_code = 1 + return_codes[cmd] = return_code + else: + try: + return_code = subprocess.call([cmd] + extra, shell=True) + except Exception: + return_code = 1 + return_codes[cmd] = return_code + + return return_codes + + +def python_script_exists(package=None, module=None): + """ + Return absolute path if Python script exists (otherwise, return None) + package=None -> module is in sys.path (standard library modules) + """ + assert module is not None + if package is None: + spec = importlib.util.find_spec(module) + if spec: + path = spec.origin + else: + path = None + else: + spec = importlib.util.find_spec(package) + if spec: + path = osp.join(spec.origin, module)+'.py' + else: + path = None + if path: + if not osp.isfile(path): + path += 'w' + if osp.isfile(path): + return path + + +def run_python_script(package=None, module=None, args=[], p_args=[]): + """ + Run Python script in a separate process + package=None -> module is in sys.path (standard library modules) + """ + assert module is not None + assert isinstance(args, (tuple, list)) and isinstance(p_args, (tuple, list)) + path = python_script_exists(package, module) + run_program(sys.executable, p_args + [path] + args) + + +def shell_split(text): + """ + Split the string `text` using shell-like syntax + + This avoids breaking single/double-quoted strings (e.g. containing + strings with spaces). This function is almost equivalent to the shlex.split + function (see standard library `shlex`) except that it is supporting + unicode strings (shlex does not support unicode until Python 2.7.3). + """ + assert is_text_string(text) # in case a QString is passed... + pattern = r'(\s+|(?': + return parse_version(actver) > parse_version(version) + elif cmp_op == '>=': + return parse_version(actver) >= parse_version(version) + elif cmp_op == '=': + return parse_version(actver) == parse_version(version) + elif cmp_op == '<': + return parse_version(actver) < parse_version(version) + elif cmp_op == '<=': + return parse_version(actver) <= parse_version(version) + else: + return False + except TypeError: + return True + + +def get_module_version(module_name): + """Return module version or None if version can't be retrieved.""" + mod = __import__(module_name) + ver = getattr(mod, '__version__', getattr(mod, 'VERSION', None)) + if not ver: + ver = get_package_version(module_name) + return ver + + +def get_package_version(package_name): + """Return package version or None if version can't be retrieved.""" + + # When support for Python 3.7 and below is dropped, this can be replaced + # with the built-in importlib.metadata.version + try: + ver = pkg_resources.get_distribution(package_name).version + return ver + except pkg_resources.DistributionNotFound: + return None + + +def is_module_installed(module_name, version=None, interpreter=None, + distribution_name=None): + """ + Return True if module ``module_name`` is installed + + If ``version`` is not None, checks that the module's installed version is + consistent with ``version``. The module must have an attribute named + '__version__' or 'VERSION'. + + version may start with =, >=, > or < to specify the exact requirement ; + multiple conditions may be separated by ';' (e.g. '>=0.13;<1.0') + + If ``interpreter`` is not None, checks if a module is installed with a + given ``version`` in the ``interpreter``'s environment. Otherwise checks + in Spyder's environment. + + ``distribution_name`` is the distribution name of a package. For instance, + for pylsp_black that name is python_lsp_black. + """ + if interpreter is not None: + if is_python_interpreter(interpreter): + cmd = dedent(""" + try: + import {} as mod + except Exception: + print('No Module') # spyder: test-skip + print(getattr(mod, '__version__', getattr(mod, 'VERSION', None))) # spyder: test-skip + """).format(module_name) + try: + # use clean environment + proc = run_program(interpreter, ['-c', cmd], env={}) + stdout, stderr = proc.communicate() + stdout = stdout.decode().strip() + except Exception: + return False + + if 'No Module' in stdout: + return False + elif stdout != 'None': + # the module is installed and it has a version attribute + module_version = stdout + else: + module_version = None + else: + # Try to not take a wrong decision if interpreter check fails + return True + else: + # interpreter is None, just get module version in Spyder environment + try: + module_version = get_module_version(module_name) + except Exception: + # Module is not installed + return False + + # This can happen if a package was not uninstalled correctly. For + # instance, if it's __pycache__ main directory is left behind. + try: + mod = __import__(module_name) + if not getattr(mod, '__file__', None): + return False + except Exception: + pass + + # Try to get the module version from its distribution name. For + # instance, pylsp_black doesn't have a version but that can be + # obtained from its distribution, called python_lsp_black. + if not module_version and distribution_name: + module_version = get_package_version(distribution_name) + + if version is None: + return True + else: + if ';' in version: + versions = version.split(';') + else: + versions = [version] + + output = True + for _ver in versions: + match = re.search(r'[0-9]', _ver) + assert match is not None, "Invalid version number" + symb = _ver[:match.start()] + if not symb: + symb = '=' + assert symb in ('>=', '>', '=', '<', '<='),\ + "Invalid version condition '%s'" % symb + ver = _ver[match.start():] + output = output and check_version(module_version, ver, symb) + return output + + +def is_python_interpreter_valid_name(filename): + """Check that the python interpreter file has a valid name.""" + pattern = r'.*python(\d\.?\d*)?(w)?(.exe)?$' + if re.match(pattern, filename, flags=re.I) is None: + return False + else: + return True + + +def is_python_interpreter(filename): + """Evaluate whether a file is a python interpreter or not.""" + # Must be imported here to avoid circular import + from spyder.utils.conda import is_conda_env + + real_filename = os.path.realpath(filename) # To follow symlink if existent + + if (not osp.isfile(real_filename) or + not is_python_interpreter_valid_name(real_filename)): + return False + + # File exists and has valid name + is_text_file = encoding.is_text_file(real_filename) + + if is_pythonw(real_filename): + if os.name == 'nt': + # pythonw is a binary on Windows + if not is_text_file: + return True + else: + return False + elif sys.platform == 'darwin': + # pythonw is a text file in Anaconda but a binary in + # the system + if is_conda_env(pyexec=real_filename) and is_text_file: + return True + elif not is_text_file: + return True + else: + return False + else: + # There's no pythonw in other systems + return False + elif is_text_file: + # At this point we can't have a text file + return False + else: + return check_python_help(real_filename) + + +def is_pythonw(filename): + """Check that the python interpreter has 'pythonw'.""" + pattern = r'.*python(\d\.?\d*)?w(.exe)?$' + if re.match(pattern, filename, flags=re.I) is None: + return False + else: + return True + + +def check_python_help(filename): + """Check that the python interpreter can compile and provide the zen.""" + try: + proc = run_program(filename, ['-c', 'import this'], env={}) + stdout, _ = proc.communicate() + stdout = to_text_string(stdout) + valid_lines = [ + 'Beautiful is better than ugly.', + 'Explicit is better than implicit.', + 'Simple is better than complex.', + 'Complex is better than complicated.', + ] + if all(line in stdout for line in valid_lines): + return True + else: + return False + except Exception: + return False + + +def is_spyder_process(pid): + """ + Test whether given PID belongs to a Spyder process. + + This is checked by testing the first three command line arguments. This + function returns a bool. If there is no process with this PID or its + command line cannot be accessed (perhaps because the process is owned by + another user), then the function returns False. + """ + try: + p = psutil.Process(int(pid)) + + # Valid names for main script + names = set(['spyder', 'spyder3', 'spyder.exe', 'spyder3.exe', + 'bootstrap.py', 'spyder-script.py', 'Spyder.launch.pyw']) + if running_under_pytest(): + names.add('runtests.py') + + # Check the first three command line arguments + arguments = set(os.path.basename(arg) for arg in p.cmdline()[:3]) + conditions = [names & arguments] + return any(conditions) + except (psutil.NoSuchProcess, psutil.AccessDenied): + return False + + +def get_interpreter_info(path): + """Return version information of the selected Python interpreter.""" + try: + out, __ = run_program(path, ['-V']).communicate() + out = out.decode() + except Exception: + out = '' + return out.strip() + + +def find_git(): + """Find git executable in the system.""" + if sys.platform == 'darwin': + proc = subprocess.run( + osp.join(HERE, "check-git.sh"), capture_output=True) + if proc.returncode != 0: + return None + return find_program('git') + else: + return find_program('git') diff --git a/spyder/utils/qthelpers.py b/spyder/utils/qthelpers.py index b5f1a43b733..70f81e03f54 100644 --- a/spyder/utils/qthelpers.py +++ b/spyder/utils/qthelpers.py @@ -1,806 +1,806 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Qt utilities.""" - -# Standard library imports -import functools -from math import pi -import logging -import os -import os.path as osp -import re -import sys -import types - -# Third party imports -from qtpy.compat import from_qvariant, to_qvariant -from qtpy.QtCore import (QEvent, QLibraryInfo, QLocale, QObject, Qt, QTimer, - QTranslator, QUrl, Signal, Slot) -from qtpy.QtGui import QDesktopServices, QKeyEvent, QKeySequence, QPixmap -from qtpy.QtWidgets import (QAction, QApplication, QDialog, QHBoxLayout, - QLabel, QLineEdit, QMenu, QPlainTextEdit, - QProxyStyle, QPushButton, QStyle, - QToolButton, QVBoxLayout, QWidget) - -# Local imports -from spyder.config.base import running_in_mac_app -from spyder.config.manager import CONF -from spyder.py3compat import configparser, is_text_string, to_text_string, PY2 -from spyder.utils.icon_manager import ima -from spyder.utils import programs -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.palette import QStylePalette -from spyder.utils.registries import ACTION_REGISTRY, TOOLBUTTON_REGISTRY -from spyder.widgets.waitingspinner import QWaitingSpinner - -# Third party imports -if sys.platform == "darwin" and not running_in_mac_app(): - import applaunchservices as als - -if PY2: - from urllib import unquote -else: - from urllib.parse import unquote - - -# Note: How to redirect a signal from widget *a* to widget *b* ? -# ---- -# It has to be done manually: -# * typing 'SIGNAL("clicked()")' works -# * typing 'signalstr = "clicked()"; SIGNAL(signalstr)' won't work -# Here is an example of how to do it: -# (self.listwidget is widget *a* and self is widget *b*) -# self.connect(self.listwidget, SIGNAL('option_changed'), -# lambda *args: self.emit(SIGNAL('option_changed'), *args)) -logger = logging.getLogger(__name__) -MENU_SEPARATOR = None - - -def start_file(filename): - """ - Generalized os.startfile for all platforms supported by Qt - - This function is simply wrapping QDesktopServices.openUrl - - Returns True if successful, otherwise returns False. - """ - - # We need to use setUrl instead of setPath because this is the only - # cross-platform way to open external files. setPath fails completely on - # Mac and doesn't open non-ascii files on Linux. - # Fixes spyder-ide/spyder#740. - url = QUrl() - url.setUrl(filename) - return QDesktopServices.openUrl(url) - - -def get_image_label(name, default="not_found"): - """Return image inside a QLabel object""" - label = QLabel() - label.setPixmap(QPixmap(get_image_path(name, default))) - return label - - -def get_origin_filename(): - """Return the filename at the top of the stack""" - # Get top frame - f = sys._getframe() - while f.f_back is not None: - f = f.f_back - return f.f_code.co_filename - - -def qapplication(translate=True, test_time=3): - """ - Return QApplication instance - Creates it if it doesn't already exist - - test_time: Time to maintain open the application when testing. It's given - in seconds - """ - if sys.platform == "darwin": - SpyderApplication = MacApplication - else: - SpyderApplication = QApplication - - app = SpyderApplication.instance() - if app is None: - # Set Application name for Gnome 3 - # https://groups.google.com/forum/#!topic/pyside/24qxvwfrRDs - app = SpyderApplication(['Spyder']) - - # Set application name for KDE. See spyder-ide/spyder#2207. - app.setApplicationName('Spyder') - - if (sys.platform == "darwin" - and not running_in_mac_app() - and CONF.get('main', 'mac_open_file', False)): - # Register app if setting is set - register_app_launchservices() - - if translate: - install_translator(app) - - test_ci = os.environ.get('TEST_CI_WIDGETS', None) - if test_ci is not None: - timer_shutdown = QTimer(app) - timer_shutdown.timeout.connect(app.quit) - timer_shutdown.start(test_time * 1000) - return app - - -def file_uri(fname): - """Select the right file uri scheme according to the operating system""" - if os.name == 'nt': - # Local file - if re.search(r'^[a-zA-Z]:', fname): - return 'file:///' + fname - # UNC based path - else: - return 'file://' + fname - else: - return 'file://' + fname - - -QT_TRANSLATOR = None - - -def install_translator(qapp): - """Install Qt translator to the QApplication instance""" - global QT_TRANSLATOR - if QT_TRANSLATOR is None: - qt_translator = QTranslator() - if qt_translator.load( - "qt_" + QLocale.system().name(), - QLibraryInfo.location(QLibraryInfo.TranslationsPath)): - QT_TRANSLATOR = qt_translator # Keep reference alive - if QT_TRANSLATOR is not None: - qapp.installTranslator(QT_TRANSLATOR) - - -def keybinding(attr): - """Return keybinding""" - ks = getattr(QKeySequence, attr) - return from_qvariant(QKeySequence.keyBindings(ks)[0], str) - - -def _process_mime_path(path, extlist): - if path.startswith(r"file://"): - if os.name == 'nt': - # On Windows platforms, a local path reads: file:///c:/... - # and a UNC based path reads like: file://server/share - if path.startswith(r"file:///"): # this is a local path - path = path[8:] - else: # this is a unc path - path = path[5:] - else: - path = path[7:] - path = path.replace('\\', os.sep) # Transforming backslashes - if osp.exists(path): - if extlist is None or osp.splitext(path)[1] in extlist: - return path - - -def mimedata2url(source, extlist=None): - """ - Extract url list from MIME data - extlist: for example ('.py', '.pyw') - """ - pathlist = [] - if source.hasUrls(): - for url in source.urls(): - path = _process_mime_path( - unquote(to_text_string(url.toString())), extlist) - if path is not None: - pathlist.append(path) - elif source.hasText(): - for rawpath in to_text_string(source.text()).splitlines(): - path = _process_mime_path(rawpath, extlist) - if path is not None: - pathlist.append(path) - if pathlist: - return pathlist - - -def keyevent2tuple(event): - """Convert QKeyEvent instance into a tuple""" - return (event.type(), event.key(), event.modifiers(), event.text(), - event.isAutoRepeat(), event.count()) - - -def tuple2keyevent(past_event): - """Convert tuple into a QKeyEvent instance""" - return QKeyEvent(*past_event) - - -def restore_keyevent(event): - if isinstance(event, tuple): - _, key, modifiers, text, _, _ = event - event = tuple2keyevent(event) - else: - text = event.text() - modifiers = event.modifiers() - key = event.key() - ctrl = modifiers & Qt.ControlModifier - shift = modifiers & Qt.ShiftModifier - return event, text, key, ctrl, shift - - -def create_toolbutton(parent, text=None, shortcut=None, icon=None, tip=None, - toggled=None, triggered=None, - autoraise=True, text_beside_icon=False, - section=None, option=None, id_=None, plugin=None, - context_name=None, register_toolbutton=False): - """Create a QToolButton""" - button = QToolButton(parent) - if text is not None: - button.setText(text) - if icon is not None: - if is_text_string(icon): - icon = ima.get_icon(icon) - button.setIcon(icon) - if text is not None or tip is not None: - button.setToolTip(text if tip is None else tip) - if text_beside_icon: - button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - button.setAutoRaise(autoraise) - if triggered is not None: - button.clicked.connect(triggered) - if toggled is not None: - setup_toggled_action(button, toggled, section, option) - if shortcut is not None: - button.setShortcut(shortcut) - if id_ is not None: - button.ID = id_ - - if register_toolbutton: - TOOLBUTTON_REGISTRY.register_reference( - button, id_, plugin, context_name) - return button - - -def create_waitspinner(size=32, n=11, parent=None): - """ - Create a wait spinner with the specified size built with n circling dots. - """ - dot_padding = 1 - - # To calculate the size of the dots, we need to solve the following - # system of two equations in two variables. - # (1) middle_circumference = pi * (size - dot_size) - # (2) middle_circumference = n * (dot_size + dot_padding) - dot_size = (pi * size - n * dot_padding) / (n + pi) - inner_radius = (size - 2 * dot_size) / 2 - - spinner = QWaitingSpinner(parent, centerOnParent=False) - spinner.setTrailSizeDecreasing(True) - spinner.setNumberOfLines(n) - spinner.setLineLength(dot_size) - spinner.setLineWidth(dot_size) - spinner.setInnerRadius(inner_radius) - spinner.setColor(QStylePalette.COLOR_TEXT_1) - - return spinner - - -def action2button(action, autoraise=True, text_beside_icon=False, parent=None, - icon=None): - """Create a QToolButton directly from a QAction object""" - if parent is None: - parent = action.parent() - button = QToolButton(parent) - button.setDefaultAction(action) - button.setAutoRaise(autoraise) - if text_beside_icon: - button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - if icon: - action.setIcon(icon) - return button - - -def toggle_actions(actions, enable): - """Enable/disable actions""" - if actions is not None: - for action in actions: - if action is not None: - action.setEnabled(enable) - - -def create_action(parent, text, shortcut=None, icon=None, tip=None, - toggled=None, triggered=None, data=None, menurole=None, - context=Qt.WindowShortcut, option=None, section=None, - id_=None, plugin=None, context_name=None, - register_action=False, overwrite=False): - """Create a QAction""" - action = SpyderAction(text, parent, action_id=id_) - if triggered is not None: - action.triggered.connect(triggered) - if toggled is not None: - setup_toggled_action(action, toggled, section, option) - if icon is not None: - if is_text_string(icon): - icon = ima.get_icon(icon) - action.setIcon(icon) - if tip is not None: - action.setToolTip(tip) - action.setStatusTip(tip) - if data is not None: - action.setData(to_qvariant(data)) - if menurole is not None: - action.setMenuRole(menurole) - - # Workround for Mac because setting context=Qt.WidgetShortcut - # there doesn't have any effect - if sys.platform == 'darwin': - action._shown_shortcut = None - if context == Qt.WidgetShortcut: - if shortcut is not None: - action._shown_shortcut = shortcut - else: - # This is going to be filled by - # main.register_shortcut - action._shown_shortcut = 'missing' - else: - if shortcut is not None: - action.setShortcut(shortcut) - action.setShortcutContext(context) - else: - if shortcut is not None: - action.setShortcut(shortcut) - action.setShortcutContext(context) - - if register_action: - ACTION_REGISTRY.register_reference( - action, id_, plugin, context_name, overwrite) - return action - - -def setup_toggled_action(action, toggled, section, option): - """ - Setup a checkable action and wrap the toggle function to receive - configuration. - """ - toggled = wrap_toggled(toggled, section, option) - action.toggled.connect(toggled) - action.setCheckable(True) - if section is not None and option is not None: - CONF.observe_configuration(action, section, option) - add_configuration_update(action) - - -def wrap_toggled(toggled, section, option): - """Wrap a toggle function to set a value on a configuration option.""" - if section is not None and option is not None: - @functools.wraps(toggled) - def wrapped_toggled(value): - CONF.set(section, option, value, recursive_notification=True) - toggled(value) - return wrapped_toggled - return toggled - - -def add_configuration_update(action): - """Add on_configuration_change to a SpyderAction that depends on CONF.""" - - def on_configuration_change(self, _option, _section, value): - self.blockSignals(True) - self.setChecked(value) - self.blockSignals(False) - method = types.MethodType(on_configuration_change, action) - setattr(action, 'on_configuration_change', method) - - -def add_shortcut_to_tooltip(action, context, name): - """Add the shortcut associated with a given action to its tooltip""" - if not hasattr(action, '_tooltip_backup'): - # We store the original tooltip of the action without its associated - # shortcut so that we can update the tooltip properly if shortcuts - # are changed by the user over the course of the current session. - # See spyder-ide/spyder#10726. - action._tooltip_backup = action.toolTip() - - try: - # Some shortcuts might not be assigned so we need to catch the error - shortcut = CONF.get_shortcut(context=context, name=name) - except (configparser.NoSectionError, configparser.NoOptionError): - shortcut = None - - if shortcut: - keyseq = QKeySequence(shortcut) - # See: spyder-ide/spyder#12168 - string = keyseq.toString(QKeySequence.NativeText) - action.setToolTip(u'{0} ({1})'.format(action._tooltip_backup, string)) - - -def add_actions(target, actions, insert_before=None): - """Add actions to a QMenu or a QToolBar.""" - previous_action = None - target_actions = list(target.actions()) - if target_actions: - previous_action = target_actions[-1] - if previous_action.isSeparator(): - previous_action = None - for action in actions: - if (action is None) and (previous_action is not None): - if insert_before is None: - target.addSeparator() - else: - target.insertSeparator(insert_before) - elif isinstance(action, QMenu): - if insert_before is None: - target.addMenu(action) - else: - target.insertMenu(insert_before, action) - elif isinstance(action, QAction): - if insert_before is None: - # This is needed in order to ignore adding an action whose - # wrapped C/C++ object has been deleted. - # See spyder-ide/spyder#5074. - try: - target.addAction(action) - except RuntimeError: - continue - else: - target.insertAction(insert_before, action) - previous_action = action - - -def get_item_user_text(item): - """Get QTreeWidgetItem user role string""" - return from_qvariant(item.data(0, Qt.UserRole), to_text_string) - - -def set_item_user_text(item, text): - """Set QTreeWidgetItem user role string""" - item.setData(0, Qt.UserRole, to_qvariant(text)) - - -def create_bookmark_action(parent, url, title, icon=None, shortcut=None): - """Create bookmark action""" - - @Slot() - def open_url(): - return start_file(url) - - return create_action( parent, title, shortcut=shortcut, icon=icon, - triggered=open_url) - - -def create_module_bookmark_actions(parent, bookmarks): - """ - Create bookmark actions depending on module installation: - bookmarks = ((module_name, url, title), ...) - """ - actions = [] - for key, url, title in bookmarks: - # Create actions for scientific distros only if Spyder is installed - # under them - create_act = True - if key == 'winpython': - if not programs.is_module_installed(key): - create_act = False - if create_act: - act = create_bookmark_action(parent, url, title) - actions.append(act) - return actions - - -def create_program_action(parent, text, name, icon=None, nt_name=None): - """Create action to run a program""" - if is_text_string(icon): - icon = ima.get_icon(icon) - if os.name == 'nt' and nt_name is not None: - name = nt_name - path = programs.find_program(name) - if path is not None: - return create_action(parent, text, icon=icon, - triggered=lambda: programs.run_program(name)) - - -def create_python_script_action(parent, text, icon, package, module, args=[]): - """Create action to run a GUI based Python script""" - if is_text_string(icon): - icon = ima.get_icon(icon) - if programs.python_script_exists(package, module): - return create_action(parent, text, icon=icon, - triggered=lambda: - programs.run_python_script(package, module, args)) - - -class DialogManager(QObject): - """ - Object that keep references to non-modal dialog boxes for another QObject, - typically a QMainWindow or any kind of QWidget - """ - - def __init__(self): - QObject.__init__(self) - self.dialogs = {} - - def show(self, dialog): - """Generic method to show a non-modal dialog and keep reference - to the Qt C++ object""" - for dlg in list(self.dialogs.values()): - if to_text_string(dlg.windowTitle()) \ - == to_text_string(dialog.windowTitle()): - dlg.show() - dlg.raise_() - break - else: - dialog.show() - self.dialogs[id(dialog)] = dialog - dialog.accepted.connect( - lambda eid=id(dialog): self.dialog_finished(eid)) - dialog.rejected.connect( - lambda eid=id(dialog): self.dialog_finished(eid)) - - def dialog_finished(self, dialog_id): - """Manage non-modal dialog boxes""" - return self.dialogs.pop(dialog_id) - - def close_all(self): - """Close all opened dialog boxes""" - for dlg in list(self.dialogs.values()): - dlg.reject() - - -def get_filetype_icon(fname): - """Return file type icon""" - ext = osp.splitext(fname)[1] - if ext.startswith('.'): - ext = ext[1:] - return ima.get_icon("%s.png" % ext, ima.icon('FileIcon')) - - -class SpyderAction(QAction): - """Spyder QAction class wrapper to handle cross platform patches.""" - - def __init__(self, *args, action_id=None, **kwargs): - """Spyder QAction class wrapper to handle cross platform patches.""" - super(SpyderAction, self).__init__(*args, **kwargs) - self.action_id = action_id - if sys.platform == "darwin": - self.setIconVisibleInMenu(False) - - def __str__(self): - return "SpyderAction('{0}')".format(self.text()) - - def __repr__(self): - return "SpyderAction('{0}')".format(self.text()) - - -class ShowStdIcons(QWidget): - """ - Dialog showing standard icons - """ - - def __init__(self, parent): - QWidget.__init__(self, parent) - layout = QHBoxLayout() - row_nb = 14 - cindex = 0 - for child in dir(QStyle): - if child.startswith('SP_'): - if cindex == 0: - col_layout = QVBoxLayout() - icon_layout = QHBoxLayout() - icon = ima.get_std_icon(child) - label = QLabel() - label.setPixmap(icon.pixmap(32, 32)) - icon_layout.addWidget(label) - icon_layout.addWidget(QLineEdit(child.replace('SP_', ''))) - col_layout.addLayout(icon_layout) - cindex = (cindex + 1) % row_nb - if cindex == 0: - layout.addLayout(col_layout) - self.setLayout(layout) - self.setWindowTitle('Standard Platform Icons') - self.setWindowIcon(ima.get_std_icon('TitleBarMenuButton')) - - -def show_std_icons(): - """ - Show all standard Icons - """ - app = qapplication() - dialog = ShowStdIcons(None) - dialog.show() - sys.exit(app.exec_()) - - -def calc_tools_spacing(tools_layout): - """ - Return a spacing (int) or None if we don't have the appropriate metrics - to calculate the spacing. - - We're trying to adapt the spacing below the tools_layout spacing so that - the main_widget has the same vertical position as the editor widgets - (which have tabs above). - - The required spacing is - - spacing = tabbar_height - tools_height + offset - - where the tabbar_heights were empirically determined for a combination of - operating systems and styles. Offsets were manually adjusted, so that the - heights of main_widgets and editor widgets match. This is probably - caused by a still not understood element of the layout and style metrics. - """ - metrics = { # (tabbar_height, offset) - 'nt.fusion': (32, 0), - 'nt.windowsvista': (21, 3), - 'nt.windowsxp': (24, 0), - 'nt.windows': (21, 3), - 'posix.breeze': (28, -1), - 'posix.oxygen': (38, -2), - 'posix.qtcurve': (27, 0), - 'posix.windows': (26, 0), - 'posix.fusion': (32, 0), - } - - style_name = qapplication().style().property('name') - key = '%s.%s' % (os.name, style_name) - - if key in metrics: - tabbar_height, offset = metrics[key] - tools_height = tools_layout.sizeHint().height() - spacing = tabbar_height - tools_height + offset - return max(spacing, 0) - - -def create_plugin_layout(tools_layout, main_widget=None): - """ - Returns a layout for a set of controls above a main widget. This is a - standard layout for many plugin panes (even though, it's currently - more often applied not to the pane itself but with in the one widget - contained in the pane. - - tools_layout: a layout containing the top toolbar - main_widget: the main widget. Can be None, if you want to add this - manually later on. - """ - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - spacing = calc_tools_spacing(tools_layout) - if spacing is not None: - layout.setSpacing(spacing) - - layout.addLayout(tools_layout) - if main_widget is not None: - layout.addWidget(main_widget) - return layout - - -def set_menu_icons(menu, state): - """Show/hide icons for menu actions.""" - menu_actions = menu.actions() - for action in menu_actions: - try: - if action.menu() is not None: - # This is submenu, so we need to call this again - set_menu_icons(action.menu(), state) - elif action.isSeparator(): - continue - else: - action.setIconVisibleInMenu(state) - except RuntimeError: - continue - - -class SpyderProxyStyle(QProxyStyle): - """Style proxy to adjust qdarkstyle issues.""" - - def styleHint(self, hint, option=0, widget=0, returnData=0): - """Override Qt method.""" - if hint == QStyle.SH_ComboBox_Popup: - # Disable combo-box popup top & bottom areas - # See: https://stackoverflow.com/a/21019371 - return 0 - - return QProxyStyle.styleHint(self, hint, option, widget, returnData) - - -class QInputDialogMultiline(QDialog): - """ - Build a replica interface of QInputDialog.getMultilineText. - - Based on: https://stackoverflow.com/a/58823967 - """ - - def __init__(self, parent, title, label, text='', **kwargs): - super(QInputDialogMultiline, self).__init__(parent, **kwargs) - if title is not None: - self.setWindowTitle(title) - - self.setLayout(QVBoxLayout()) - self.layout().addWidget(QLabel(label)) - self.text_edit = QPlainTextEdit() - self.layout().addWidget(self.text_edit) - - button_layout = QHBoxLayout() - button_layout.addStretch() - ok_button = QPushButton('OK') - button_layout.addWidget(ok_button) - cancel_button = QPushButton('Cancel') - button_layout.addWidget(cancel_button) - self.layout().addLayout(button_layout) - - self.text_edit.setPlainText(text) - ok_button.clicked.connect(self.accept) - cancel_button.clicked.connect(self.reject) - - -# ============================================================================= -# Only for macOS -# ============================================================================= -class MacApplication(QApplication): - """Subclass to be able to open external files with our Mac app""" - sig_open_external_file = Signal(str) - - def __init__(self, *args): - QApplication.__init__(self, *args) - self._never_shown = True - self._has_started = False - self._pending_file_open = [] - self._original_handlers = {} - - def event(self, event): - if event.type() == QEvent.FileOpen: - fname = str(event.file()) - if sys.argv and sys.argv[0] == fname: - # Ignore requests to open own script - # Later, mainwindow.initialize() will set sys.argv[0] to '' - pass - elif self._has_started: - self.sig_open_external_file.emit(fname) - else: - self._pending_file_open.append(fname) - return QApplication.event(self, event) - - -def restore_launchservices(): - """Restore LaunchServices to the previous state""" - app = QApplication.instance() - for key, handler in app._original_handlers.items(): - UTI, role = key - als.set_UTI_handler(UTI, role, handler) - - -def register_app_launchservices( - uniform_type_identifier="public.python-script", - role='editor'): - """ - Register app to the Apple launch services so it can open Python files - """ - app = QApplication.instance() - - old_handler = als.get_UTI_handler(uniform_type_identifier, role) - - app._original_handlers[(uniform_type_identifier, role)] = old_handler - - # Restore previous handle when quitting - app.aboutToQuit.connect(restore_launchservices) - - if not app._never_shown: - bundle_identifier = als.get_bundle_identifier() - als.set_UTI_handler( - uniform_type_identifier, role, bundle_identifier) - return - - # Wait to be visible to set ourselves as the UTI handler - def handle_applicationStateChanged(state): - if state == Qt.ApplicationActive and app._never_shown: - app._never_shown = False - bundle_identifier = als.get_bundle_identifier() - als.set_UTI_handler( - uniform_type_identifier, role, bundle_identifier) - - app.applicationStateChanged.connect(handle_applicationStateChanged) - - -if __name__ == "__main__": - show_std_icons() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Qt utilities.""" + +# Standard library imports +import functools +from math import pi +import logging +import os +import os.path as osp +import re +import sys +import types + +# Third party imports +from qtpy.compat import from_qvariant, to_qvariant +from qtpy.QtCore import (QEvent, QLibraryInfo, QLocale, QObject, Qt, QTimer, + QTranslator, QUrl, Signal, Slot) +from qtpy.QtGui import QDesktopServices, QKeyEvent, QKeySequence, QPixmap +from qtpy.QtWidgets import (QAction, QApplication, QDialog, QHBoxLayout, + QLabel, QLineEdit, QMenu, QPlainTextEdit, + QProxyStyle, QPushButton, QStyle, + QToolButton, QVBoxLayout, QWidget) + +# Local imports +from spyder.config.base import running_in_mac_app +from spyder.config.manager import CONF +from spyder.py3compat import configparser, is_text_string, to_text_string, PY2 +from spyder.utils.icon_manager import ima +from spyder.utils import programs +from spyder.utils.image_path_manager import get_image_path +from spyder.utils.palette import QStylePalette +from spyder.utils.registries import ACTION_REGISTRY, TOOLBUTTON_REGISTRY +from spyder.widgets.waitingspinner import QWaitingSpinner + +# Third party imports +if sys.platform == "darwin" and not running_in_mac_app(): + import applaunchservices as als + +if PY2: + from urllib import unquote +else: + from urllib.parse import unquote + + +# Note: How to redirect a signal from widget *a* to widget *b* ? +# ---- +# It has to be done manually: +# * typing 'SIGNAL("clicked()")' works +# * typing 'signalstr = "clicked()"; SIGNAL(signalstr)' won't work +# Here is an example of how to do it: +# (self.listwidget is widget *a* and self is widget *b*) +# self.connect(self.listwidget, SIGNAL('option_changed'), +# lambda *args: self.emit(SIGNAL('option_changed'), *args)) +logger = logging.getLogger(__name__) +MENU_SEPARATOR = None + + +def start_file(filename): + """ + Generalized os.startfile for all platforms supported by Qt + + This function is simply wrapping QDesktopServices.openUrl + + Returns True if successful, otherwise returns False. + """ + + # We need to use setUrl instead of setPath because this is the only + # cross-platform way to open external files. setPath fails completely on + # Mac and doesn't open non-ascii files on Linux. + # Fixes spyder-ide/spyder#740. + url = QUrl() + url.setUrl(filename) + return QDesktopServices.openUrl(url) + + +def get_image_label(name, default="not_found"): + """Return image inside a QLabel object""" + label = QLabel() + label.setPixmap(QPixmap(get_image_path(name, default))) + return label + + +def get_origin_filename(): + """Return the filename at the top of the stack""" + # Get top frame + f = sys._getframe() + while f.f_back is not None: + f = f.f_back + return f.f_code.co_filename + + +def qapplication(translate=True, test_time=3): + """ + Return QApplication instance + Creates it if it doesn't already exist + + test_time: Time to maintain open the application when testing. It's given + in seconds + """ + if sys.platform == "darwin": + SpyderApplication = MacApplication + else: + SpyderApplication = QApplication + + app = SpyderApplication.instance() + if app is None: + # Set Application name for Gnome 3 + # https://groups.google.com/forum/#!topic/pyside/24qxvwfrRDs + app = SpyderApplication(['Spyder']) + + # Set application name for KDE. See spyder-ide/spyder#2207. + app.setApplicationName('Spyder') + + if (sys.platform == "darwin" + and not running_in_mac_app() + and CONF.get('main', 'mac_open_file', False)): + # Register app if setting is set + register_app_launchservices() + + if translate: + install_translator(app) + + test_ci = os.environ.get('TEST_CI_WIDGETS', None) + if test_ci is not None: + timer_shutdown = QTimer(app) + timer_shutdown.timeout.connect(app.quit) + timer_shutdown.start(test_time * 1000) + return app + + +def file_uri(fname): + """Select the right file uri scheme according to the operating system""" + if os.name == 'nt': + # Local file + if re.search(r'^[a-zA-Z]:', fname): + return 'file:///' + fname + # UNC based path + else: + return 'file://' + fname + else: + return 'file://' + fname + + +QT_TRANSLATOR = None + + +def install_translator(qapp): + """Install Qt translator to the QApplication instance""" + global QT_TRANSLATOR + if QT_TRANSLATOR is None: + qt_translator = QTranslator() + if qt_translator.load( + "qt_" + QLocale.system().name(), + QLibraryInfo.location(QLibraryInfo.TranslationsPath)): + QT_TRANSLATOR = qt_translator # Keep reference alive + if QT_TRANSLATOR is not None: + qapp.installTranslator(QT_TRANSLATOR) + + +def keybinding(attr): + """Return keybinding""" + ks = getattr(QKeySequence, attr) + return from_qvariant(QKeySequence.keyBindings(ks)[0], str) + + +def _process_mime_path(path, extlist): + if path.startswith(r"file://"): + if os.name == 'nt': + # On Windows platforms, a local path reads: file:///c:/... + # and a UNC based path reads like: file://server/share + if path.startswith(r"file:///"): # this is a local path + path = path[8:] + else: # this is a unc path + path = path[5:] + else: + path = path[7:] + path = path.replace('\\', os.sep) # Transforming backslashes + if osp.exists(path): + if extlist is None or osp.splitext(path)[1] in extlist: + return path + + +def mimedata2url(source, extlist=None): + """ + Extract url list from MIME data + extlist: for example ('.py', '.pyw') + """ + pathlist = [] + if source.hasUrls(): + for url in source.urls(): + path = _process_mime_path( + unquote(to_text_string(url.toString())), extlist) + if path is not None: + pathlist.append(path) + elif source.hasText(): + for rawpath in to_text_string(source.text()).splitlines(): + path = _process_mime_path(rawpath, extlist) + if path is not None: + pathlist.append(path) + if pathlist: + return pathlist + + +def keyevent2tuple(event): + """Convert QKeyEvent instance into a tuple""" + return (event.type(), event.key(), event.modifiers(), event.text(), + event.isAutoRepeat(), event.count()) + + +def tuple2keyevent(past_event): + """Convert tuple into a QKeyEvent instance""" + return QKeyEvent(*past_event) + + +def restore_keyevent(event): + if isinstance(event, tuple): + _, key, modifiers, text, _, _ = event + event = tuple2keyevent(event) + else: + text = event.text() + modifiers = event.modifiers() + key = event.key() + ctrl = modifiers & Qt.ControlModifier + shift = modifiers & Qt.ShiftModifier + return event, text, key, ctrl, shift + + +def create_toolbutton(parent, text=None, shortcut=None, icon=None, tip=None, + toggled=None, triggered=None, + autoraise=True, text_beside_icon=False, + section=None, option=None, id_=None, plugin=None, + context_name=None, register_toolbutton=False): + """Create a QToolButton""" + button = QToolButton(parent) + if text is not None: + button.setText(text) + if icon is not None: + if is_text_string(icon): + icon = ima.get_icon(icon) + button.setIcon(icon) + if text is not None or tip is not None: + button.setToolTip(text if tip is None else tip) + if text_beside_icon: + button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + button.setAutoRaise(autoraise) + if triggered is not None: + button.clicked.connect(triggered) + if toggled is not None: + setup_toggled_action(button, toggled, section, option) + if shortcut is not None: + button.setShortcut(shortcut) + if id_ is not None: + button.ID = id_ + + if register_toolbutton: + TOOLBUTTON_REGISTRY.register_reference( + button, id_, plugin, context_name) + return button + + +def create_waitspinner(size=32, n=11, parent=None): + """ + Create a wait spinner with the specified size built with n circling dots. + """ + dot_padding = 1 + + # To calculate the size of the dots, we need to solve the following + # system of two equations in two variables. + # (1) middle_circumference = pi * (size - dot_size) + # (2) middle_circumference = n * (dot_size + dot_padding) + dot_size = (pi * size - n * dot_padding) / (n + pi) + inner_radius = (size - 2 * dot_size) / 2 + + spinner = QWaitingSpinner(parent, centerOnParent=False) + spinner.setTrailSizeDecreasing(True) + spinner.setNumberOfLines(n) + spinner.setLineLength(dot_size) + spinner.setLineWidth(dot_size) + spinner.setInnerRadius(inner_radius) + spinner.setColor(QStylePalette.COLOR_TEXT_1) + + return spinner + + +def action2button(action, autoraise=True, text_beside_icon=False, parent=None, + icon=None): + """Create a QToolButton directly from a QAction object""" + if parent is None: + parent = action.parent() + button = QToolButton(parent) + button.setDefaultAction(action) + button.setAutoRaise(autoraise) + if text_beside_icon: + button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + if icon: + action.setIcon(icon) + return button + + +def toggle_actions(actions, enable): + """Enable/disable actions""" + if actions is not None: + for action in actions: + if action is not None: + action.setEnabled(enable) + + +def create_action(parent, text, shortcut=None, icon=None, tip=None, + toggled=None, triggered=None, data=None, menurole=None, + context=Qt.WindowShortcut, option=None, section=None, + id_=None, plugin=None, context_name=None, + register_action=False, overwrite=False): + """Create a QAction""" + action = SpyderAction(text, parent, action_id=id_) + if triggered is not None: + action.triggered.connect(triggered) + if toggled is not None: + setup_toggled_action(action, toggled, section, option) + if icon is not None: + if is_text_string(icon): + icon = ima.get_icon(icon) + action.setIcon(icon) + if tip is not None: + action.setToolTip(tip) + action.setStatusTip(tip) + if data is not None: + action.setData(to_qvariant(data)) + if menurole is not None: + action.setMenuRole(menurole) + + # Workround for Mac because setting context=Qt.WidgetShortcut + # there doesn't have any effect + if sys.platform == 'darwin': + action._shown_shortcut = None + if context == Qt.WidgetShortcut: + if shortcut is not None: + action._shown_shortcut = shortcut + else: + # This is going to be filled by + # main.register_shortcut + action._shown_shortcut = 'missing' + else: + if shortcut is not None: + action.setShortcut(shortcut) + action.setShortcutContext(context) + else: + if shortcut is not None: + action.setShortcut(shortcut) + action.setShortcutContext(context) + + if register_action: + ACTION_REGISTRY.register_reference( + action, id_, plugin, context_name, overwrite) + return action + + +def setup_toggled_action(action, toggled, section, option): + """ + Setup a checkable action and wrap the toggle function to receive + configuration. + """ + toggled = wrap_toggled(toggled, section, option) + action.toggled.connect(toggled) + action.setCheckable(True) + if section is not None and option is not None: + CONF.observe_configuration(action, section, option) + add_configuration_update(action) + + +def wrap_toggled(toggled, section, option): + """Wrap a toggle function to set a value on a configuration option.""" + if section is not None and option is not None: + @functools.wraps(toggled) + def wrapped_toggled(value): + CONF.set(section, option, value, recursive_notification=True) + toggled(value) + return wrapped_toggled + return toggled + + +def add_configuration_update(action): + """Add on_configuration_change to a SpyderAction that depends on CONF.""" + + def on_configuration_change(self, _option, _section, value): + self.blockSignals(True) + self.setChecked(value) + self.blockSignals(False) + method = types.MethodType(on_configuration_change, action) + setattr(action, 'on_configuration_change', method) + + +def add_shortcut_to_tooltip(action, context, name): + """Add the shortcut associated with a given action to its tooltip""" + if not hasattr(action, '_tooltip_backup'): + # We store the original tooltip of the action without its associated + # shortcut so that we can update the tooltip properly if shortcuts + # are changed by the user over the course of the current session. + # See spyder-ide/spyder#10726. + action._tooltip_backup = action.toolTip() + + try: + # Some shortcuts might not be assigned so we need to catch the error + shortcut = CONF.get_shortcut(context=context, name=name) + except (configparser.NoSectionError, configparser.NoOptionError): + shortcut = None + + if shortcut: + keyseq = QKeySequence(shortcut) + # See: spyder-ide/spyder#12168 + string = keyseq.toString(QKeySequence.NativeText) + action.setToolTip(u'{0} ({1})'.format(action._tooltip_backup, string)) + + +def add_actions(target, actions, insert_before=None): + """Add actions to a QMenu or a QToolBar.""" + previous_action = None + target_actions = list(target.actions()) + if target_actions: + previous_action = target_actions[-1] + if previous_action.isSeparator(): + previous_action = None + for action in actions: + if (action is None) and (previous_action is not None): + if insert_before is None: + target.addSeparator() + else: + target.insertSeparator(insert_before) + elif isinstance(action, QMenu): + if insert_before is None: + target.addMenu(action) + else: + target.insertMenu(insert_before, action) + elif isinstance(action, QAction): + if insert_before is None: + # This is needed in order to ignore adding an action whose + # wrapped C/C++ object has been deleted. + # See spyder-ide/spyder#5074. + try: + target.addAction(action) + except RuntimeError: + continue + else: + target.insertAction(insert_before, action) + previous_action = action + + +def get_item_user_text(item): + """Get QTreeWidgetItem user role string""" + return from_qvariant(item.data(0, Qt.UserRole), to_text_string) + + +def set_item_user_text(item, text): + """Set QTreeWidgetItem user role string""" + item.setData(0, Qt.UserRole, to_qvariant(text)) + + +def create_bookmark_action(parent, url, title, icon=None, shortcut=None): + """Create bookmark action""" + + @Slot() + def open_url(): + return start_file(url) + + return create_action( parent, title, shortcut=shortcut, icon=icon, + triggered=open_url) + + +def create_module_bookmark_actions(parent, bookmarks): + """ + Create bookmark actions depending on module installation: + bookmarks = ((module_name, url, title), ...) + """ + actions = [] + for key, url, title in bookmarks: + # Create actions for scientific distros only if Spyder is installed + # under them + create_act = True + if key == 'winpython': + if not programs.is_module_installed(key): + create_act = False + if create_act: + act = create_bookmark_action(parent, url, title) + actions.append(act) + return actions + + +def create_program_action(parent, text, name, icon=None, nt_name=None): + """Create action to run a program""" + if is_text_string(icon): + icon = ima.get_icon(icon) + if os.name == 'nt' and nt_name is not None: + name = nt_name + path = programs.find_program(name) + if path is not None: + return create_action(parent, text, icon=icon, + triggered=lambda: programs.run_program(name)) + + +def create_python_script_action(parent, text, icon, package, module, args=[]): + """Create action to run a GUI based Python script""" + if is_text_string(icon): + icon = ima.get_icon(icon) + if programs.python_script_exists(package, module): + return create_action(parent, text, icon=icon, + triggered=lambda: + programs.run_python_script(package, module, args)) + + +class DialogManager(QObject): + """ + Object that keep references to non-modal dialog boxes for another QObject, + typically a QMainWindow or any kind of QWidget + """ + + def __init__(self): + QObject.__init__(self) + self.dialogs = {} + + def show(self, dialog): + """Generic method to show a non-modal dialog and keep reference + to the Qt C++ object""" + for dlg in list(self.dialogs.values()): + if to_text_string(dlg.windowTitle()) \ + == to_text_string(dialog.windowTitle()): + dlg.show() + dlg.raise_() + break + else: + dialog.show() + self.dialogs[id(dialog)] = dialog + dialog.accepted.connect( + lambda eid=id(dialog): self.dialog_finished(eid)) + dialog.rejected.connect( + lambda eid=id(dialog): self.dialog_finished(eid)) + + def dialog_finished(self, dialog_id): + """Manage non-modal dialog boxes""" + return self.dialogs.pop(dialog_id) + + def close_all(self): + """Close all opened dialog boxes""" + for dlg in list(self.dialogs.values()): + dlg.reject() + + +def get_filetype_icon(fname): + """Return file type icon""" + ext = osp.splitext(fname)[1] + if ext.startswith('.'): + ext = ext[1:] + return ima.get_icon("%s.png" % ext, ima.icon('FileIcon')) + + +class SpyderAction(QAction): + """Spyder QAction class wrapper to handle cross platform patches.""" + + def __init__(self, *args, action_id=None, **kwargs): + """Spyder QAction class wrapper to handle cross platform patches.""" + super(SpyderAction, self).__init__(*args, **kwargs) + self.action_id = action_id + if sys.platform == "darwin": + self.setIconVisibleInMenu(False) + + def __str__(self): + return "SpyderAction('{0}')".format(self.text()) + + def __repr__(self): + return "SpyderAction('{0}')".format(self.text()) + + +class ShowStdIcons(QWidget): + """ + Dialog showing standard icons + """ + + def __init__(self, parent): + QWidget.__init__(self, parent) + layout = QHBoxLayout() + row_nb = 14 + cindex = 0 + for child in dir(QStyle): + if child.startswith('SP_'): + if cindex == 0: + col_layout = QVBoxLayout() + icon_layout = QHBoxLayout() + icon = ima.get_std_icon(child) + label = QLabel() + label.setPixmap(icon.pixmap(32, 32)) + icon_layout.addWidget(label) + icon_layout.addWidget(QLineEdit(child.replace('SP_', ''))) + col_layout.addLayout(icon_layout) + cindex = (cindex + 1) % row_nb + if cindex == 0: + layout.addLayout(col_layout) + self.setLayout(layout) + self.setWindowTitle('Standard Platform Icons') + self.setWindowIcon(ima.get_std_icon('TitleBarMenuButton')) + + +def show_std_icons(): + """ + Show all standard Icons + """ + app = qapplication() + dialog = ShowStdIcons(None) + dialog.show() + sys.exit(app.exec_()) + + +def calc_tools_spacing(tools_layout): + """ + Return a spacing (int) or None if we don't have the appropriate metrics + to calculate the spacing. + + We're trying to adapt the spacing below the tools_layout spacing so that + the main_widget has the same vertical position as the editor widgets + (which have tabs above). + + The required spacing is + + spacing = tabbar_height - tools_height + offset + + where the tabbar_heights were empirically determined for a combination of + operating systems and styles. Offsets were manually adjusted, so that the + heights of main_widgets and editor widgets match. This is probably + caused by a still not understood element of the layout and style metrics. + """ + metrics = { # (tabbar_height, offset) + 'nt.fusion': (32, 0), + 'nt.windowsvista': (21, 3), + 'nt.windowsxp': (24, 0), + 'nt.windows': (21, 3), + 'posix.breeze': (28, -1), + 'posix.oxygen': (38, -2), + 'posix.qtcurve': (27, 0), + 'posix.windows': (26, 0), + 'posix.fusion': (32, 0), + } + + style_name = qapplication().style().property('name') + key = '%s.%s' % (os.name, style_name) + + if key in metrics: + tabbar_height, offset = metrics[key] + tools_height = tools_layout.sizeHint().height() + spacing = tabbar_height - tools_height + offset + return max(spacing, 0) + + +def create_plugin_layout(tools_layout, main_widget=None): + """ + Returns a layout for a set of controls above a main widget. This is a + standard layout for many plugin panes (even though, it's currently + more often applied not to the pane itself but with in the one widget + contained in the pane. + + tools_layout: a layout containing the top toolbar + main_widget: the main widget. Can be None, if you want to add this + manually later on. + """ + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + spacing = calc_tools_spacing(tools_layout) + if spacing is not None: + layout.setSpacing(spacing) + + layout.addLayout(tools_layout) + if main_widget is not None: + layout.addWidget(main_widget) + return layout + + +def set_menu_icons(menu, state): + """Show/hide icons for menu actions.""" + menu_actions = menu.actions() + for action in menu_actions: + try: + if action.menu() is not None: + # This is submenu, so we need to call this again + set_menu_icons(action.menu(), state) + elif action.isSeparator(): + continue + else: + action.setIconVisibleInMenu(state) + except RuntimeError: + continue + + +class SpyderProxyStyle(QProxyStyle): + """Style proxy to adjust qdarkstyle issues.""" + + def styleHint(self, hint, option=0, widget=0, returnData=0): + """Override Qt method.""" + if hint == QStyle.SH_ComboBox_Popup: + # Disable combo-box popup top & bottom areas + # See: https://stackoverflow.com/a/21019371 + return 0 + + return QProxyStyle.styleHint(self, hint, option, widget, returnData) + + +class QInputDialogMultiline(QDialog): + """ + Build a replica interface of QInputDialog.getMultilineText. + + Based on: https://stackoverflow.com/a/58823967 + """ + + def __init__(self, parent, title, label, text='', **kwargs): + super(QInputDialogMultiline, self).__init__(parent, **kwargs) + if title is not None: + self.setWindowTitle(title) + + self.setLayout(QVBoxLayout()) + self.layout().addWidget(QLabel(label)) + self.text_edit = QPlainTextEdit() + self.layout().addWidget(self.text_edit) + + button_layout = QHBoxLayout() + button_layout.addStretch() + ok_button = QPushButton('OK') + button_layout.addWidget(ok_button) + cancel_button = QPushButton('Cancel') + button_layout.addWidget(cancel_button) + self.layout().addLayout(button_layout) + + self.text_edit.setPlainText(text) + ok_button.clicked.connect(self.accept) + cancel_button.clicked.connect(self.reject) + + +# ============================================================================= +# Only for macOS +# ============================================================================= +class MacApplication(QApplication): + """Subclass to be able to open external files with our Mac app""" + sig_open_external_file = Signal(str) + + def __init__(self, *args): + QApplication.__init__(self, *args) + self._never_shown = True + self._has_started = False + self._pending_file_open = [] + self._original_handlers = {} + + def event(self, event): + if event.type() == QEvent.FileOpen: + fname = str(event.file()) + if sys.argv and sys.argv[0] == fname: + # Ignore requests to open own script + # Later, mainwindow.initialize() will set sys.argv[0] to '' + pass + elif self._has_started: + self.sig_open_external_file.emit(fname) + else: + self._pending_file_open.append(fname) + return QApplication.event(self, event) + + +def restore_launchservices(): + """Restore LaunchServices to the previous state""" + app = QApplication.instance() + for key, handler in app._original_handlers.items(): + UTI, role = key + als.set_UTI_handler(UTI, role, handler) + + +def register_app_launchservices( + uniform_type_identifier="public.python-script", + role='editor'): + """ + Register app to the Apple launch services so it can open Python files + """ + app = QApplication.instance() + + old_handler = als.get_UTI_handler(uniform_type_identifier, role) + + app._original_handlers[(uniform_type_identifier, role)] = old_handler + + # Restore previous handle when quitting + app.aboutToQuit.connect(restore_launchservices) + + if not app._never_shown: + bundle_identifier = als.get_bundle_identifier() + als.set_UTI_handler( + uniform_type_identifier, role, bundle_identifier) + return + + # Wait to be visible to set ourselves as the UTI handler + def handle_applicationStateChanged(state): + if state == Qt.ApplicationActive and app._never_shown: + app._never_shown = False + bundle_identifier = als.get_bundle_identifier() + als.set_UTI_handler( + uniform_type_identifier, role, bundle_identifier) + + app.applicationStateChanged.connect(handle_applicationStateChanged) + + +if __name__ == "__main__": + show_std_icons() diff --git a/spyder/utils/sourcecode.py b/spyder/utils/sourcecode.py index f292fee5edd..4d955f3f990 100644 --- a/spyder/utils/sourcecode.py +++ b/spyder/utils/sourcecode.py @@ -1,240 +1,240 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Source code text utilities -""" - -# Standard library imports -import re -import os -import sys - -# Third-part imports -from pylsp._utils import get_eol_chars as _get_eol_chars - - -# Order is important: -EOL_CHARS = (("\r\n", 'nt'), ("\n", 'posix'), ("\r", 'mac')) - - -def get_eol_chars(text): - """ - Get text end-of-line (eol) characters. - - If no eol chars are found, return ones based on the operating - system. - - Parameters - ---------- - text: str - Text to get its eol chars from - - Returns - ------- - eol: str or None - Eol found in ``text``. - """ - eol_chars = _get_eol_chars(text) - - if not eol_chars: - if os.name == 'nt': - eol_chars = "\r\n" - elif sys.platform.startswith('linux'): - eol_chars = "\n" - elif sys.platform == 'darwin': - eol_chars = "\r" - else: - eol_chars = "\n" - - return eol_chars - - -def get_os_name_from_eol_chars(eol_chars): - """Return OS name from EOL characters""" - for chars, os_name in EOL_CHARS: - if eol_chars == chars: - return os_name - - -def get_eol_chars_from_os_name(os_name): - """Return EOL characters from OS name""" - for eol_chars, name in EOL_CHARS: - if name == os_name: - return eol_chars - - -def has_mixed_eol_chars(text): - """Detect if text has mixed EOL characters""" - eol_chars = get_eol_chars(text) - if eol_chars is None: - return False - correct_text = eol_chars.join((text+eol_chars).splitlines()) - return repr(correct_text) != repr(text) - - -def normalize_eols(text, eol='\n'): - """Use the same eol's in text""" - for eol_char, _ in EOL_CHARS: - if eol_char != eol: - text = text.replace(eol_char, eol) - return text - - -def fix_indentation(text, indent_chars): - """Replace tabs by spaces""" - return text.replace('\t', indent_chars) - - -def is_builtin(text): - """Test if passed string is the name of a Python builtin object""" - from spyder.py3compat import builtins - return text in [str(name) for name in dir(builtins) - if not name.startswith('_')] - - -def is_keyword(text): - """Test if passed string is the name of a Python keyword""" - import keyword - return text in keyword.kwlist - - -def get_primary_at(source_code, offset, retry=True): - """Return Python object in *source_code* at *offset* - Periods to the left of the cursor are carried forward - e.g. 'functools.par^tial' would yield 'functools.partial' - Retry prevents infinite recursion: retry only once - """ - obj = '' - left = re.split(r"[^0-9a-zA-Z_.]", source_code[:offset]) - if left and left[-1]: - obj = left[-1] - right = re.split(r"\W", source_code[offset:]) - if right and right[0]: - obj += right[0] - if obj and obj[0].isdigit(): - obj = '' - # account for opening chars with no text to the right - if not obj and retry and offset and source_code[offset - 1] in '([.': - return get_primary_at(source_code, offset - 1, retry=False) - return obj - - -def split_source(source_code): - '''Split source code into lines - ''' - eol_chars = get_eol_chars(source_code) - if eol_chars: - return source_code.split(eol_chars) - else: - return [source_code] - - -def get_identifiers(source_code): - '''Split source code into python identifier-like tokens''' - tokens = set(re.split(r"[^0-9a-zA-Z_.]", source_code)) - valid = re.compile(r'[a-zA-Z_]') - return [token for token in tokens if re.match(valid, token)] - -def path_components(path): - """ - Return the individual components of a given file path - string (for the local operating system). - - Taken from https://stackoverflow.com/q/21498939/438386 - """ - components = [] - # The loop guarantees that the returned components can be - # os.path.joined with the path separator and point to the same - # location: - while True: - (new_path, tail) = os.path.split(path) # Works on any platform - components.append(tail) - if new_path == path: # Root (including drive, on Windows) reached - break - path = new_path - components.append(new_path) - components.reverse() # First component first - return components - -def differentiate_prefix(path_components0, path_components1): - """ - Return the differentiated prefix of the given two iterables. - - Taken from https://stackoverflow.com/q/21498939/438386 - """ - longest_prefix = [] - root_comparison = False - common_elmt = None - for index, (elmt0, elmt1) in enumerate(zip(path_components0, path_components1)): - if elmt0 != elmt1: - if index == 2: - root_comparison = True - break - else: - common_elmt = elmt0 - longest_prefix.append(elmt0) - file_name_length = len(path_components0[len(path_components0) - 1]) - path_0 = os.path.join(*path_components0)[:-file_name_length - 1] - if len(longest_prefix) > 0: - longest_path_prefix = os.path.join(*longest_prefix) - longest_prefix_length = len(longest_path_prefix) + 1 - if path_0[longest_prefix_length:] != '' and not root_comparison: - path_0_components = path_components(path_0[longest_prefix_length:]) - if path_0_components[0] == ''and path_0_components[1] == ''and len( - path_0[longest_prefix_length:]) > 20: - path_0_components.insert(2, common_elmt) - path_0 = os.path.join(*path_0_components) - else: - path_0 = path_0[longest_prefix_length:] - elif not root_comparison: - path_0 = common_elmt - elif sys.platform.startswith('linux') and path_0 == '': - path_0 = '/' - return path_0 - -def disambiguate_fname(files_path_list, filename): - """Get tab title without ambiguation.""" - fname = os.path.basename(filename) - same_name_files = get_same_name_files(files_path_list, fname) - if len(same_name_files) > 1: - compare_path = shortest_path(same_name_files) - if compare_path == filename: - same_name_files.remove(path_components(filename)) - compare_path = shortest_path(same_name_files) - diff_path = differentiate_prefix(path_components(filename), - path_components(compare_path)) - diff_path_length = len(diff_path) - path_component = path_components(diff_path) - if (diff_path_length > 20 and len(path_component) > 2): - if path_component[0] != '/' and path_component[0] != '': - path_component = [path_component[0], '...', - path_component[-1]] - else: - path_component = [path_component[2], '...', - path_component[-1]] - diff_path = os.path.join(*path_component) - fname = fname + " - " + diff_path - return fname - -def get_same_name_files(files_path_list, filename): - """Get a list of the path components of the files with the same name.""" - same_name_files = [] - for fname in files_path_list: - if filename == os.path.basename(fname): - same_name_files.append(path_components(fname)) - return same_name_files - -def shortest_path(files_path_list): - """Shortest path between files in the list.""" - if len(files_path_list) > 0: - shortest_path = files_path_list[0] - shortest_path_length = len(files_path_list[0]) - for path_elmts in files_path_list: - if len(path_elmts) < shortest_path_length: - shortest_path_length = len(path_elmts) - shortest_path = path_elmts - return os.path.join(*shortest_path) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Source code text utilities +""" + +# Standard library imports +import re +import os +import sys + +# Third-part imports +from pylsp._utils import get_eol_chars as _get_eol_chars + + +# Order is important: +EOL_CHARS = (("\r\n", 'nt'), ("\n", 'posix'), ("\r", 'mac')) + + +def get_eol_chars(text): + """ + Get text end-of-line (eol) characters. + + If no eol chars are found, return ones based on the operating + system. + + Parameters + ---------- + text: str + Text to get its eol chars from + + Returns + ------- + eol: str or None + Eol found in ``text``. + """ + eol_chars = _get_eol_chars(text) + + if not eol_chars: + if os.name == 'nt': + eol_chars = "\r\n" + elif sys.platform.startswith('linux'): + eol_chars = "\n" + elif sys.platform == 'darwin': + eol_chars = "\r" + else: + eol_chars = "\n" + + return eol_chars + + +def get_os_name_from_eol_chars(eol_chars): + """Return OS name from EOL characters""" + for chars, os_name in EOL_CHARS: + if eol_chars == chars: + return os_name + + +def get_eol_chars_from_os_name(os_name): + """Return EOL characters from OS name""" + for eol_chars, name in EOL_CHARS: + if name == os_name: + return eol_chars + + +def has_mixed_eol_chars(text): + """Detect if text has mixed EOL characters""" + eol_chars = get_eol_chars(text) + if eol_chars is None: + return False + correct_text = eol_chars.join((text+eol_chars).splitlines()) + return repr(correct_text) != repr(text) + + +def normalize_eols(text, eol='\n'): + """Use the same eol's in text""" + for eol_char, _ in EOL_CHARS: + if eol_char != eol: + text = text.replace(eol_char, eol) + return text + + +def fix_indentation(text, indent_chars): + """Replace tabs by spaces""" + return text.replace('\t', indent_chars) + + +def is_builtin(text): + """Test if passed string is the name of a Python builtin object""" + from spyder.py3compat import builtins + return text in [str(name) for name in dir(builtins) + if not name.startswith('_')] + + +def is_keyword(text): + """Test if passed string is the name of a Python keyword""" + import keyword + return text in keyword.kwlist + + +def get_primary_at(source_code, offset, retry=True): + """Return Python object in *source_code* at *offset* + Periods to the left of the cursor are carried forward + e.g. 'functools.par^tial' would yield 'functools.partial' + Retry prevents infinite recursion: retry only once + """ + obj = '' + left = re.split(r"[^0-9a-zA-Z_.]", source_code[:offset]) + if left and left[-1]: + obj = left[-1] + right = re.split(r"\W", source_code[offset:]) + if right and right[0]: + obj += right[0] + if obj and obj[0].isdigit(): + obj = '' + # account for opening chars with no text to the right + if not obj and retry and offset and source_code[offset - 1] in '([.': + return get_primary_at(source_code, offset - 1, retry=False) + return obj + + +def split_source(source_code): + '''Split source code into lines + ''' + eol_chars = get_eol_chars(source_code) + if eol_chars: + return source_code.split(eol_chars) + else: + return [source_code] + + +def get_identifiers(source_code): + '''Split source code into python identifier-like tokens''' + tokens = set(re.split(r"[^0-9a-zA-Z_.]", source_code)) + valid = re.compile(r'[a-zA-Z_]') + return [token for token in tokens if re.match(valid, token)] + +def path_components(path): + """ + Return the individual components of a given file path + string (for the local operating system). + + Taken from https://stackoverflow.com/q/21498939/438386 + """ + components = [] + # The loop guarantees that the returned components can be + # os.path.joined with the path separator and point to the same + # location: + while True: + (new_path, tail) = os.path.split(path) # Works on any platform + components.append(tail) + if new_path == path: # Root (including drive, on Windows) reached + break + path = new_path + components.append(new_path) + components.reverse() # First component first + return components + +def differentiate_prefix(path_components0, path_components1): + """ + Return the differentiated prefix of the given two iterables. + + Taken from https://stackoverflow.com/q/21498939/438386 + """ + longest_prefix = [] + root_comparison = False + common_elmt = None + for index, (elmt0, elmt1) in enumerate(zip(path_components0, path_components1)): + if elmt0 != elmt1: + if index == 2: + root_comparison = True + break + else: + common_elmt = elmt0 + longest_prefix.append(elmt0) + file_name_length = len(path_components0[len(path_components0) - 1]) + path_0 = os.path.join(*path_components0)[:-file_name_length - 1] + if len(longest_prefix) > 0: + longest_path_prefix = os.path.join(*longest_prefix) + longest_prefix_length = len(longest_path_prefix) + 1 + if path_0[longest_prefix_length:] != '' and not root_comparison: + path_0_components = path_components(path_0[longest_prefix_length:]) + if path_0_components[0] == ''and path_0_components[1] == ''and len( + path_0[longest_prefix_length:]) > 20: + path_0_components.insert(2, common_elmt) + path_0 = os.path.join(*path_0_components) + else: + path_0 = path_0[longest_prefix_length:] + elif not root_comparison: + path_0 = common_elmt + elif sys.platform.startswith('linux') and path_0 == '': + path_0 = '/' + return path_0 + +def disambiguate_fname(files_path_list, filename): + """Get tab title without ambiguation.""" + fname = os.path.basename(filename) + same_name_files = get_same_name_files(files_path_list, fname) + if len(same_name_files) > 1: + compare_path = shortest_path(same_name_files) + if compare_path == filename: + same_name_files.remove(path_components(filename)) + compare_path = shortest_path(same_name_files) + diff_path = differentiate_prefix(path_components(filename), + path_components(compare_path)) + diff_path_length = len(diff_path) + path_component = path_components(diff_path) + if (diff_path_length > 20 and len(path_component) > 2): + if path_component[0] != '/' and path_component[0] != '': + path_component = [path_component[0], '...', + path_component[-1]] + else: + path_component = [path_component[2], '...', + path_component[-1]] + diff_path = os.path.join(*path_component) + fname = fname + " - " + diff_path + return fname + +def get_same_name_files(files_path_list, filename): + """Get a list of the path components of the files with the same name.""" + same_name_files = [] + for fname in files_path_list: + if filename == os.path.basename(fname): + same_name_files.append(path_components(fname)) + return same_name_files + +def shortest_path(files_path_list): + """Shortest path between files in the list.""" + if len(files_path_list) > 0: + shortest_path = files_path_list[0] + shortest_path_length = len(files_path_list[0]) + for path_elmts in files_path_list: + if len(path_elmts) < shortest_path_length: + shortest_path_length = len(path_elmts) + shortest_path = path_elmts + return os.path.join(*shortest_path) diff --git a/spyder/utils/syntaxhighlighters.py b/spyder/utils/syntaxhighlighters.py index 89b3e1b5098..40d2999eac0 100644 --- a/spyder/utils/syntaxhighlighters.py +++ b/spyder/utils/syntaxhighlighters.py @@ -1,1438 +1,1438 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Editor widget syntax highlighters based on QtGui.QSyntaxHighlighter -(Python syntax highlighting rules are inspired from idlelib) -""" - -# Standard library imports -from __future__ import print_function -import keyword -import os -import re -import weakref - -# Third party imports -from pygments.lexer import RegexLexer, bygroups -from pygments.lexers import get_lexer_by_name -from pygments.token import (Text, Other, Keyword, Name, String, Number, - Comment, Generic, Token) -from qtpy.QtCore import Qt, QTimer, Signal -from qtpy.QtGui import (QColor, QCursor, QFont, QSyntaxHighlighter, - QTextCharFormat, QTextOption) -from qtpy.QtWidgets import QApplication - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.py3compat import (builtins, is_text_string, to_text_string, PY3, - PY36_OR_MORE) -from spyder.plugins.editor.utils.languages import CELL_LANGUAGES -from spyder.plugins.editor.utils.editor import TextBlockHelper as tbh -from spyder.plugins.editor.utils.editor import BlockUserData -from spyder.utils.workers import WorkerManager -from spyder.plugins.outlineexplorer.api import OutlineExplorerData -from spyder.utils.qstringhelpers import qstring_length - - - -# ============================================================================= -# Constants -# ============================================================================= -DEFAULT_PATTERNS = { - 'file': - r'file:///?(?:[\S]*)', - 'issue': - (r'(?:(?:(?:gh:)|(?:gl:)|(?:bb:))?[\w\-_]*/[\w\-_]*#\d+)|' - r'(?:(?:(?:gh-)|(?:gl-)|(?:bb-))\d+)'), - 'mail': - r'(?:mailto:\s*)?([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})', - 'url': - r"https?://([\da-z\.-]+)\.([a-z\.]{2,6})([/\w\.-]*)[^ ^'^\"]+", -} - -COLOR_SCHEME_KEYS = { - "background": _("Background:"), - "currentline": _("Current line:"), - "currentcell": _("Current cell:"), - "occurrence": _("Occurrence:"), - "ctrlclick": _("Link:"), - "sideareas": _("Side areas:"), - "matched_p": _("Matched
    parens:"), - "unmatched_p": _("Unmatched
    parens:"), - "normal": _("Normal text:"), - "keyword": _("Keyword:"), - "builtin": _("Builtin:"), - "definition": _("Definition:"), - "comment": _("Comment:"), - "string": _("String:"), - "number": _("Number:"), - "instance": _("Instance:"), - "magic": _("Magic:"), -} - -COLOR_SCHEME_DEFAULT_VALUES = { - "background": "#19232D", - "currentline": "#3a424a", - "currentcell": "#292d3e", - "occurrence": "#1A72BB", - "ctrlclick": "#179ae0", - "sideareas": "#222b35", - "matched_p": "#0bbe0b", - "unmatched_p": "#ff4340", - "normal": ("#ffffff", False, False), - "keyword": ("#c670e0", False, False), - "builtin": ("#fab16c", False, False), - "definition": ("#57d6e4", True, False), - "comment": ("#999999", False, False), - "string": ("#b0e686", False, True), - "number": ("#faed5c", False, False), - "instance": ("#ee6772", False, True), - "magic": ("#c670e0", False, False), -} - -COLOR_SCHEME_NAMES = CONF.get('appearance', 'names') - -# Mapping for file extensions that use Pygments highlighting but should use -# different lexers than Pygments' autodetection suggests. Keys are file -# extensions or tuples of extensions, values are Pygments lexer names. -CUSTOM_EXTENSION_LEXER = { - '.ipynb': 'json', - '.nt': 'bat', - '.m': 'matlab', - ('.properties', '.session', '.inf', '.reg', '.url', - '.cfg', '.cnf', '.aut', '.iss'): 'ini' -} - -# Convert custom extensions into a one-to-one mapping for easier lookup. -custom_extension_lexer_mapping = {} -for key, value in CUSTOM_EXTENSION_LEXER.items(): - # Single key is mapped unchanged. - if is_text_string(key): - custom_extension_lexer_mapping[key] = value - # Tuple of keys is iterated over and each is mapped to value. - else: - for k in key: - custom_extension_lexer_mapping[k] = value - - -#============================================================================== -# Auxiliary functions -#============================================================================== -def get_span(match, key=None): - if key is not None: - start, end = match.span(key) - else: - start, end = match.span() - start16 = qstring_length(match.string[:start]) - end16 = start16 + qstring_length(match.string[start:end]) - return start16, end16 - - -def get_color_scheme(name): - """Get a color scheme from config using its name""" - name = name.lower() - scheme = {} - for key in COLOR_SCHEME_KEYS: - try: - scheme[key] = CONF.get('appearance', name+'/'+key) - except: - scheme[key] = CONF.get('appearance', 'spyder/'+key) - return scheme - - -def any(name, alternates): - "Return a named group pattern matching list of alternates." - return "(?P<%s>" % name + "|".join(alternates) + ")" - - -def create_patterns(patterns, compile=False): - """ - Create patterns from pattern dictionary. - - The key correspond to the group name and the values a list of - possible pattern alternatives. - """ - all_patterns = [] - for key, value in patterns.items(): - all_patterns.append(any(key, [value])) - - regex = '|'.join(all_patterns) - - if compile: - regex = re.compile(regex) - - return regex - - -DEFAULT_PATTERNS_TEXT = create_patterns(DEFAULT_PATTERNS, compile=False) -DEFAULT_COMPILED_PATTERNS = re.compile(create_patterns(DEFAULT_PATTERNS, - compile=True)) - - -#============================================================================== -# Syntax highlighting color schemes -#============================================================================== -class BaseSH(QSyntaxHighlighter): - """Base Syntax Highlighter Class""" - # Syntax highlighting rules: - PROG = None - BLANKPROG = re.compile(r"\s+") - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - # Syntax highlighting parameters. - BLANK_ALPHA_FACTOR = 0.31 - - sig_outline_explorer_data_changed = Signal() - - # Use to signal font change - sig_font_changed = Signal() - - def __init__(self, parent, font=None, color_scheme='Spyder'): - QSyntaxHighlighter.__init__(self, parent) - - self.font = font - if is_text_string(color_scheme): - self.color_scheme = get_color_scheme(color_scheme) - else: - self.color_scheme = color_scheme - - self.background_color = None - self.currentline_color = None - self.currentcell_color = None - self.occurrence_color = None - self.ctrlclick_color = None - self.sideareas_color = None - self.matched_p_color = None - self.unmatched_p_color = None - - self.formats = None - self.setup_formats(font) - - self.cell_separators = None - self.editor = None - self.patterns = DEFAULT_COMPILED_PATTERNS - - # List of cells - self._cell_list = [] - - def get_background_color(self): - return QColor(self.background_color) - - def get_foreground_color(self): - """Return foreground ('normal' text) color""" - return self.formats["normal"].foreground().color() - - def get_currentline_color(self): - return QColor(self.currentline_color) - - def get_currentcell_color(self): - return QColor(self.currentcell_color) - - def get_occurrence_color(self): - return QColor(self.occurrence_color) - - def get_ctrlclick_color(self): - return QColor(self.ctrlclick_color) - - def get_sideareas_color(self): - return QColor(self.sideareas_color) - - def get_matched_p_color(self): - return QColor(self.matched_p_color) - - def get_unmatched_p_color(self): - return QColor(self.unmatched_p_color) - - def get_comment_color(self): - """ Return color for the comments """ - return self.formats['comment'].foreground().color() - - def get_color_name(self, fmt): - """Return color name assigned to a given format""" - return self.formats[fmt].foreground().color().name() - - def setup_formats(self, font=None): - base_format = QTextCharFormat() - if font is not None: - self.font = font - if self.font is not None: - base_format.setFont(self.font) - self.sig_font_changed.emit() - self.formats = {} - colors = self.color_scheme.copy() - self.background_color = colors.pop("background") - self.currentline_color = colors.pop("currentline") - self.currentcell_color = colors.pop("currentcell") - self.occurrence_color = colors.pop("occurrence") - self.ctrlclick_color = colors.pop("ctrlclick") - self.sideareas_color = colors.pop("sideareas") - self.matched_p_color = colors.pop("matched_p") - self.unmatched_p_color = colors.pop("unmatched_p") - for name, (color, bold, italic) in list(colors.items()): - format = QTextCharFormat(base_format) - format.setForeground(QColor(color)) - if bold: - format.setFontWeight(QFont.Bold) - format.setFontItalic(italic) - self.formats[name] = format - - def set_color_scheme(self, color_scheme): - if is_text_string(color_scheme): - self.color_scheme = get_color_scheme(color_scheme) - else: - self.color_scheme = color_scheme - - self.setup_formats() - self.rehighlight() - - @staticmethod - def _find_prev_non_blank_block(current_block): - previous_block = (current_block.previous() - if current_block.blockNumber() else None) - # find the previous non-blank block - while (previous_block and previous_block.blockNumber() and - previous_block.text().strip() == ''): - previous_block = previous_block.previous() - return previous_block - - def update_patterns(self, patterns): - """Update patterns to underline.""" - all_patterns = DEFAULT_PATTERNS.copy() - additional_patterns = patterns.copy() - - # Check that default keys are not overwritten - for key in DEFAULT_PATTERNS.keys(): - if key in additional_patterns: - # TODO: print warning or check this at the plugin level? - additional_patterns.pop(key) - all_patterns.update(additional_patterns) - - self.patterns = create_patterns(all_patterns, compile=True) - - def highlightBlock(self, text): - """ - Highlights a block of text. Please do not override, this method. - Instead you should implement - :func:`spyder.utils.syntaxhighplighters.SyntaxHighlighter.highlight_block`. - - :param text: text to highlight. - """ - self.highlight_block(text) - - def highlight_block(self, text): - """ - Abstract method. Override this to apply syntax highlighting. - - :param text: Line of text to highlight. - :param block: current block - """ - raise NotImplementedError() - - def highlight_patterns(self, text, offset=0): - """Highlight URI and mailto: patterns.""" - for match in self.patterns.finditer(text, offset): - for __, value in list(match.groupdict().items()): - if value: - start, end = get_span(match) - start = max([0, start + offset]) - end = max([0, end + offset]) - font = self.format(start) - font.setUnderlineStyle(QTextCharFormat.SingleUnderline) - self.setFormat(start, end - start, font) - - def highlight_spaces(self, text, offset=0): - """ - Make blank space less apparent by setting the foreground alpha. - This only has an effect when 'Show blank space' is turned on. - """ - flags_text = self.document().defaultTextOption().flags() - show_blanks = flags_text & QTextOption.ShowTabsAndSpaces - if show_blanks: - format_leading = self.formats.get("leading", None) - format_trailing = self.formats.get("trailing", None) - text = text[offset:] - for match in self.BLANKPROG.finditer(text): - start, end = get_span(match) - start = max([0, start+offset]) - end = max([0, end+offset]) - # Format trailing spaces at the end of the line. - if end == qstring_length(text) and format_trailing is not None: - self.setFormat(start, end - start, format_trailing) - # Format leading spaces, e.g. indentation. - if start == 0 and format_leading is not None: - self.setFormat(start, end - start, format_leading) - format = self.format(start) - color_foreground = format.foreground().color() - alpha_new = self.BLANK_ALPHA_FACTOR * color_foreground.alphaF() - color_foreground.setAlphaF(alpha_new) - self.setFormat(start, end - start, color_foreground) - - def highlight_extras(self, text, offset=0): - """ - Perform additional global text highlight. - - Derived classes could call this function at the end of - highlight_block(). - """ - self.highlight_spaces(text, offset=offset) - self.highlight_patterns(text, offset=offset) - - def rehighlight(self): - QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) - QSyntaxHighlighter.rehighlight(self) - QApplication.restoreOverrideCursor() - - -class TextSH(BaseSH): - """Simple Text Syntax Highlighter Class (only highlight spaces).""" - - def highlight_block(self, text): - """Implement highlight, only highlight spaces.""" - text = to_text_string(text) - self.setFormat(0, qstring_length(text), self.formats["normal"]) - self.highlight_extras(text) - - -class GenericSH(BaseSH): - """Generic Syntax Highlighter""" - # Syntax highlighting rules: - PROG = None # to be redefined in child classes - - def highlight_block(self, text): - """Implement highlight using regex defined in children classes.""" - text = to_text_string(text) - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - index = 0 - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - start, end = get_span(match, key) - index += end-start - self.setFormat(start, end-start, self.formats[key]) - - self.highlight_extras(text) - - -#============================================================================== -# Python syntax highlighter -#============================================================================== -def make_python_patterns(additional_keywords=[], additional_builtins=[]): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwlist = keyword.kwlist + additional_keywords - builtinlist = [str(name) for name in dir(builtins) - if not name.startswith('_')] + additional_builtins - repeated = set(kwlist) & set(builtinlist) - for repeated_element in repeated: - kwlist.remove(repeated_element) - kw = r"\b" + any("keyword", kwlist) + r"\b" - builtin = r"([^.'\"\\#]\b|^)" + any("builtin", builtinlist) + r"\b" - comment = any("comment", [r"#[^\n]*"]) - instance = any("instance", [r"\bself\b", - r"\bcls\b", - (r"^\s*@([a-zA-Z_][a-zA-Z0-9_]*)" - r"(\.[a-zA-Z_][a-zA-Z0-9_]*)*")]) - number_regex = [r"\b[+-]?[0-9]+[lLjJ]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?0[oO][0-7]+[lL]?\b", - r"\b[+-]?0[bB][01]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?[jJ]?\b"] - if PY3: - prefix = "r|u|R|U|f|F|fr|Fr|fR|FR|rf|rF|Rf|RF|b|B|br|Br|bR|BR|rb|rB|Rb|RB" - else: - prefix = "r|u|ur|R|U|UR|Ur|uR|b|B|br|Br|bR|BR" - sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*'?" % prefix - dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*"?' % prefix - uf_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(\\)$(?!')$" % prefix - uf_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(\\)$(?!")$' % prefix - ufe_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(?!\\)$(?!')$" % prefix - ufe_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(?!\\)$(?!")$' % prefix - sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" % prefix - dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' % prefix - uf_sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(\\)?(?!''')$" \ - % prefix - uf_dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(\\)?(?!""")$' \ - % prefix - # Needed to achieve correct highlighting in Python 3.6+ - # See spyder-ide/spyder#7324. - if PY36_OR_MORE: - # Based on - # https://github.com/python/cpython/blob/ - # 81950495ba2c36056e0ce48fd37d514816c26747/Lib/tokenize.py#L117 - # In order: Hexnumber, Binnumber, Octnumber, Decnumber, - # Pointfloat + Exponent, Expfloat, Imagnumber - number_regex = [ - r"\b[+-]?0[xX](?:_?[0-9A-Fa-f])+[lL]?\b", - r"\b[+-]?0[bB](?:_?[01])+[lL]?\b", - r"\b[+-]?0[oO](?:_?[0-7])+[lL]?\b", - r"\b[+-]?(?:0(?:_?0)*|[1-9](?:_?[0-9])*)[lL]?\b", - r"\b((\.[0-9](?:_?[0-9])*')|\.[0-9](?:_?[0-9])*)" - "([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", - r"\b[0-9](?:_?[0-9])*([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", - r"\b[0-9](?:_?[0-9])*[jJ]\b"] - number = any("number", number_regex) - - string = any("string", [sq3string, dq3string, sqstring, dqstring]) - ufstring1 = any("uf_sqstring", [uf_sqstring]) - ufstring2 = any("uf_dqstring", [uf_dqstring]) - ufstring3 = any("uf_sq3string", [uf_sq3string]) - ufstring4 = any("uf_dq3string", [uf_dq3string]) - ufstring5 = any("ufe_sqstring", [ufe_sqstring]) - ufstring6 = any("ufe_dqstring", [ufe_dqstring]) - return "|".join([instance, kw, builtin, comment, - ufstring1, ufstring2, ufstring3, ufstring4, ufstring5, - ufstring6, string, number, any("SYNC", [r"\n"])]) - - -def make_ipython_patterns(additional_keywords=[], additional_builtins=[]): - return (make_python_patterns(additional_keywords, additional_builtins) - + r"|^\s*%%?(?P[^\s]*)") - - -def get_code_cell_name(text): - """Returns a code cell name from a code cell comment.""" - name = text.strip().lstrip("#% ") - if name.startswith(""): - name = name[10:].lstrip() - elif name.startswith("In["): - name = name[2:] - if name.endswith("]:"): - name = name[:-1] - name = name.strip() - return name - - -class PythonSH(BaseSH): - """Python Syntax Highlighter""" - # Syntax highlighting rules: - add_kw = ['async', 'await'] - PROG = re.compile(make_python_patterns(additional_keywords=add_kw), re.S) - IDPROG = re.compile(r"\s+(\w+)", re.S) - ASPROG = re.compile(r"\b(as)\b") - # Syntax highlighting states (from one text block to another): - (NORMAL, INSIDE_SQ3STRING, INSIDE_DQ3STRING, - INSIDE_SQSTRING, INSIDE_DQSTRING, - INSIDE_NON_MULTILINE_STRING) = list(range(6)) - DEF_TYPES = {"def": OutlineExplorerData.FUNCTION, - "class": OutlineExplorerData.CLASS} - # Comments suitable for Outline Explorer - OECOMMENT = re.compile(r'^(# ?--[-]+|##[#]+ )[ -]*[^- ]+') - - def __init__(self, parent, font=None, color_scheme='Spyder'): - BaseSH.__init__(self, parent, font, color_scheme) - self.cell_separators = CELL_LANGUAGES['Python'] - # Avoid updating the outline explorer with every single letter typed - self.outline_explorer_data_update_timer = QTimer() - self.outline_explorer_data_update_timer.setSingleShot(True) - - def highlight_match(self, text, match, key, value, offset, - state, import_stmt, oedata): - """Highlight a single match.""" - start, end = get_span(match, key) - start = max([0, start+offset]) - end = max([0, end+offset]) - length = end - start - if key == "uf_sq3string": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_SQ3STRING - elif key == "uf_dq3string": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_DQ3STRING - elif key == "uf_sqstring": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_SQSTRING - elif key == "uf_dqstring": - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_DQSTRING - elif key in ["ufe_sqstring", "ufe_dqstring"]: - self.setFormat(start, length, self.formats["string"]) - state = self.INSIDE_NON_MULTILINE_STRING - else: - self.setFormat(start, length, self.formats[key]) - if key == "comment": - if text.lstrip().startswith(self.cell_separators): - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text).strip() - # cell_head: string containing the first group - # of '%'s in the cell header - cell_head = re.search(r"%+|$", text.lstrip()).group() - if cell_head == '': - oedata.cell_level = 0 - else: - oedata.cell_level = qstring_length(cell_head) - 2 - oedata.fold_level = start - oedata.def_type = OutlineExplorerData.CELL - def_name = get_code_cell_name(text) - oedata.def_name = def_name - # Keep list of cells for performence reasons - self._cell_list.append(oedata) - elif self.OECOMMENT.match(text.lstrip()): - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text).strip() - oedata.fold_level = start - oedata.def_type = OutlineExplorerData.COMMENT - oedata.def_name = text.strip() - elif key == "keyword": - if value in ("def", "class"): - match1 = self.IDPROG.match(text, end) - if match1: - start1, end1 = get_span(match1, 1) - self.setFormat(start1, end1-start1, - self.formats["definition"]) - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text) - oedata.fold_level = (qstring_length(text) - - qstring_length(text.lstrip())) - oedata.def_type = self.DEF_TYPES[to_text_string(value)] - oedata.def_name = text[start1:end1] - oedata.color = self.formats["definition"] - elif value in ("elif", "else", "except", "finally", - "for", "if", "try", "while", - "with"): - if text.lstrip().startswith(value): - oedata = OutlineExplorerData(self.currentBlock()) - oedata.text = to_text_string(text).strip() - oedata.fold_level = start - oedata.def_type = OutlineExplorerData.STATEMENT - oedata.def_name = text.strip() - elif value == "import": - import_stmt = text.strip() - # color all the "as" words on same line, except - # if in a comment; cheap approximation to the - # truth - if '#' in text: - endpos = qstring_length(text[:text.index('#')]) - else: - endpos = qstring_length(text) - while True: - match1 = self.ASPROG.match(text, end, endpos) - if not match1: - break - start, end = get_span(match1, 1) - self.setFormat(start, length, self.formats["keyword"]) - - return state, import_stmt, oedata - - def highlight_block(self, text): - """Implement specific highlight for Python.""" - text = to_text_string(text) - prev_state = tbh.get_state(self.currentBlock().previous()) - if prev_state == self.INSIDE_DQ3STRING: - offset = -4 - text = r'""" '+text - elif prev_state == self.INSIDE_SQ3STRING: - offset = -4 - text = r"''' "+text - elif prev_state == self.INSIDE_DQSTRING: - offset = -2 - text = r'" '+text - elif prev_state == self.INSIDE_SQSTRING: - offset = -2 - text = r"' "+text - else: - offset = 0 - prev_state = self.NORMAL - - oedata = None - import_stmt = None - - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - state = self.NORMAL - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - state, import_stmt, oedata = self.highlight_match( - text, match, key, value, offset, - state, import_stmt, oedata) - - tbh.set_state(self.currentBlock(), state) - - # Use normal format for indentation and trailing spaces - # Unless we are in a string - states_multiline_string = [ - self.INSIDE_DQ3STRING, self.INSIDE_SQ3STRING, - self.INSIDE_DQSTRING, self.INSIDE_SQSTRING] - states_string = states_multiline_string + [ - self.INSIDE_NON_MULTILINE_STRING] - self.formats['leading'] = self.formats['normal'] - if prev_state in states_multiline_string: - self.formats['leading'] = self.formats["string"] - self.formats['trailing'] = self.formats['normal'] - if state in states_string: - self.formats['trailing'] = self.formats['string'] - self.highlight_extras(text, offset) - - block = self.currentBlock() - data = block.userData() - - need_data = (oedata or import_stmt) - - if need_data and not data: - data = BlockUserData(self.editor) - - # Try updating - update = False - if oedata and data and data.oedata: - update = data.oedata.update(oedata) - - if data and not update: - data.oedata = oedata - self.outline_explorer_data_update_timer.start(500) - - if (import_stmt) or (data and data.import_statement): - data.import_statement = import_stmt - - block.setUserData(data) - - def get_import_statements(self): - """Get import statment list.""" - block = self.document().firstBlock() - statments = [] - while block.isValid(): - data = block.userData() - if data and data.import_statement: - statments.append(data.import_statement) - block = block.next() - return statments - - def rehighlight(self): - BaseSH.rehighlight(self) - - -# ============================================================================= -# IPython syntax highlighter -# ============================================================================= -class IPythonSH(PythonSH): - """IPython Syntax Highlighter""" - add_kw = ['async', 'await'] - PROG = re.compile(make_ipython_patterns(additional_keywords=add_kw), re.S) - - -#============================================================================== -# Cython syntax highlighter -#============================================================================== -C_TYPES = 'bool char double enum float int long mutable short signed struct unsigned void NULL' - -class CythonSH(PythonSH): - """Cython Syntax Highlighter""" - ADDITIONAL_KEYWORDS = [ - "cdef", "ctypedef", "cpdef", "inline", "cimport", "extern", - "include", "begin", "end", "by", "gil", "nogil", "const", "public", - "readonly", "fused", "static", "api", "DEF", "IF", "ELIF", "ELSE"] - - ADDITIONAL_BUILTINS = C_TYPES.split() + [ - "array", "bint", "Py_ssize_t", "intern", "reload", "sizeof", "NULL"] - PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, - ADDITIONAL_BUILTINS), re.S) - IDPROG = re.compile(r"\s+([\w\.]+)", re.S) - - -#============================================================================== -# Enaml syntax highlighter -#============================================================================== -class EnamlSH(PythonSH): - """Enaml Syntax Highlighter""" - ADDITIONAL_KEYWORDS = ["enamldef", "template", "attr", "event", "const", "alias", - "func"] - ADDITIONAL_BUILTINS = [] - PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, - ADDITIONAL_BUILTINS), re.S) - IDPROG = re.compile(r"\s+([\w\.]+)", re.S) - - -#============================================================================== -# C/C++ syntax highlighter -#============================================================================== -C_KEYWORDS1 = 'and and_eq bitand bitor break case catch const const_cast continue default delete do dynamic_cast else explicit export extern for friend goto if inline namespace new not not_eq operator or or_eq private protected public register reinterpret_cast return sizeof static static_cast switch template throw try typedef typeid typename union using virtual while xor xor_eq' -C_KEYWORDS2 = 'a addindex addtogroup anchor arg attention author b brief bug c class code date def defgroup deprecated dontinclude e em endcode endhtmlonly ifdef endif endlatexonly endlink endverbatim enum example exception f$ file fn hideinitializer htmlinclude htmlonly if image include ingroup internal invariant interface latexonly li line link mainpage name namespace nosubgrouping note overload p page par param post pre ref relates remarks return retval sa section see showinitializer since skip skipline subsection test throw todo typedef union until var verbatim verbinclude version warning weakgroup' -C_KEYWORDS3 = 'asm auto class compl false true volatile wchar_t' - -def make_generic_c_patterns(keywords, builtins, - instance=None, define=None, comment=None): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kw = r"\b" + any("keyword", keywords.split()) + r"\b" - builtin = r"\b" + any("builtin", builtins.split()+C_TYPES.split()) + r"\b" - if comment is None: - comment = any("comment", [r"//[^\n]*", r"\/\*(.*?)\*\/"]) - comment_start = any("comment_start", [r"\/\*"]) - comment_end = any("comment_end", [r"\*\/"]) - if instance is None: - instance = any("instance", [r"\bthis\b"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - if define is None: - define = any("define", [r"#[^\n]*"]) - return "|".join([instance, kw, comment, string, number, - comment_start, comment_end, builtin, - define, any("SYNC", [r"\n"])]) - -def make_cpp_patterns(): - return make_generic_c_patterns(C_KEYWORDS1+' '+C_KEYWORDS2, C_KEYWORDS3) - -class CppSH(BaseSH): - """C/C++ Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_cpp_patterns(), re.S) - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - INSIDE_COMMENT = 1 - def __init__(self, parent, font=None, color_scheme=None): - BaseSH.__init__(self, parent, font, color_scheme) - - def highlight_block(self, text): - """Implement highlight specific for C/C++.""" - text = to_text_string(text) - inside_comment = tbh.get_state(self.currentBlock().previous()) == self.INSIDE_COMMENT - self.setFormat(0, qstring_length(text), - self.formats["comment" if inside_comment else "normal"]) - - index = 0 - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - start, end = get_span(match, key) - index += end-start - if key == "comment_start": - inside_comment = True - self.setFormat(start, qstring_length(text)-start, - self.formats["comment"]) - elif key == "comment_end": - inside_comment = False - self.setFormat(start, end-start, - self.formats["comment"]) - elif inside_comment: - self.setFormat(start, end-start, - self.formats["comment"]) - elif key == "define": - self.setFormat(start, end-start, - self.formats["number"]) - else: - self.setFormat(start, end-start, self.formats[key]) - - self.highlight_extras(text) - - last_state = self.INSIDE_COMMENT if inside_comment else self.NORMAL - tbh.set_state(self.currentBlock(), last_state) - - -def make_opencl_patterns(): - # Keywords: - kwstr1 = 'cl_char cl_uchar cl_short cl_ushort cl_int cl_uint cl_long cl_ulong cl_half cl_float cl_double cl_platform_id cl_device_id cl_context cl_command_queue cl_mem cl_program cl_kernel cl_event cl_sampler cl_bool cl_bitfield cl_device_type cl_platform_info cl_device_info cl_device_address_info cl_device_fp_config cl_device_mem_cache_type cl_device_local_mem_type cl_device_exec_capabilities cl_command_queue_properties cl_context_properties cl_context_info cl_command_queue_info cl_channel_order cl_channel_type cl_mem_flags cl_mem_object_type cl_mem_info cl_image_info cl_addressing_mode cl_filter_mode cl_sampler_info cl_map_flags cl_program_info cl_program_build_info cl_build_status cl_kernel_info cl_kernel_work_group_info cl_event_info cl_command_type cl_profiling_info cl_image_format' - # Constants: - kwstr2 = 'CL_FALSE, CL_TRUE, CL_PLATFORM_PROFILE, CL_PLATFORM_VERSION, CL_PLATFORM_NAME, CL_PLATFORM_VENDOR, CL_PLATFORM_EXTENSIONS, CL_DEVICE_TYPE_DEFAULT , CL_DEVICE_TYPE_CPU, CL_DEVICE_TYPE_GPU, CL_DEVICE_TYPE_ACCELERATOR, CL_DEVICE_TYPE_ALL, CL_DEVICE_TYPE, CL_DEVICE_VENDOR_ID, CL_DEVICE_MAX_COMPUTE_UNITS, CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS, CL_DEVICE_MAX_WORK_GROUP_SIZE, CL_DEVICE_MAX_WORK_ITEM_SIZES, CL_DEVICE_PREFERRED_VECTOR_WIDTH_CHAR, CL_DEVICE_PREFERRED_VECTOR_WIDTH_SHORT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_INT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_LONG, CL_DEVICE_PREFERRED_VECTOR_WIDTH_FLOAT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLE, CL_DEVICE_MAX_CLOCK_FREQUENCY, CL_DEVICE_ADDRESS_BITS, CL_DEVICE_MAX_READ_IMAGE_ARGS, CL_DEVICE_MAX_WRITE_IMAGE_ARGS, CL_DEVICE_MAX_MEM_ALLOC_SIZE, CL_DEVICE_IMAGE2D_MAX_WIDTH, CL_DEVICE_IMAGE2D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_WIDTH, CL_DEVICE_IMAGE3D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_DEPTH, CL_DEVICE_IMAGE_SUPPORT, CL_DEVICE_MAX_PARAMETER_SIZE, CL_DEVICE_MAX_SAMPLERS, CL_DEVICE_MEM_BASE_ADDR_ALIGN, CL_DEVICE_MIN_DATA_TYPE_ALIGN_SIZE, CL_DEVICE_SINGLE_FP_CONFIG, CL_DEVICE_GLOBAL_MEM_CACHE_TYPE, CL_DEVICE_GLOBAL_MEM_CACHELINE_SIZE, CL_DEVICE_GLOBAL_MEM_CACHE_SIZE, CL_DEVICE_GLOBAL_MEM_SIZE, CL_DEVICE_MAX_CONSTANT_BUFFER_SIZE, CL_DEVICE_MAX_CONSTANT_ARGS, CL_DEVICE_LOCAL_MEM_TYPE, CL_DEVICE_LOCAL_MEM_SIZE, CL_DEVICE_ERROR_CORRECTION_SUPPORT, CL_DEVICE_PROFILING_TIMER_RESOLUTION, CL_DEVICE_ENDIAN_LITTLE, CL_DEVICE_AVAILABLE, CL_DEVICE_COMPILER_AVAILABLE, CL_DEVICE_EXECUTION_CAPABILITIES, CL_DEVICE_QUEUE_PROPERTIES, CL_DEVICE_NAME, CL_DEVICE_VENDOR, CL_DRIVER_VERSION, CL_DEVICE_PROFILE, CL_DEVICE_VERSION, CL_DEVICE_EXTENSIONS, CL_DEVICE_PLATFORM, CL_FP_DENORM, CL_FP_INF_NAN, CL_FP_ROUND_TO_NEAREST, CL_FP_ROUND_TO_ZERO, CL_FP_ROUND_TO_INF, CL_FP_FMA, CL_NONE, CL_READ_ONLY_CACHE, CL_READ_WRITE_CACHE, CL_LOCAL, CL_GLOBAL, CL_EXEC_KERNEL, CL_EXEC_NATIVE_KERNEL, CL_QUEUE_OUT_OF_ORDER_EXEC_MODE_ENABLE, CL_QUEUE_PROFILING_ENABLE, CL_CONTEXT_REFERENCE_COUNT, CL_CONTEXT_DEVICES, CL_CONTEXT_PROPERTIES, CL_CONTEXT_PLATFORM, CL_QUEUE_CONTEXT, CL_QUEUE_DEVICE, CL_QUEUE_REFERENCE_COUNT, CL_QUEUE_PROPERTIES, CL_MEM_READ_WRITE, CL_MEM_WRITE_ONLY, CL_MEM_READ_ONLY, CL_MEM_USE_HOST_PTR, CL_MEM_ALLOC_HOST_PTR, CL_MEM_COPY_HOST_PTR, CL_R, CL_A, CL_RG, CL_RA, CL_RGB, CL_RGBA, CL_BGRA, CL_ARGB, CL_INTENSITY, CL_LUMINANCE, CL_SNORM_INT8, CL_SNORM_INT16, CL_UNORM_INT8, CL_UNORM_INT16, CL_UNORM_SHORT_565, CL_UNORM_SHORT_555, CL_UNORM_INT_101010, CL_SIGNED_INT8, CL_SIGNED_INT16, CL_SIGNED_INT32, CL_UNSIGNED_INT8, CL_UNSIGNED_INT16, CL_UNSIGNED_INT32, CL_HALF_FLOAT, CL_FLOAT, CL_MEM_OBJECT_BUFFER, CL_MEM_OBJECT_IMAGE2D, CL_MEM_OBJECT_IMAGE3D, CL_MEM_TYPE, CL_MEM_FLAGS, CL_MEM_SIZECL_MEM_HOST_PTR, CL_MEM_HOST_PTR, CL_MEM_MAP_COUNT, CL_MEM_REFERENCE_COUNT, CL_MEM_CONTEXT, CL_IMAGE_FORMAT, CL_IMAGE_ELEMENT_SIZE, CL_IMAGE_ROW_PITCH, CL_IMAGE_SLICE_PITCH, CL_IMAGE_WIDTH, CL_IMAGE_HEIGHT, CL_IMAGE_DEPTH, CL_ADDRESS_NONE, CL_ADDRESS_CLAMP_TO_EDGE, CL_ADDRESS_CLAMP, CL_ADDRESS_REPEAT, CL_FILTER_NEAREST, CL_FILTER_LINEAR, CL_SAMPLER_REFERENCE_COUNT, CL_SAMPLER_CONTEXT, CL_SAMPLER_NORMALIZED_COORDS, CL_SAMPLER_ADDRESSING_MODE, CL_SAMPLER_FILTER_MODE, CL_MAP_READ, CL_MAP_WRITE, CL_PROGRAM_REFERENCE_COUNT, CL_PROGRAM_CONTEXT, CL_PROGRAM_NUM_DEVICES, CL_PROGRAM_DEVICES, CL_PROGRAM_SOURCE, CL_PROGRAM_BINARY_SIZES, CL_PROGRAM_BINARIES, CL_PROGRAM_BUILD_STATUS, CL_PROGRAM_BUILD_OPTIONS, CL_PROGRAM_BUILD_LOG, CL_BUILD_SUCCESS, CL_BUILD_NONE, CL_BUILD_ERROR, CL_BUILD_IN_PROGRESS, CL_KERNEL_FUNCTION_NAME, CL_KERNEL_NUM_ARGS, CL_KERNEL_REFERENCE_COUNT, CL_KERNEL_CONTEXT, CL_KERNEL_PROGRAM, CL_KERNEL_WORK_GROUP_SIZE, CL_KERNEL_COMPILE_WORK_GROUP_SIZE, CL_KERNEL_LOCAL_MEM_SIZE, CL_EVENT_COMMAND_QUEUE, CL_EVENT_COMMAND_TYPE, CL_EVENT_REFERENCE_COUNT, CL_EVENT_COMMAND_EXECUTION_STATUS, CL_COMMAND_NDRANGE_KERNEL, CL_COMMAND_TASK, CL_COMMAND_NATIVE_KERNEL, CL_COMMAND_READ_BUFFER, CL_COMMAND_WRITE_BUFFER, CL_COMMAND_COPY_BUFFER, CL_COMMAND_READ_IMAGE, CL_COMMAND_WRITE_IMAGE, CL_COMMAND_COPY_IMAGE, CL_COMMAND_COPY_IMAGE_TO_BUFFER, CL_COMMAND_COPY_BUFFER_TO_IMAGE, CL_COMMAND_MAP_BUFFER, CL_COMMAND_MAP_IMAGE, CL_COMMAND_UNMAP_MEM_OBJECT, CL_COMMAND_MARKER, CL_COMMAND_ACQUIRE_GL_OBJECTS, CL_COMMAND_RELEASE_GL_OBJECTS, command execution status, CL_COMPLETE, CL_RUNNING, CL_SUBMITTED, CL_QUEUED, CL_PROFILING_COMMAND_QUEUED, CL_PROFILING_COMMAND_SUBMIT, CL_PROFILING_COMMAND_START, CL_PROFILING_COMMAND_END, CL_CHAR_BIT, CL_SCHAR_MAX, CL_SCHAR_MIN, CL_CHAR_MAX, CL_CHAR_MIN, CL_UCHAR_MAX, CL_SHRT_MAX, CL_SHRT_MIN, CL_USHRT_MAX, CL_INT_MAX, CL_INT_MIN, CL_UINT_MAX, CL_LONG_MAX, CL_LONG_MIN, CL_ULONG_MAX, CL_FLT_DIG, CL_FLT_MANT_DIG, CL_FLT_MAX_10_EXP, CL_FLT_MAX_EXP, CL_FLT_MIN_10_EXP, CL_FLT_MIN_EXP, CL_FLT_RADIX, CL_FLT_MAX, CL_FLT_MIN, CL_FLT_EPSILON, CL_DBL_DIG, CL_DBL_MANT_DIG, CL_DBL_MAX_10_EXP, CL_DBL_MAX_EXP, CL_DBL_MIN_10_EXP, CL_DBL_MIN_EXP, CL_DBL_RADIX, CL_DBL_MAX, CL_DBL_MIN, CL_DBL_EPSILON, CL_SUCCESS, CL_DEVICE_NOT_FOUND, CL_DEVICE_NOT_AVAILABLE, CL_COMPILER_NOT_AVAILABLE, CL_MEM_OBJECT_ALLOCATION_FAILURE, CL_OUT_OF_RESOURCES, CL_OUT_OF_HOST_MEMORY, CL_PROFILING_INFO_NOT_AVAILABLE, CL_MEM_COPY_OVERLAP, CL_IMAGE_FORMAT_MISMATCH, CL_IMAGE_FORMAT_NOT_SUPPORTED, CL_BUILD_PROGRAM_FAILURE, CL_MAP_FAILURE, CL_INVALID_VALUE, CL_INVALID_DEVICE_TYPE, CL_INVALID_PLATFORM, CL_INVALID_DEVICE, CL_INVALID_CONTEXT, CL_INVALID_QUEUE_PROPERTIES, CL_INVALID_COMMAND_QUEUE, CL_INVALID_HOST_PTR, CL_INVALID_MEM_OBJECT, CL_INVALID_IMAGE_FORMAT_DESCRIPTOR, CL_INVALID_IMAGE_SIZE, CL_INVALID_SAMPLER, CL_INVALID_BINARY, CL_INVALID_BUILD_OPTIONS, CL_INVALID_PROGRAM, CL_INVALID_PROGRAM_EXECUTABLE, CL_INVALID_KERNEL_NAME, CL_INVALID_KERNEL_DEFINITION, CL_INVALID_KERNEL, CL_INVALID_ARG_INDEX, CL_INVALID_ARG_VALUE, CL_INVALID_ARG_SIZE, CL_INVALID_KERNEL_ARGS, CL_INVALID_WORK_DIMENSION, CL_INVALID_WORK_GROUP_SIZE, CL_INVALID_WORK_ITEM_SIZE, CL_INVALID_GLOBAL_OFFSET, CL_INVALID_EVENT_WAIT_LIST, CL_INVALID_EVENT, CL_INVALID_OPERATION, CL_INVALID_GL_OBJECT, CL_INVALID_BUFFER_SIZE, CL_INVALID_MIP_LEVEL, CL_INVALID_GLOBAL_WORK_SIZE' - # Functions: - builtins = 'clGetPlatformIDs, clGetPlatformInfo, clGetDeviceIDs, clGetDeviceInfo, clCreateContext, clCreateContextFromType, clReleaseContext, clGetContextInfo, clCreateCommandQueue, clRetainCommandQueue, clReleaseCommandQueue, clGetCommandQueueInfo, clSetCommandQueueProperty, clCreateBuffer, clCreateImage2D, clCreateImage3D, clRetainMemObject, clReleaseMemObject, clGetSupportedImageFormats, clGetMemObjectInfo, clGetImageInfo, clCreateSampler, clRetainSampler, clReleaseSampler, clGetSamplerInfo, clCreateProgramWithSource, clCreateProgramWithBinary, clRetainProgram, clReleaseProgram, clBuildProgram, clUnloadCompiler, clGetProgramInfo, clGetProgramBuildInfo, clCreateKernel, clCreateKernelsInProgram, clRetainKernel, clReleaseKernel, clSetKernelArg, clGetKernelInfo, clGetKernelWorkGroupInfo, clWaitForEvents, clGetEventInfo, clRetainEvent, clReleaseEvent, clGetEventProfilingInfo, clFlush, clFinish, clEnqueueReadBuffer, clEnqueueWriteBuffer, clEnqueueCopyBuffer, clEnqueueReadImage, clEnqueueWriteImage, clEnqueueCopyImage, clEnqueueCopyImageToBuffer, clEnqueueCopyBufferToImage, clEnqueueMapBuffer, clEnqueueMapImage, clEnqueueUnmapMemObject, clEnqueueNDRangeKernel, clEnqueueTask, clEnqueueNativeKernel, clEnqueueMarker, clEnqueueWaitForEvents, clEnqueueBarrier' - # Qualifiers: - qualifiers = '__global __local __constant __private __kernel' - keyword_list = C_KEYWORDS1+' '+C_KEYWORDS2+' '+kwstr1+' '+kwstr2 - builtin_list = C_KEYWORDS3+' '+builtins+' '+qualifiers - return make_generic_c_patterns(keyword_list, builtin_list) - -class OpenCLSH(CppSH): - """OpenCL Syntax Highlighter""" - PROG = re.compile(make_opencl_patterns(), re.S) - - -#============================================================================== -# Fortran Syntax Highlighter -#============================================================================== -def make_fortran_patterns(): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwstr = 'access action advance allocatable allocate apostrophe assign assignment associate asynchronous backspace bind blank blockdata call case character class close common complex contains continue cycle data deallocate decimal delim default dimension direct do dowhile double doubleprecision else elseif elsewhere encoding end endassociate endblockdata enddo endfile endforall endfunction endif endinterface endmodule endprogram endselect endsubroutine endtype endwhere entry eor equivalence err errmsg exist exit external file flush fmt forall form format formatted function go goto id if implicit in include inout integer inquire intent interface intrinsic iomsg iolength iostat kind len logical module name named namelist nextrec nml none nullify number only open opened operator optional out pad parameter pass pause pending pointer pos position precision print private program protected public quote read readwrite real rec recl recursive result return rewind save select selectcase selecttype sequential sign size stat status stop stream subroutine target then to type unformatted unit use value volatile wait where while write' - bistr1 = 'abs achar acos acosd adjustl adjustr aimag aimax0 aimin0 aint ajmax0 ajmin0 akmax0 akmin0 all allocated alog alog10 amax0 amax1 amin0 amin1 amod anint any asin asind associated atan atan2 atan2d atand bitest bitl bitlr bitrl bjtest bit_size bktest break btest cabs ccos cdabs cdcos cdexp cdlog cdsin cdsqrt ceiling cexp char clog cmplx conjg cos cosd cosh count cpu_time cshift csin csqrt dabs dacos dacosd dasin dasind datan datan2 datan2d datand date date_and_time dble dcmplx dconjg dcos dcosd dcosh dcotan ddim dexp dfloat dflotk dfloti dflotj digits dim dimag dint dlog dlog10 dmax1 dmin1 dmod dnint dot_product dprod dreal dsign dsin dsind dsinh dsqrt dtan dtand dtanh eoshift epsilon errsns exp exponent float floati floatj floatk floor fraction free huge iabs iachar iand ibclr ibits ibset ichar idate idim idint idnint ieor ifix iiabs iiand iibclr iibits iibset iidim iidint iidnnt iieor iifix iint iior iiqint iiqnnt iishft iishftc iisign ilen imax0 imax1 imin0 imin1 imod index inint inot int int1 int2 int4 int8 iqint iqnint ior ishft ishftc isign isnan izext jiand jibclr jibits jibset jidim jidint jidnnt jieor jifix jint jior jiqint jiqnnt jishft jishftc jisign jmax0 jmax1 jmin0 jmin1 jmod jnint jnot jzext kiabs kiand kibclr kibits kibset kidim kidint kidnnt kieor kifix kind kint kior kishft kishftc kisign kmax0 kmax1 kmin0 kmin1 kmod knint knot kzext lbound leadz len len_trim lenlge lge lgt lle llt log log10 logical lshift malloc matmul max max0 max1 maxexponent maxloc maxval merge min min0 min1 minexponent minloc minval mod modulo mvbits nearest nint not nworkers number_of_processors pack popcnt poppar precision present product radix random random_number random_seed range real repeat reshape rrspacing rshift scale scan secnds selected_int_kind selected_real_kind set_exponent shape sign sin sind sinh size sizeof sngl snglq spacing spread sqrt sum system_clock tan tand tanh tiny transfer transpose trim ubound unpack verify' - bistr2 = 'cdabs cdcos cdexp cdlog cdsin cdsqrt cotan cotand dcmplx dconjg dcotan dcotand decode dimag dll_export dll_import doublecomplex dreal dvchk encode find flen flush getarg getcharqq getcl getdat getenv gettim hfix ibchng identifier imag int1 int2 int4 intc intrup invalop iostat_msg isha ishc ishl jfix lacfar locking locnear map nargs nbreak ndperr ndpexc offset ovefl peekcharqq precfill prompt qabs qacos qacosd qasin qasind qatan qatand qatan2 qcmplx qconjg qcos qcosd qcosh qdim qexp qext qextd qfloat qimag qlog qlog10 qmax1 qmin1 qmod qreal qsign qsin qsind qsinh qsqrt qtan qtand qtanh ran rand randu rewrite segment setdat settim system timer undfl unlock union val virtual volatile zabs zcos zexp zlog zsin zsqrt' - kw = r"\b" + any("keyword", kwstr.split()) + r"\b" - builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" - comment = any("comment", [r"\![^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, comment, string, number, builtin, - any("SYNC", [r"\n"])]) - -class FortranSH(BaseSH): - """Fortran Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_fortran_patterns(), re.S|re.I) - IDPROG = re.compile(r"\s+(\w+)", re.S) - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - def __init__(self, parent, font=None, color_scheme=None): - BaseSH.__init__(self, parent, font, color_scheme) - - def highlight_block(self, text): - """Implement highlight specific for Fortran.""" - text = to_text_string(text) - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - index = 0 - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - if value: - start, end = get_span(match, key) - index += end-start - self.setFormat(start, end-start, self.formats[key]) - if value.lower() in ("subroutine", "module", "function"): - match1 = self.IDPROG.match(text, end) - if match1: - start1, end1 = get_span(match1, 1) - self.setFormat(start1, end1-start1, - self.formats["definition"]) - - self.highlight_extras(text) - -class Fortran77SH(FortranSH): - """Fortran 77 Syntax Highlighter""" - def highlight_block(self, text): - """Implement highlight specific for Fortran77.""" - text = to_text_string(text) - if text.startswith(("c", "C")): - self.setFormat(0, qstring_length(text), self.formats["comment"]) - self.highlight_extras(text) - else: - FortranSH.highlight_block(self, text) - self.setFormat(0, 5, self.formats["comment"]) - self.setFormat(73, max([73, qstring_length(text)]), - self.formats["comment"]) - - -#============================================================================== -# IDL highlighter -# -# Contribution from Stuart Mumford (Littlemumford) - 2012-02-02 -# See spyder-ide/spyder#850. -#============================================================================== -def make_idl_patterns(): - """Strongly inspired by idlelib.ColorDelegator.make_pat.""" - kwstr = 'begin of pro function endfor endif endwhile endrep endcase endswitch end if then else for do while repeat until break case switch common continue exit return goto help message print read retall stop' - bistr1 = 'a_correlate abs acos adapt_hist_equal alog alog10 amoeba arg_present arra_equal array_indices ascii_template asin assoc atan beseli beselj besel k besely beta bilinear bin_date binary_template dinfgen dinomial blk_con broyden bytarr byte bytscl c_correlate call_external call_function ceil chebyshev check_math chisqr_cvf chisqr_pdf choldc cholsol cindgen clust_wts cluster color_quan colormap_applicable comfit complex complexarr complexround compute_mesh_normals cond congrid conj convert_coord convol coord2to3 correlate cos cosh cramer create_struct crossp crvlength ct_luminance cti_test curvefit cv_coord cvttobm cw_animate cw_arcball cw_bgroup cw_clr_index cw_colorsel cw_defroi cw_field cw_filesel cw_form cw_fslider cw_light_editor cw_orient cw_palette_editor cw_pdmenu cw_rgbslider cw_tmpl cw_zoom dblarr dcindgen dcomplexarr defroi deriv derivsig determ diag_matrix dialog_message dialog_pickfile pialog_printersetup dialog_printjob dialog_read_image dialog_write_image digital_filter dilate dindgen dist double eigenql eigenvec elmhes eof erode erf erfc erfcx execute exp expand_path expint extrac extract_slice f_cvf f_pdf factorial fft file_basename file_dirname file_expand_path file_info file_same file_search file_test file_which filepath findfile findgen finite fix float floor fltarr format_axis_values fstat fulstr fv_test fx_root fz_roots gamma gauss_cvf gauss_pdf gauss2dfit gaussfit gaussint get_drive_list get_kbrd get_screen_size getenv grid_tps grid3 griddata gs_iter hanning hdf_browser hdf_read hilbert hist_2d hist_equal histogram hough hqr ibeta identity idl_validname idlitsys_createtool igamma imaginary indgen int_2d int_3d int_tabulated intarr interpol interpolate invert ioctl ishft julday keword_set krig2d kurtosis kw_test l64indgen label_date label_region ladfit laguerre la_cholmprove la_cholsol la_Determ la_eigenproblem la_eigenql la_eigenvec la_elmhes la_gm_linear_model la_hqr la_invert la_least_square_equality la_least_squares la_linear_equation la_lumprove la_lusol la_trimprove la_trisol leefit legendre linbcg lindgen linfit ll_arc_distance lmfit lmgr lngamma lnp_test locale_get logical_and logical_or logical_true lon64arr lonarr long long64 lsode lu_complex lumprove lusol m_correlate machar make_array map_2points map_image map_patch map_proj_forward map_proj_init map_proj_inverse matrix_multiply matrix_power max md_test mean meanabsdev median memory mesh_clip mesh_decimate mesh_issolid mesh_merge mesh_numtriangles mesh_smooth mesh_surfacearea mesh_validate mesh_volume min min_curve_surf moment morph_close morph_distance morph_gradient morph_histormiss morph_open morph_thin morph_tophat mpeg_open msg_cat_open n_elements n_params n_tags newton norm obj_class obj_isa obj_new obj_valid objarr p_correlate path_sep pcomp pnt_line polar_surface poly poly_2d poly_area poly_fit polyfillv ployshade primes product profile profiles project_vol ptr_new ptr_valid ptrarr qgrid3 qromb qromo qsimp query_bmp query_dicom query_image query_jpeg query_mrsid query_pict query_png query_ppm query_srf query_tiff query_wav r_correlate r_test radon randomn randomu ranks read_ascii read_binary read_bmp read_dicom read_image read_mrsid read_png read_spr read_sylk read_tiff read_wav read_xwd real_part rebin recall_commands recon3 reform region_grow regress replicate reverse rk4 roberts rot rotate round routine_info rs_test s_test savgol search2d search3d sfit shift shmdebug shmvar simplex sin sindgen sinh size skewness smooth sobel sort sph_scat spher_harm spl_init spl_interp spline spline_p sprsab sprsax sprsin sprstp sqrt standardize stddev strarr strcmp strcompress stregex string strjoin strlen strlowcase strmatch strmessage strmid strpos strsplit strtrim strupcase svdfit svsol swap_endian systime t_cvf t_pdf tag_names tan tanh temporary tetra_clip tetra_surface tetra_volume thin timegen tm_test total trace transpose tri_surf trigrid trisol ts_coef ts_diff ts_fcast ts_smooth tvrd uindgen unit uintarr ul64indgen ulindgen ulon64arr ulonarr ulong ulong64 uniq value_locate variance vert_t3d voigt voxel_proj warp_tri watershed where widget_actevix widget_base widget_button widget_combobox widget_draw widget_droplist widget_event widget_info widget_label widget_list widget_propertsheet widget_slider widget_tab widget_table widget_text widget_tree write_sylk wtn xfont xregistered xsq_test' - bistr2 = 'annotate arrow axis bar_plot blas_axpy box_cursor breakpoint byteorder caldata calendar call_method call_procedure catch cd cir_3pnt close color_convert compile_opt constrained_min contour copy_lun cpu create_view cursor cw_animate_getp cw_animate_load cw_animate_run cw_light_editor_get cw_light_editor_set cw_palette_editor_get cw_palette_editor_set define_key define_msgblk define_msgblk_from_file defsysv delvar device dfpmin dissolve dlm_load doc_librar draw_roi efont empty enable_sysrtn erase errplot expand file_chmod file_copy file_delete file_lines file_link file_mkdir file_move file_readlink flick flow3 flush forward_function free_lun funct gamma_ct get_lun grid_input h_eq_ct h_eq_int heap_free heap_gc hls hsv icontour iimage image_cont image_statistics internal_volume iplot isocontour isosurface isurface itcurrent itdelete itgetcurrent itregister itreset ivolume journal la_choldc la_ludc la_svd la_tridc la_triql la_trired linkimage loadct ludc make_dll map_continents map_grid map_proj_info map_set mesh_obj mk_html_help modifyct mpeg_close mpeg_put mpeg_save msg_cat_close msg_cat_compile multi obj_destroy on_error on_ioerror online_help openr openw openu oplot oploterr particle_trace path_cache plot plot_3dbox plot_field ploterr plots point_lun polar_contour polyfill polywarp popd powell printf printd ps_show_fonts psafm pseudo ptr_free pushd qhull rdpix readf read_interfile read_jpeg read_pict read_ppm read_srf read_wave read_x11_bitmap reads readu reduce_colors register_cursor replicate_inplace resolve_all resolve_routine restore save scale3 scale3d set_plot set_shading setenv setup_keys shade_surf shade_surf_irr shade_volume shmmap show3 showfont skip_lun slicer3 slide_image socket spawn sph_4pnt streamline stretch strput struct_assign struct_hide surface surfr svdc swap_enian_inplace t3d tek_color threed time_test2 triangulate triql trired truncate_lun tv tvcrs tvlct tvscl usersym vector_field vel velovect voronoi wait wdelete wf_draw widget_control widget_displaycontextmenu window write_bmp write_image write_jpeg write_nrif write_pict write_png write_ppm write_spr write_srf write_tiff write_wav write_wave writeu wset wshow xbm_edit xdisplayfile xdxf xinteranimate xloadct xmanager xmng_tmpl xmtool xobjview xobjview_rotate xobjview_write_image xpalette xpcolo xplot3d xroi xsurface xvaredit xvolume xyouts zoom zoom_24' - kw = r"\b" + any("keyword", kwstr.split()) + r"\b" - builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" - comment = any("comment", [r"\;[^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b\.[0-9]d0|\.d0+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, comment, string, number, builtin, - any("SYNC", [r"\n"])]) - -class IdlSH(GenericSH): - """IDL Syntax Highlighter""" - PROG = re.compile(make_idl_patterns(), re.S|re.I) - - -#============================================================================== -# Diff/Patch highlighter -#============================================================================== -class DiffSH(BaseSH): - """Simple Diff/Patch Syntax Highlighter Class""" - def highlight_block(self, text): - """Implement highlight specific Diff/Patch files.""" - text = to_text_string(text) - if text.startswith("+++"): - self.setFormat(0, qstring_length(text), self.formats["keyword"]) - elif text.startswith("---"): - self.setFormat(0, qstring_length(text), self.formats["keyword"]) - elif text.startswith("+"): - self.setFormat(0, qstring_length(text), self.formats["string"]) - elif text.startswith("-"): - self.setFormat(0, qstring_length(text), self.formats["number"]) - elif text.startswith("@"): - self.setFormat(0, qstring_length(text), self.formats["builtin"]) - - self.highlight_extras(text) - -#============================================================================== -# NSIS highlighter -#============================================================================== -def make_nsis_patterns(): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwstr1 = 'Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exec ExecShell ExecWait Exch ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileSeek FileWrite FileWriteByte FindClose FindFirst FindNext FindWindow FlushINI Function FunctionEnd GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetLabelAddress GetTempFileName Goto HideWindow ChangeUI CheckBitmap Icon IfAbort IfErrors IfFileExists IfRebootFlag IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText IntCmp IntCmpU IntFmt IntOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadLanguageFile LogSet LogText MessageBox MiscButtonText Name OutFile Page PageCallbacks PageEx PageExEnd Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename ReserveFile Return RMDir SearchPath Section SectionEnd SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetPluginUnload SetRebootFlag SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCpy StrLen SubCaption SubSection SubSectionEnd UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegStr WriteUninstaller XPStyle' - kwstr2 = 'all alwaysoff ARCHIVE auto both bzip2 components current custom details directory false FILE_ATTRIBUTE_ARCHIVE FILE_ATTRIBUTE_HIDDEN FILE_ATTRIBUTE_NORMAL FILE_ATTRIBUTE_OFFLINE FILE_ATTRIBUTE_READONLY FILE_ATTRIBUTE_SYSTEM FILE_ATTRIBUTE_TEMPORARY force grey HIDDEN hide IDABORT IDCANCEL IDIGNORE IDNO IDOK IDRETRY IDYES ifdiff ifnewer instfiles instfiles lastused leave left level license listonly lzma manual MB_ABORTRETRYIGNORE MB_DEFBUTTON1 MB_DEFBUTTON2 MB_DEFBUTTON3 MB_DEFBUTTON4 MB_ICONEXCLAMATION MB_ICONINFORMATION MB_ICONQUESTION MB_ICONSTOP MB_OK MB_OKCANCEL MB_RETRYCANCEL MB_RIGHT MB_SETFOREGROUND MB_TOPMOST MB_YESNO MB_YESNOCANCEL nevershow none NORMAL off OFFLINE on READONLY right RO show silent silentlog SYSTEM TEMPORARY text textonly true try uninstConfirm windows zlib' - kwstr3 = 'MUI_ABORTWARNING MUI_ABORTWARNING_CANCEL_DEFAULT MUI_ABORTWARNING_TEXT MUI_BGCOLOR MUI_COMPONENTSPAGE_CHECKBITMAP MUI_COMPONENTSPAGE_NODESC MUI_COMPONENTSPAGE_SMALLDESC MUI_COMPONENTSPAGE_TEXT_COMPLIST MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_INFO MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_TITLE MUI_COMPONENTSPAGE_TEXT_INSTTYPE MUI_COMPONENTSPAGE_TEXT_TOP MUI_CUSTOMFUNCTION_ABORT MUI_CUSTOMFUNCTION_GUIINIT MUI_CUSTOMFUNCTION_UNABORT MUI_CUSTOMFUNCTION_UNGUIINIT MUI_DESCRIPTION_TEXT MUI_DIRECTORYPAGE_BGCOLOR MUI_DIRECTORYPAGE_TEXT_DESTINATION MUI_DIRECTORYPAGE_TEXT_TOP MUI_DIRECTORYPAGE_VARIABLE MUI_DIRECTORYPAGE_VERIFYONLEAVE MUI_FINISHPAGE_BUTTON MUI_FINISHPAGE_CANCEL_ENABLED MUI_FINISHPAGE_LINK MUI_FINISHPAGE_LINK_COLOR MUI_FINISHPAGE_LINK_LOCATION MUI_FINISHPAGE_NOAUTOCLOSE MUI_FINISHPAGE_NOREBOOTSUPPORT MUI_FINISHPAGE_REBOOTLATER_DEFAULT MUI_FINISHPAGE_RUN MUI_FINISHPAGE_RUN_FUNCTION MUI_FINISHPAGE_RUN_NOTCHECKED MUI_FINISHPAGE_RUN_PARAMETERS MUI_FINISHPAGE_RUN_TEXT MUI_FINISHPAGE_SHOWREADME MUI_FINISHPAGE_SHOWREADME_FUNCTION MUI_FINISHPAGE_SHOWREADME_NOTCHECKED MUI_FINISHPAGE_SHOWREADME_TEXT MUI_FINISHPAGE_TEXT MUI_FINISHPAGE_TEXT_LARGE MUI_FINISHPAGE_TEXT_REBOOT MUI_FINISHPAGE_TEXT_REBOOTLATER MUI_FINISHPAGE_TEXT_REBOOTNOW MUI_FINISHPAGE_TITLE MUI_FINISHPAGE_TITLE_3LINES MUI_FUNCTION_DESCRIPTION_BEGIN MUI_FUNCTION_DESCRIPTION_END MUI_HEADER_TEXT MUI_HEADER_TRANSPARENT_TEXT MUI_HEADERIMAGE MUI_HEADERIMAGE_BITMAP MUI_HEADERIMAGE_BITMAP_NOSTRETCH MUI_HEADERIMAGE_BITMAP_RTL MUI_HEADERIMAGE_BITMAP_RTL_NOSTRETCH MUI_HEADERIMAGE_RIGHT MUI_HEADERIMAGE_UNBITMAP MUI_HEADERIMAGE_UNBITMAP_NOSTRETCH MUI_HEADERIMAGE_UNBITMAP_RTL MUI_HEADERIMAGE_UNBITMAP_RTL_NOSTRETCH MUI_HWND MUI_ICON MUI_INSTALLCOLORS MUI_INSTALLOPTIONS_DISPLAY MUI_INSTALLOPTIONS_DISPLAY_RETURN MUI_INSTALLOPTIONS_EXTRACT MUI_INSTALLOPTIONS_EXTRACT_AS MUI_INSTALLOPTIONS_INITDIALOG MUI_INSTALLOPTIONS_READ MUI_INSTALLOPTIONS_SHOW MUI_INSTALLOPTIONS_SHOW_RETURN MUI_INSTALLOPTIONS_WRITE MUI_INSTFILESPAGE_ABORTHEADER_SUBTEXT MUI_INSTFILESPAGE_ABORTHEADER_TEXT MUI_INSTFILESPAGE_COLORS MUI_INSTFILESPAGE_FINISHHEADER_SUBTEXT MUI_INSTFILESPAGE_FINISHHEADER_TEXT MUI_INSTFILESPAGE_PROGRESSBAR MUI_LANGDLL_ALLLANGUAGES MUI_LANGDLL_ALWAYSSHOW MUI_LANGDLL_DISPLAY MUI_LANGDLL_INFO MUI_LANGDLL_REGISTRY_KEY MUI_LANGDLL_REGISTRY_ROOT MUI_LANGDLL_REGISTRY_VALUENAME MUI_LANGDLL_WINDOWTITLE MUI_LANGUAGE MUI_LICENSEPAGE_BGCOLOR MUI_LICENSEPAGE_BUTTON MUI_LICENSEPAGE_CHECKBOX MUI_LICENSEPAGE_CHECKBOX_TEXT MUI_LICENSEPAGE_RADIOBUTTONS MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_ACCEPT MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_DECLINE MUI_LICENSEPAGE_TEXT_BOTTOM MUI_LICENSEPAGE_TEXT_TOP MUI_PAGE_COMPONENTS MUI_PAGE_CUSTOMFUNCTION_LEAVE MUI_PAGE_CUSTOMFUNCTION_PRE MUI_PAGE_CUSTOMFUNCTION_SHOW MUI_PAGE_DIRECTORY MUI_PAGE_FINISH MUI_PAGE_HEADER_SUBTEXT MUI_PAGE_HEADER_TEXT MUI_PAGE_INSTFILES MUI_PAGE_LICENSE MUI_PAGE_STARTMENU MUI_PAGE_WELCOME MUI_RESERVEFILE_INSTALLOPTIONS MUI_RESERVEFILE_LANGDLL MUI_SPECIALINI MUI_STARTMENU_GETFOLDER MUI_STARTMENU_WRITE_BEGIN MUI_STARTMENU_WRITE_END MUI_STARTMENUPAGE_BGCOLOR MUI_STARTMENUPAGE_DEFAULTFOLDER MUI_STARTMENUPAGE_NODISABLE MUI_STARTMENUPAGE_REGISTRY_KEY MUI_STARTMENUPAGE_REGISTRY_ROOT MUI_STARTMENUPAGE_REGISTRY_VALUENAME MUI_STARTMENUPAGE_TEXT_CHECKBOX MUI_STARTMENUPAGE_TEXT_TOP MUI_UI MUI_UI_COMPONENTSPAGE_NODESC MUI_UI_COMPONENTSPAGE_SMALLDESC MUI_UI_HEADERIMAGE MUI_UI_HEADERIMAGE_RIGHT MUI_UNABORTWARNING MUI_UNABORTWARNING_CANCEL_DEFAULT MUI_UNABORTWARNING_TEXT MUI_UNCONFIRMPAGE_TEXT_LOCATION MUI_UNCONFIRMPAGE_TEXT_TOP MUI_UNFINISHPAGE_NOAUTOCLOSE MUI_UNFUNCTION_DESCRIPTION_BEGIN MUI_UNFUNCTION_DESCRIPTION_END MUI_UNGETLANGUAGE MUI_UNICON MUI_UNPAGE_COMPONENTS MUI_UNPAGE_CONFIRM MUI_UNPAGE_DIRECTORY MUI_UNPAGE_FINISH MUI_UNPAGE_INSTFILES MUI_UNPAGE_LICENSE MUI_UNPAGE_WELCOME MUI_UNWELCOMEFINISHPAGE_BITMAP MUI_UNWELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_UNWELCOMEFINISHPAGE_INI MUI_WELCOMEFINISHPAGE_BITMAP MUI_WELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_WELCOMEFINISHPAGE_CUSTOMFUNCTION_INIT MUI_WELCOMEFINISHPAGE_INI MUI_WELCOMEPAGE_TEXT MUI_WELCOMEPAGE_TITLE MUI_WELCOMEPAGE_TITLE_3LINES' - bistr = 'addincludedir addplugindir AndIf cd define echo else endif error execute If ifdef ifmacrodef ifmacrondef ifndef include insertmacro macro macroend onGUIEnd onGUIInit onInit onInstFailed onInstSuccess onMouseOverSection onRebootFailed onSelChange onUserAbort onVerifyInstDir OrIf packhdr system undef verbose warning' - instance = any("instance", [r'\$\{.*?\}', r'\$[A-Za-z0-9\_]*']) - define = any("define", [r"\![^\n]*"]) - comment = any("comment", [r"\;[^\n]*", r"\#[^\n]*", r"\/\*(.*?)\*\/"]) - return make_generic_c_patterns(kwstr1+' '+kwstr2+' '+kwstr3, bistr, - instance=instance, define=define, - comment=comment) - -class NsisSH(CppSH): - """NSIS Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_nsis_patterns(), re.S) - - -#============================================================================== -# gettext highlighter -#============================================================================== -def make_gettext_patterns(): - "Strongly inspired from idlelib.ColorDelegator.make_pat" - kwstr = 'msgid msgstr' - kw = r"\b" + any("keyword", kwstr.split()) + r"\b" - fuzzy = any("builtin", [r"#,[^\n]*"]) - links = any("normal", [r"#:[^\n]*"]) - comment = any("comment", [r"#[^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, string, number, fuzzy, links, comment, - any("SYNC", [r"\n"])]) - -class GetTextSH(GenericSH): - """gettext Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_gettext_patterns(), re.S) - -#============================================================================== -# yaml highlighter -#============================================================================== -def make_yaml_patterns(): - "Strongly inspired from sublime highlighter " - kw = any("keyword", [r":|>|-|\||\[|\]|[A-Za-z][\w\s\-\_ ]+(?=:)"]) - links = any("normal", [r"#:[^\n]*"]) - comment = any("comment", [r"#[^\n]*"]) - number = any("number", - [r"\b[+-]?[0-9]+[lL]?\b", - r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", - r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) - sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" - dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' - string = any("string", [sqstring, dqstring]) - return "|".join([kw, string, number, links, comment, - any("SYNC", [r"\n"])]) - -class YamlSH(GenericSH): - """yaml Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_yaml_patterns(), re.S) - - -#============================================================================== -# HTML highlighter -#============================================================================== -class BaseWebSH(BaseSH): - """Base class for CSS and HTML syntax highlighters""" - NORMAL = 0 - COMMENT = 1 - - def __init__(self, parent, font=None, color_scheme=None): - BaseSH.__init__(self, parent, font, color_scheme) - - def highlight_block(self, text): - """Implement highlight specific for CSS and HTML.""" - text = to_text_string(text) - previous_state = tbh.get_state(self.currentBlock().previous()) - - if previous_state == self.COMMENT: - self.setFormat(0, qstring_length(text), self.formats["comment"]) - else: - previous_state = self.NORMAL - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - tbh.set_state(self.currentBlock(), previous_state) - - match_count = 0 - n_characters = qstring_length(text) - # There should never be more matches than characters in the text. - for match in self.PROG.finditer(text): - match_dict = match.groupdict() - for key, value in list(match_dict.items()): - if value: - start, end = get_span(match, key) - if previous_state == self.COMMENT: - if key == "multiline_comment_end": - tbh.set_state(self.currentBlock(), self.NORMAL) - self.setFormat(end, qstring_length(text), - self.formats["normal"]) - else: - tbh.set_state(self.currentBlock(), self.COMMENT) - self.setFormat(0, qstring_length(text), - self.formats["comment"]) - else: - if key == "multiline_comment_start": - tbh.set_state(self.currentBlock(), self.COMMENT) - self.setFormat(start, qstring_length(text), - self.formats["comment"]) - else: - tbh.set_state(self.currentBlock(), self.NORMAL) - try: - self.setFormat(start, end-start, - self.formats[key]) - except KeyError: - # Happens with unmatched end-of-comment. - # See spyder-ide/spyder#1462. - pass - match_count += 1 - if match_count >= n_characters: - break - - self.highlight_extras(text) - -def make_html_patterns(): - """Strongly inspired from idlelib.ColorDelegator.make_pat """ - tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) - keywords = any("keyword", [r" [\w:-]*?(?==)"]) - string = any("string", [r'".*?"']) - comment = any("comment", [r""]) - multiline_comment_start = any("multiline_comment_start", [r""]) - return "|".join([comment, multiline_comment_start, - multiline_comment_end, tags, keywords, string]) - -class HtmlSH(BaseWebSH): - """HTML Syntax Highlighter""" - PROG = re.compile(make_html_patterns(), re.S) - - -# ============================================================================= -# Markdown highlighter -# ============================================================================= -def make_md_patterns(): - h1 = '^#[^#]+' - h2 = '^##[^#]+' - h3 = '^###[^#]+' - h4 = '^####[^#]+' - h5 = '^#####[^#]+' - h6 = '^######[^#]+' - - titles = any('title', [h1, h2, h3, h4, h5, h6]) - - html_tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) - html_symbols = '&[^; ].+;' - html_comment = '' - - strikethrough = any('strikethrough', [r'(~~)(.*?)~~']) - strong = any('strong', [r'(\*\*)(.*?)\*\*']) - - italic = r'(__)(.*?)__' - emphasis = r'(//)(.*?)//' - italic = any('italic', [italic, emphasis]) - - # links - (links) after [] or links after []: - link_html = (r'(?<=(\]\())[^\(\)]*(?=\))|' - '(]+>)|' - '(<[^ >]+@[^ >]+>)') - # link/image references - [] or ![] - link = r'!?\[[^\[\]]*\]' - links = any('link', [link_html, link]) - - # blockquotes and lists - > or - or * or 0. - blockquotes = (r'(^>+.*)' - r'|(^(?: |\t)*[0-9]+\. )' - r'|(^(?: |\t)*- )' - r'|(^(?: |\t)*\* )') - # code - code = any('code', ['^`{3,}.*$']) - inline_code = any('inline_code', ['`[^`]*`']) - - # math - $$ - math = any('number', [r'^(?:\${2}).*$', html_symbols]) - - comment = any('comment', [blockquotes, html_comment]) - - return '|'.join([titles, comment, html_tags, math, links, italic, strong, - strikethrough, code, inline_code]) - - -class MarkdownSH(BaseSH): - """Markdown Syntax Highlighter""" - # Syntax highlighting rules: - PROG = re.compile(make_md_patterns(), re.S) - NORMAL = 0 - CODE = 1 - - def highlightBlock(self, text): - text = to_text_string(text) - previous_state = self.previousBlockState() - - if previous_state == self.CODE: - self.setFormat(0, qstring_length(text), self.formats["code"]) - else: - previous_state = self.NORMAL - self.setFormat(0, qstring_length(text), self.formats["normal"]) - - self.setCurrentBlockState(previous_state) - - match_count = 0 - n_characters = qstring_length(text) - for match in self.PROG.finditer(text): - for key, value in list(match.groupdict().items()): - start, end = get_span(match, key) - - if value: - previous_state = self.previousBlockState() - - if previous_state == self.CODE: - if key == "code": - # Change to normal - self.setFormat(0, qstring_length(text), - self.formats["normal"]) - self.setCurrentBlockState(self.NORMAL) - else: - continue - else: - if key == "code": - # Change to code - self.setFormat(0, qstring_length(text), - self.formats["code"]) - self.setCurrentBlockState(self.CODE) - continue - - self.setFormat(start, end - start, self.formats[key]) - - match_count += 1 - if match_count >= n_characters: - break - - self.highlight_extras(text) - - def setup_formats(self, font=None): - super(MarkdownSH, self).setup_formats(font) - - font = QTextCharFormat(self.formats['normal']) - font.setFontItalic(True) - self.formats['italic'] = font - - self.formats['strong'] = self.formats['definition'] - - font = QTextCharFormat(self.formats['normal']) - font.setFontStrikeOut(True) - self.formats['strikethrough'] = font - - font = QTextCharFormat(self.formats['string']) - font.setUnderlineStyle(QTextCharFormat.SingleUnderline) - self.formats['link'] = font - - self.formats['code'] = self.formats['string'] - self.formats['inline_code'] = self.formats['string'] - - font = QTextCharFormat(self.formats['keyword']) - font.setFontWeight(QFont.Bold) - self.formats['title'] = font - - -#============================================================================== -# Pygments based omni-parser -#============================================================================== -# IMPORTANT NOTE: -# -------------- -# Do not be tempted to generalize the use of PygmentsSH (that is tempting -# because it would lead to more generic and compact code, and not only in -# this very module) because this generic syntax highlighter is far slower -# than the native ones (all classes above). For example, a Python syntax -# highlighter based on PygmentsSH would be 2 to 3 times slower than the -# current native PythonSH syntax highlighter. - -class PygmentsSH(BaseSH): - """Generic Pygments syntax highlighter.""" - # Store the language name and a ref to the lexer - _lang_name = None - _lexer = None - - # Syntax highlighting states (from one text block to another): - NORMAL = 0 - def __init__(self, parent, font=None, color_scheme=None): - # Map Pygments tokens to Spyder tokens - self._tokmap = {Text: "normal", - Generic: "normal", - Other: "normal", - Keyword: "keyword", - Token.Operator: "normal", - Name.Builtin: "builtin", - Name: "normal", - Comment: "comment", - String: "string", - Number: "number"} - # Load Pygments' Lexer - if self._lang_name is not None: - self._lexer = get_lexer_by_name(self._lang_name) - - BaseSH.__init__(self, parent, font, color_scheme) - - # This worker runs in a thread to avoid blocking when doing full file - # parsing - self._worker_manager = WorkerManager() - - # Store the format for all the tokens after Pygments parsing - self._charlist = [] - - # Flag variable to avoid unnecessary highlights if the worker has not - # yet finished processing - self._allow_highlight = True - - def stop(self): - self._worker_manager.terminate_all() - - def make_charlist(self): - """Parses the complete text and stores format for each character.""" - - def worker_output(worker, output, error): - """Worker finished callback.""" - self._charlist = output - if error is None and output: - self._allow_highlight = True - self.rehighlight() - self._allow_highlight = False - - text = to_text_string(self.document().toPlainText()) - tokens = self._lexer.get_tokens(text) - - # Before starting a new worker process make sure to end previous - # incarnations - self._worker_manager.terminate_all() - - worker = self._worker_manager.create_python_worker( - self._make_charlist, - tokens, - self._tokmap, - self.formats, - ) - worker.sig_finished.connect(worker_output) - worker.start() - - def _make_charlist(self, tokens, tokmap, formats): - """ - Parses the complete text and stores format for each character. - - Uses the attached lexer to parse into a list of tokens and Pygments - token types. Then breaks tokens into individual letters, each with a - Spyder token type attached. Stores this list as self._charlist. - - It's attached to the contentsChange signal of the parent QTextDocument - so that the charlist is updated whenever the document changes. - """ - - def _get_fmt(typ): - """Get the Spyder format code for the given Pygments token type.""" - # Exact matches first - if typ in tokmap: - return tokmap[typ] - # Partial (parent-> child) matches - for key, val in tokmap.items(): - if typ in key: # Checks if typ is a subtype of key. - return val - - return 'normal' - - charlist = [] - for typ, token in tokens: - fmt = formats[_get_fmt(typ)] - for letter in token: - charlist.append((fmt, letter)) - - return charlist - - def highlightBlock(self, text): - """ Actually highlight the block""" - # Note that an undefined blockstate is equal to -1, so the first block - # will have the correct behaviour of starting at 0. - if self._allow_highlight: - start = self.previousBlockState() + 1 - end = start + qstring_length(text) - for i, (fmt, letter) in enumerate(self._charlist[start:end]): - self.setFormat(i, 1, fmt) - self.setCurrentBlockState(end) - self.highlight_extras(text) - - -class PythonLoggingLexer(RegexLexer): - """ - A lexer for logs generated by the Python builtin 'logging' library. - - Taken from - https://bitbucket.org/birkenfeld/pygments-main/pull-requests/451/add-python-logging-lexer - """ - - name = 'Python Logging' - aliases = ['pylog', 'pythonlogging'] - filenames = ['*.log'] - tokens = { - 'root': [ - (r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\,?\d*)(\s\w+)', - bygroups(Comment.Preproc, Number.Integer), 'message'), - (r'"(.*?)"|\'(.*?)\'', String), - (r'(\d)', Number.Integer), - (r'(\s.+/n)', Text) - ], - - 'message': [ - (r'(\s-)(\sDEBUG)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', - bygroups(Text, Number, Text, Name.Builtin), '#pop'), - (r'(\s-)(\sINFO\w*)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', - bygroups(Generic.Heading, Text, Text, Name.Builtin), '#pop'), - (r'(\sWARN\w*)(\s.+)', bygroups(String, String), '#pop'), - (r'(\sERROR)(\s.+)', - bygroups(Generic.Error, Name.Constant), '#pop'), - (r'(\sCRITICAL)(\s.+)', - bygroups(Generic.Error, Name.Constant), '#pop'), - (r'(\sTRACE)(\s.+)', - bygroups(Generic.Error, Name.Constant), '#pop'), - (r'(\s\w+)(\s.+)', - bygroups(Comment, Generic.Output), '#pop'), - ], - - } - - -def guess_pygments_highlighter(filename): - """ - Factory to generate syntax highlighter for the given filename. - - If a syntax highlighter is not available for a particular file, this - function will attempt to generate one based on the lexers in Pygments. If - Pygments is not available or does not have an appropriate lexer, TextSH - will be returned instead. - """ - try: - from pygments.lexers import get_lexer_for_filename, get_lexer_by_name - except Exception: - return TextSH - - root, ext = os.path.splitext(filename) - if ext == '.txt': - # Pygments assigns a lexer that doesn’t highlight anything to - # txt files. So we avoid that here. - return TextSH - elif ext in custom_extension_lexer_mapping: - try: - lexer = get_lexer_by_name(custom_extension_lexer_mapping[ext]) - except Exception: - return TextSH - elif ext == '.log': - lexer = PythonLoggingLexer() - else: - try: - lexer = get_lexer_for_filename(filename) - except Exception: - return TextSH - - class GuessedPygmentsSH(PygmentsSH): - _lexer = lexer - - return GuessedPygmentsSH +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Editor widget syntax highlighters based on QtGui.QSyntaxHighlighter +(Python syntax highlighting rules are inspired from idlelib) +""" + +# Standard library imports +from __future__ import print_function +import keyword +import os +import re +import weakref + +# Third party imports +from pygments.lexer import RegexLexer, bygroups +from pygments.lexers import get_lexer_by_name +from pygments.token import (Text, Other, Keyword, Name, String, Number, + Comment, Generic, Token) +from qtpy.QtCore import Qt, QTimer, Signal +from qtpy.QtGui import (QColor, QCursor, QFont, QSyntaxHighlighter, + QTextCharFormat, QTextOption) +from qtpy.QtWidgets import QApplication + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.py3compat import (builtins, is_text_string, to_text_string, PY3, + PY36_OR_MORE) +from spyder.plugins.editor.utils.languages import CELL_LANGUAGES +from spyder.plugins.editor.utils.editor import TextBlockHelper as tbh +from spyder.plugins.editor.utils.editor import BlockUserData +from spyder.utils.workers import WorkerManager +from spyder.plugins.outlineexplorer.api import OutlineExplorerData +from spyder.utils.qstringhelpers import qstring_length + + + +# ============================================================================= +# Constants +# ============================================================================= +DEFAULT_PATTERNS = { + 'file': + r'file:///?(?:[\S]*)', + 'issue': + (r'(?:(?:(?:gh:)|(?:gl:)|(?:bb:))?[\w\-_]*/[\w\-_]*#\d+)|' + r'(?:(?:(?:gh-)|(?:gl-)|(?:bb-))\d+)'), + 'mail': + r'(?:mailto:\s*)?([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})', + 'url': + r"https?://([\da-z\.-]+)\.([a-z\.]{2,6})([/\w\.-]*)[^ ^'^\"]+", +} + +COLOR_SCHEME_KEYS = { + "background": _("Background:"), + "currentline": _("Current line:"), + "currentcell": _("Current cell:"), + "occurrence": _("Occurrence:"), + "ctrlclick": _("Link:"), + "sideareas": _("Side areas:"), + "matched_p": _("Matched
    parens:"), + "unmatched_p": _("Unmatched
    parens:"), + "normal": _("Normal text:"), + "keyword": _("Keyword:"), + "builtin": _("Builtin:"), + "definition": _("Definition:"), + "comment": _("Comment:"), + "string": _("String:"), + "number": _("Number:"), + "instance": _("Instance:"), + "magic": _("Magic:"), +} + +COLOR_SCHEME_DEFAULT_VALUES = { + "background": "#19232D", + "currentline": "#3a424a", + "currentcell": "#292d3e", + "occurrence": "#1A72BB", + "ctrlclick": "#179ae0", + "sideareas": "#222b35", + "matched_p": "#0bbe0b", + "unmatched_p": "#ff4340", + "normal": ("#ffffff", False, False), + "keyword": ("#c670e0", False, False), + "builtin": ("#fab16c", False, False), + "definition": ("#57d6e4", True, False), + "comment": ("#999999", False, False), + "string": ("#b0e686", False, True), + "number": ("#faed5c", False, False), + "instance": ("#ee6772", False, True), + "magic": ("#c670e0", False, False), +} + +COLOR_SCHEME_NAMES = CONF.get('appearance', 'names') + +# Mapping for file extensions that use Pygments highlighting but should use +# different lexers than Pygments' autodetection suggests. Keys are file +# extensions or tuples of extensions, values are Pygments lexer names. +CUSTOM_EXTENSION_LEXER = { + '.ipynb': 'json', + '.nt': 'bat', + '.m': 'matlab', + ('.properties', '.session', '.inf', '.reg', '.url', + '.cfg', '.cnf', '.aut', '.iss'): 'ini' +} + +# Convert custom extensions into a one-to-one mapping for easier lookup. +custom_extension_lexer_mapping = {} +for key, value in CUSTOM_EXTENSION_LEXER.items(): + # Single key is mapped unchanged. + if is_text_string(key): + custom_extension_lexer_mapping[key] = value + # Tuple of keys is iterated over and each is mapped to value. + else: + for k in key: + custom_extension_lexer_mapping[k] = value + + +#============================================================================== +# Auxiliary functions +#============================================================================== +def get_span(match, key=None): + if key is not None: + start, end = match.span(key) + else: + start, end = match.span() + start16 = qstring_length(match.string[:start]) + end16 = start16 + qstring_length(match.string[start:end]) + return start16, end16 + + +def get_color_scheme(name): + """Get a color scheme from config using its name""" + name = name.lower() + scheme = {} + for key in COLOR_SCHEME_KEYS: + try: + scheme[key] = CONF.get('appearance', name+'/'+key) + except: + scheme[key] = CONF.get('appearance', 'spyder/'+key) + return scheme + + +def any(name, alternates): + "Return a named group pattern matching list of alternates." + return "(?P<%s>" % name + "|".join(alternates) + ")" + + +def create_patterns(patterns, compile=False): + """ + Create patterns from pattern dictionary. + + The key correspond to the group name and the values a list of + possible pattern alternatives. + """ + all_patterns = [] + for key, value in patterns.items(): + all_patterns.append(any(key, [value])) + + regex = '|'.join(all_patterns) + + if compile: + regex = re.compile(regex) + + return regex + + +DEFAULT_PATTERNS_TEXT = create_patterns(DEFAULT_PATTERNS, compile=False) +DEFAULT_COMPILED_PATTERNS = re.compile(create_patterns(DEFAULT_PATTERNS, + compile=True)) + + +#============================================================================== +# Syntax highlighting color schemes +#============================================================================== +class BaseSH(QSyntaxHighlighter): + """Base Syntax Highlighter Class""" + # Syntax highlighting rules: + PROG = None + BLANKPROG = re.compile(r"\s+") + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + # Syntax highlighting parameters. + BLANK_ALPHA_FACTOR = 0.31 + + sig_outline_explorer_data_changed = Signal() + + # Use to signal font change + sig_font_changed = Signal() + + def __init__(self, parent, font=None, color_scheme='Spyder'): + QSyntaxHighlighter.__init__(self, parent) + + self.font = font + if is_text_string(color_scheme): + self.color_scheme = get_color_scheme(color_scheme) + else: + self.color_scheme = color_scheme + + self.background_color = None + self.currentline_color = None + self.currentcell_color = None + self.occurrence_color = None + self.ctrlclick_color = None + self.sideareas_color = None + self.matched_p_color = None + self.unmatched_p_color = None + + self.formats = None + self.setup_formats(font) + + self.cell_separators = None + self.editor = None + self.patterns = DEFAULT_COMPILED_PATTERNS + + # List of cells + self._cell_list = [] + + def get_background_color(self): + return QColor(self.background_color) + + def get_foreground_color(self): + """Return foreground ('normal' text) color""" + return self.formats["normal"].foreground().color() + + def get_currentline_color(self): + return QColor(self.currentline_color) + + def get_currentcell_color(self): + return QColor(self.currentcell_color) + + def get_occurrence_color(self): + return QColor(self.occurrence_color) + + def get_ctrlclick_color(self): + return QColor(self.ctrlclick_color) + + def get_sideareas_color(self): + return QColor(self.sideareas_color) + + def get_matched_p_color(self): + return QColor(self.matched_p_color) + + def get_unmatched_p_color(self): + return QColor(self.unmatched_p_color) + + def get_comment_color(self): + """ Return color for the comments """ + return self.formats['comment'].foreground().color() + + def get_color_name(self, fmt): + """Return color name assigned to a given format""" + return self.formats[fmt].foreground().color().name() + + def setup_formats(self, font=None): + base_format = QTextCharFormat() + if font is not None: + self.font = font + if self.font is not None: + base_format.setFont(self.font) + self.sig_font_changed.emit() + self.formats = {} + colors = self.color_scheme.copy() + self.background_color = colors.pop("background") + self.currentline_color = colors.pop("currentline") + self.currentcell_color = colors.pop("currentcell") + self.occurrence_color = colors.pop("occurrence") + self.ctrlclick_color = colors.pop("ctrlclick") + self.sideareas_color = colors.pop("sideareas") + self.matched_p_color = colors.pop("matched_p") + self.unmatched_p_color = colors.pop("unmatched_p") + for name, (color, bold, italic) in list(colors.items()): + format = QTextCharFormat(base_format) + format.setForeground(QColor(color)) + if bold: + format.setFontWeight(QFont.Bold) + format.setFontItalic(italic) + self.formats[name] = format + + def set_color_scheme(self, color_scheme): + if is_text_string(color_scheme): + self.color_scheme = get_color_scheme(color_scheme) + else: + self.color_scheme = color_scheme + + self.setup_formats() + self.rehighlight() + + @staticmethod + def _find_prev_non_blank_block(current_block): + previous_block = (current_block.previous() + if current_block.blockNumber() else None) + # find the previous non-blank block + while (previous_block and previous_block.blockNumber() and + previous_block.text().strip() == ''): + previous_block = previous_block.previous() + return previous_block + + def update_patterns(self, patterns): + """Update patterns to underline.""" + all_patterns = DEFAULT_PATTERNS.copy() + additional_patterns = patterns.copy() + + # Check that default keys are not overwritten + for key in DEFAULT_PATTERNS.keys(): + if key in additional_patterns: + # TODO: print warning or check this at the plugin level? + additional_patterns.pop(key) + all_patterns.update(additional_patterns) + + self.patterns = create_patterns(all_patterns, compile=True) + + def highlightBlock(self, text): + """ + Highlights a block of text. Please do not override, this method. + Instead you should implement + :func:`spyder.utils.syntaxhighplighters.SyntaxHighlighter.highlight_block`. + + :param text: text to highlight. + """ + self.highlight_block(text) + + def highlight_block(self, text): + """ + Abstract method. Override this to apply syntax highlighting. + + :param text: Line of text to highlight. + :param block: current block + """ + raise NotImplementedError() + + def highlight_patterns(self, text, offset=0): + """Highlight URI and mailto: patterns.""" + for match in self.patterns.finditer(text, offset): + for __, value in list(match.groupdict().items()): + if value: + start, end = get_span(match) + start = max([0, start + offset]) + end = max([0, end + offset]) + font = self.format(start) + font.setUnderlineStyle(QTextCharFormat.SingleUnderline) + self.setFormat(start, end - start, font) + + def highlight_spaces(self, text, offset=0): + """ + Make blank space less apparent by setting the foreground alpha. + This only has an effect when 'Show blank space' is turned on. + """ + flags_text = self.document().defaultTextOption().flags() + show_blanks = flags_text & QTextOption.ShowTabsAndSpaces + if show_blanks: + format_leading = self.formats.get("leading", None) + format_trailing = self.formats.get("trailing", None) + text = text[offset:] + for match in self.BLANKPROG.finditer(text): + start, end = get_span(match) + start = max([0, start+offset]) + end = max([0, end+offset]) + # Format trailing spaces at the end of the line. + if end == qstring_length(text) and format_trailing is not None: + self.setFormat(start, end - start, format_trailing) + # Format leading spaces, e.g. indentation. + if start == 0 and format_leading is not None: + self.setFormat(start, end - start, format_leading) + format = self.format(start) + color_foreground = format.foreground().color() + alpha_new = self.BLANK_ALPHA_FACTOR * color_foreground.alphaF() + color_foreground.setAlphaF(alpha_new) + self.setFormat(start, end - start, color_foreground) + + def highlight_extras(self, text, offset=0): + """ + Perform additional global text highlight. + + Derived classes could call this function at the end of + highlight_block(). + """ + self.highlight_spaces(text, offset=offset) + self.highlight_patterns(text, offset=offset) + + def rehighlight(self): + QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) + QSyntaxHighlighter.rehighlight(self) + QApplication.restoreOverrideCursor() + + +class TextSH(BaseSH): + """Simple Text Syntax Highlighter Class (only highlight spaces).""" + + def highlight_block(self, text): + """Implement highlight, only highlight spaces.""" + text = to_text_string(text) + self.setFormat(0, qstring_length(text), self.formats["normal"]) + self.highlight_extras(text) + + +class GenericSH(BaseSH): + """Generic Syntax Highlighter""" + # Syntax highlighting rules: + PROG = None # to be redefined in child classes + + def highlight_block(self, text): + """Implement highlight using regex defined in children classes.""" + text = to_text_string(text) + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + index = 0 + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + start, end = get_span(match, key) + index += end-start + self.setFormat(start, end-start, self.formats[key]) + + self.highlight_extras(text) + + +#============================================================================== +# Python syntax highlighter +#============================================================================== +def make_python_patterns(additional_keywords=[], additional_builtins=[]): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwlist = keyword.kwlist + additional_keywords + builtinlist = [str(name) for name in dir(builtins) + if not name.startswith('_')] + additional_builtins + repeated = set(kwlist) & set(builtinlist) + for repeated_element in repeated: + kwlist.remove(repeated_element) + kw = r"\b" + any("keyword", kwlist) + r"\b" + builtin = r"([^.'\"\\#]\b|^)" + any("builtin", builtinlist) + r"\b" + comment = any("comment", [r"#[^\n]*"]) + instance = any("instance", [r"\bself\b", + r"\bcls\b", + (r"^\s*@([a-zA-Z_][a-zA-Z0-9_]*)" + r"(\.[a-zA-Z_][a-zA-Z0-9_]*)*")]) + number_regex = [r"\b[+-]?[0-9]+[lLjJ]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?0[oO][0-7]+[lL]?\b", + r"\b[+-]?0[bB][01]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?[jJ]?\b"] + if PY3: + prefix = "r|u|R|U|f|F|fr|Fr|fR|FR|rf|rF|Rf|RF|b|B|br|Br|bR|BR|rb|rB|Rb|RB" + else: + prefix = "r|u|ur|R|U|UR|Ur|uR|b|B|br|Br|bR|BR" + sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*'?" % prefix + dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*"?' % prefix + uf_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(\\)$(?!')$" % prefix + uf_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(\\)$(?!")$' % prefix + ufe_sqstring = r"(\b(%s))?'[^'\\\n]*(\\.[^'\\\n]*)*(?!\\)$(?!')$" % prefix + ufe_dqstring = r'(\b(%s))?"[^"\\\n]*(\\.[^"\\\n]*)*(?!\\)$(?!")$' % prefix + sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" % prefix + dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' % prefix + uf_sq3string = r"(\b(%s))?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(\\)?(?!''')$" \ + % prefix + uf_dq3string = r'(\b(%s))?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(\\)?(?!""")$' \ + % prefix + # Needed to achieve correct highlighting in Python 3.6+ + # See spyder-ide/spyder#7324. + if PY36_OR_MORE: + # Based on + # https://github.com/python/cpython/blob/ + # 81950495ba2c36056e0ce48fd37d514816c26747/Lib/tokenize.py#L117 + # In order: Hexnumber, Binnumber, Octnumber, Decnumber, + # Pointfloat + Exponent, Expfloat, Imagnumber + number_regex = [ + r"\b[+-]?0[xX](?:_?[0-9A-Fa-f])+[lL]?\b", + r"\b[+-]?0[bB](?:_?[01])+[lL]?\b", + r"\b[+-]?0[oO](?:_?[0-7])+[lL]?\b", + r"\b[+-]?(?:0(?:_?0)*|[1-9](?:_?[0-9])*)[lL]?\b", + r"\b((\.[0-9](?:_?[0-9])*')|\.[0-9](?:_?[0-9])*)" + "([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", + r"\b[0-9](?:_?[0-9])*([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", + r"\b[0-9](?:_?[0-9])*[jJ]\b"] + number = any("number", number_regex) + + string = any("string", [sq3string, dq3string, sqstring, dqstring]) + ufstring1 = any("uf_sqstring", [uf_sqstring]) + ufstring2 = any("uf_dqstring", [uf_dqstring]) + ufstring3 = any("uf_sq3string", [uf_sq3string]) + ufstring4 = any("uf_dq3string", [uf_dq3string]) + ufstring5 = any("ufe_sqstring", [ufe_sqstring]) + ufstring6 = any("ufe_dqstring", [ufe_dqstring]) + return "|".join([instance, kw, builtin, comment, + ufstring1, ufstring2, ufstring3, ufstring4, ufstring5, + ufstring6, string, number, any("SYNC", [r"\n"])]) + + +def make_ipython_patterns(additional_keywords=[], additional_builtins=[]): + return (make_python_patterns(additional_keywords, additional_builtins) + + r"|^\s*%%?(?P[^\s]*)") + + +def get_code_cell_name(text): + """Returns a code cell name from a code cell comment.""" + name = text.strip().lstrip("#% ") + if name.startswith(""): + name = name[10:].lstrip() + elif name.startswith("In["): + name = name[2:] + if name.endswith("]:"): + name = name[:-1] + name = name.strip() + return name + + +class PythonSH(BaseSH): + """Python Syntax Highlighter""" + # Syntax highlighting rules: + add_kw = ['async', 'await'] + PROG = re.compile(make_python_patterns(additional_keywords=add_kw), re.S) + IDPROG = re.compile(r"\s+(\w+)", re.S) + ASPROG = re.compile(r"\b(as)\b") + # Syntax highlighting states (from one text block to another): + (NORMAL, INSIDE_SQ3STRING, INSIDE_DQ3STRING, + INSIDE_SQSTRING, INSIDE_DQSTRING, + INSIDE_NON_MULTILINE_STRING) = list(range(6)) + DEF_TYPES = {"def": OutlineExplorerData.FUNCTION, + "class": OutlineExplorerData.CLASS} + # Comments suitable for Outline Explorer + OECOMMENT = re.compile(r'^(# ?--[-]+|##[#]+ )[ -]*[^- ]+') + + def __init__(self, parent, font=None, color_scheme='Spyder'): + BaseSH.__init__(self, parent, font, color_scheme) + self.cell_separators = CELL_LANGUAGES['Python'] + # Avoid updating the outline explorer with every single letter typed + self.outline_explorer_data_update_timer = QTimer() + self.outline_explorer_data_update_timer.setSingleShot(True) + + def highlight_match(self, text, match, key, value, offset, + state, import_stmt, oedata): + """Highlight a single match.""" + start, end = get_span(match, key) + start = max([0, start+offset]) + end = max([0, end+offset]) + length = end - start + if key == "uf_sq3string": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_SQ3STRING + elif key == "uf_dq3string": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_DQ3STRING + elif key == "uf_sqstring": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_SQSTRING + elif key == "uf_dqstring": + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_DQSTRING + elif key in ["ufe_sqstring", "ufe_dqstring"]: + self.setFormat(start, length, self.formats["string"]) + state = self.INSIDE_NON_MULTILINE_STRING + else: + self.setFormat(start, length, self.formats[key]) + if key == "comment": + if text.lstrip().startswith(self.cell_separators): + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text).strip() + # cell_head: string containing the first group + # of '%'s in the cell header + cell_head = re.search(r"%+|$", text.lstrip()).group() + if cell_head == '': + oedata.cell_level = 0 + else: + oedata.cell_level = qstring_length(cell_head) - 2 + oedata.fold_level = start + oedata.def_type = OutlineExplorerData.CELL + def_name = get_code_cell_name(text) + oedata.def_name = def_name + # Keep list of cells for performence reasons + self._cell_list.append(oedata) + elif self.OECOMMENT.match(text.lstrip()): + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text).strip() + oedata.fold_level = start + oedata.def_type = OutlineExplorerData.COMMENT + oedata.def_name = text.strip() + elif key == "keyword": + if value in ("def", "class"): + match1 = self.IDPROG.match(text, end) + if match1: + start1, end1 = get_span(match1, 1) + self.setFormat(start1, end1-start1, + self.formats["definition"]) + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text) + oedata.fold_level = (qstring_length(text) + - qstring_length(text.lstrip())) + oedata.def_type = self.DEF_TYPES[to_text_string(value)] + oedata.def_name = text[start1:end1] + oedata.color = self.formats["definition"] + elif value in ("elif", "else", "except", "finally", + "for", "if", "try", "while", + "with"): + if text.lstrip().startswith(value): + oedata = OutlineExplorerData(self.currentBlock()) + oedata.text = to_text_string(text).strip() + oedata.fold_level = start + oedata.def_type = OutlineExplorerData.STATEMENT + oedata.def_name = text.strip() + elif value == "import": + import_stmt = text.strip() + # color all the "as" words on same line, except + # if in a comment; cheap approximation to the + # truth + if '#' in text: + endpos = qstring_length(text[:text.index('#')]) + else: + endpos = qstring_length(text) + while True: + match1 = self.ASPROG.match(text, end, endpos) + if not match1: + break + start, end = get_span(match1, 1) + self.setFormat(start, length, self.formats["keyword"]) + + return state, import_stmt, oedata + + def highlight_block(self, text): + """Implement specific highlight for Python.""" + text = to_text_string(text) + prev_state = tbh.get_state(self.currentBlock().previous()) + if prev_state == self.INSIDE_DQ3STRING: + offset = -4 + text = r'""" '+text + elif prev_state == self.INSIDE_SQ3STRING: + offset = -4 + text = r"''' "+text + elif prev_state == self.INSIDE_DQSTRING: + offset = -2 + text = r'" '+text + elif prev_state == self.INSIDE_SQSTRING: + offset = -2 + text = r"' "+text + else: + offset = 0 + prev_state = self.NORMAL + + oedata = None + import_stmt = None + + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + state = self.NORMAL + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + state, import_stmt, oedata = self.highlight_match( + text, match, key, value, offset, + state, import_stmt, oedata) + + tbh.set_state(self.currentBlock(), state) + + # Use normal format for indentation and trailing spaces + # Unless we are in a string + states_multiline_string = [ + self.INSIDE_DQ3STRING, self.INSIDE_SQ3STRING, + self.INSIDE_DQSTRING, self.INSIDE_SQSTRING] + states_string = states_multiline_string + [ + self.INSIDE_NON_MULTILINE_STRING] + self.formats['leading'] = self.formats['normal'] + if prev_state in states_multiline_string: + self.formats['leading'] = self.formats["string"] + self.formats['trailing'] = self.formats['normal'] + if state in states_string: + self.formats['trailing'] = self.formats['string'] + self.highlight_extras(text, offset) + + block = self.currentBlock() + data = block.userData() + + need_data = (oedata or import_stmt) + + if need_data and not data: + data = BlockUserData(self.editor) + + # Try updating + update = False + if oedata and data and data.oedata: + update = data.oedata.update(oedata) + + if data and not update: + data.oedata = oedata + self.outline_explorer_data_update_timer.start(500) + + if (import_stmt) or (data and data.import_statement): + data.import_statement = import_stmt + + block.setUserData(data) + + def get_import_statements(self): + """Get import statment list.""" + block = self.document().firstBlock() + statments = [] + while block.isValid(): + data = block.userData() + if data and data.import_statement: + statments.append(data.import_statement) + block = block.next() + return statments + + def rehighlight(self): + BaseSH.rehighlight(self) + + +# ============================================================================= +# IPython syntax highlighter +# ============================================================================= +class IPythonSH(PythonSH): + """IPython Syntax Highlighter""" + add_kw = ['async', 'await'] + PROG = re.compile(make_ipython_patterns(additional_keywords=add_kw), re.S) + + +#============================================================================== +# Cython syntax highlighter +#============================================================================== +C_TYPES = 'bool char double enum float int long mutable short signed struct unsigned void NULL' + +class CythonSH(PythonSH): + """Cython Syntax Highlighter""" + ADDITIONAL_KEYWORDS = [ + "cdef", "ctypedef", "cpdef", "inline", "cimport", "extern", + "include", "begin", "end", "by", "gil", "nogil", "const", "public", + "readonly", "fused", "static", "api", "DEF", "IF", "ELIF", "ELSE"] + + ADDITIONAL_BUILTINS = C_TYPES.split() + [ + "array", "bint", "Py_ssize_t", "intern", "reload", "sizeof", "NULL"] + PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, + ADDITIONAL_BUILTINS), re.S) + IDPROG = re.compile(r"\s+([\w\.]+)", re.S) + + +#============================================================================== +# Enaml syntax highlighter +#============================================================================== +class EnamlSH(PythonSH): + """Enaml Syntax Highlighter""" + ADDITIONAL_KEYWORDS = ["enamldef", "template", "attr", "event", "const", "alias", + "func"] + ADDITIONAL_BUILTINS = [] + PROG = re.compile(make_python_patterns(ADDITIONAL_KEYWORDS, + ADDITIONAL_BUILTINS), re.S) + IDPROG = re.compile(r"\s+([\w\.]+)", re.S) + + +#============================================================================== +# C/C++ syntax highlighter +#============================================================================== +C_KEYWORDS1 = 'and and_eq bitand bitor break case catch const const_cast continue default delete do dynamic_cast else explicit export extern for friend goto if inline namespace new not not_eq operator or or_eq private protected public register reinterpret_cast return sizeof static static_cast switch template throw try typedef typeid typename union using virtual while xor xor_eq' +C_KEYWORDS2 = 'a addindex addtogroup anchor arg attention author b brief bug c class code date def defgroup deprecated dontinclude e em endcode endhtmlonly ifdef endif endlatexonly endlink endverbatim enum example exception f$ file fn hideinitializer htmlinclude htmlonly if image include ingroup internal invariant interface latexonly li line link mainpage name namespace nosubgrouping note overload p page par param post pre ref relates remarks return retval sa section see showinitializer since skip skipline subsection test throw todo typedef union until var verbatim verbinclude version warning weakgroup' +C_KEYWORDS3 = 'asm auto class compl false true volatile wchar_t' + +def make_generic_c_patterns(keywords, builtins, + instance=None, define=None, comment=None): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kw = r"\b" + any("keyword", keywords.split()) + r"\b" + builtin = r"\b" + any("builtin", builtins.split()+C_TYPES.split()) + r"\b" + if comment is None: + comment = any("comment", [r"//[^\n]*", r"\/\*(.*?)\*\/"]) + comment_start = any("comment_start", [r"\/\*"]) + comment_end = any("comment_end", [r"\*\/"]) + if instance is None: + instance = any("instance", [r"\bthis\b"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + if define is None: + define = any("define", [r"#[^\n]*"]) + return "|".join([instance, kw, comment, string, number, + comment_start, comment_end, builtin, + define, any("SYNC", [r"\n"])]) + +def make_cpp_patterns(): + return make_generic_c_patterns(C_KEYWORDS1+' '+C_KEYWORDS2, C_KEYWORDS3) + +class CppSH(BaseSH): + """C/C++ Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_cpp_patterns(), re.S) + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + INSIDE_COMMENT = 1 + def __init__(self, parent, font=None, color_scheme=None): + BaseSH.__init__(self, parent, font, color_scheme) + + def highlight_block(self, text): + """Implement highlight specific for C/C++.""" + text = to_text_string(text) + inside_comment = tbh.get_state(self.currentBlock().previous()) == self.INSIDE_COMMENT + self.setFormat(0, qstring_length(text), + self.formats["comment" if inside_comment else "normal"]) + + index = 0 + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + start, end = get_span(match, key) + index += end-start + if key == "comment_start": + inside_comment = True + self.setFormat(start, qstring_length(text)-start, + self.formats["comment"]) + elif key == "comment_end": + inside_comment = False + self.setFormat(start, end-start, + self.formats["comment"]) + elif inside_comment: + self.setFormat(start, end-start, + self.formats["comment"]) + elif key == "define": + self.setFormat(start, end-start, + self.formats["number"]) + else: + self.setFormat(start, end-start, self.formats[key]) + + self.highlight_extras(text) + + last_state = self.INSIDE_COMMENT if inside_comment else self.NORMAL + tbh.set_state(self.currentBlock(), last_state) + + +def make_opencl_patterns(): + # Keywords: + kwstr1 = 'cl_char cl_uchar cl_short cl_ushort cl_int cl_uint cl_long cl_ulong cl_half cl_float cl_double cl_platform_id cl_device_id cl_context cl_command_queue cl_mem cl_program cl_kernel cl_event cl_sampler cl_bool cl_bitfield cl_device_type cl_platform_info cl_device_info cl_device_address_info cl_device_fp_config cl_device_mem_cache_type cl_device_local_mem_type cl_device_exec_capabilities cl_command_queue_properties cl_context_properties cl_context_info cl_command_queue_info cl_channel_order cl_channel_type cl_mem_flags cl_mem_object_type cl_mem_info cl_image_info cl_addressing_mode cl_filter_mode cl_sampler_info cl_map_flags cl_program_info cl_program_build_info cl_build_status cl_kernel_info cl_kernel_work_group_info cl_event_info cl_command_type cl_profiling_info cl_image_format' + # Constants: + kwstr2 = 'CL_FALSE, CL_TRUE, CL_PLATFORM_PROFILE, CL_PLATFORM_VERSION, CL_PLATFORM_NAME, CL_PLATFORM_VENDOR, CL_PLATFORM_EXTENSIONS, CL_DEVICE_TYPE_DEFAULT , CL_DEVICE_TYPE_CPU, CL_DEVICE_TYPE_GPU, CL_DEVICE_TYPE_ACCELERATOR, CL_DEVICE_TYPE_ALL, CL_DEVICE_TYPE, CL_DEVICE_VENDOR_ID, CL_DEVICE_MAX_COMPUTE_UNITS, CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS, CL_DEVICE_MAX_WORK_GROUP_SIZE, CL_DEVICE_MAX_WORK_ITEM_SIZES, CL_DEVICE_PREFERRED_VECTOR_WIDTH_CHAR, CL_DEVICE_PREFERRED_VECTOR_WIDTH_SHORT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_INT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_LONG, CL_DEVICE_PREFERRED_VECTOR_WIDTH_FLOAT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLE, CL_DEVICE_MAX_CLOCK_FREQUENCY, CL_DEVICE_ADDRESS_BITS, CL_DEVICE_MAX_READ_IMAGE_ARGS, CL_DEVICE_MAX_WRITE_IMAGE_ARGS, CL_DEVICE_MAX_MEM_ALLOC_SIZE, CL_DEVICE_IMAGE2D_MAX_WIDTH, CL_DEVICE_IMAGE2D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_WIDTH, CL_DEVICE_IMAGE3D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_DEPTH, CL_DEVICE_IMAGE_SUPPORT, CL_DEVICE_MAX_PARAMETER_SIZE, CL_DEVICE_MAX_SAMPLERS, CL_DEVICE_MEM_BASE_ADDR_ALIGN, CL_DEVICE_MIN_DATA_TYPE_ALIGN_SIZE, CL_DEVICE_SINGLE_FP_CONFIG, CL_DEVICE_GLOBAL_MEM_CACHE_TYPE, CL_DEVICE_GLOBAL_MEM_CACHELINE_SIZE, CL_DEVICE_GLOBAL_MEM_CACHE_SIZE, CL_DEVICE_GLOBAL_MEM_SIZE, CL_DEVICE_MAX_CONSTANT_BUFFER_SIZE, CL_DEVICE_MAX_CONSTANT_ARGS, CL_DEVICE_LOCAL_MEM_TYPE, CL_DEVICE_LOCAL_MEM_SIZE, CL_DEVICE_ERROR_CORRECTION_SUPPORT, CL_DEVICE_PROFILING_TIMER_RESOLUTION, CL_DEVICE_ENDIAN_LITTLE, CL_DEVICE_AVAILABLE, CL_DEVICE_COMPILER_AVAILABLE, CL_DEVICE_EXECUTION_CAPABILITIES, CL_DEVICE_QUEUE_PROPERTIES, CL_DEVICE_NAME, CL_DEVICE_VENDOR, CL_DRIVER_VERSION, CL_DEVICE_PROFILE, CL_DEVICE_VERSION, CL_DEVICE_EXTENSIONS, CL_DEVICE_PLATFORM, CL_FP_DENORM, CL_FP_INF_NAN, CL_FP_ROUND_TO_NEAREST, CL_FP_ROUND_TO_ZERO, CL_FP_ROUND_TO_INF, CL_FP_FMA, CL_NONE, CL_READ_ONLY_CACHE, CL_READ_WRITE_CACHE, CL_LOCAL, CL_GLOBAL, CL_EXEC_KERNEL, CL_EXEC_NATIVE_KERNEL, CL_QUEUE_OUT_OF_ORDER_EXEC_MODE_ENABLE, CL_QUEUE_PROFILING_ENABLE, CL_CONTEXT_REFERENCE_COUNT, CL_CONTEXT_DEVICES, CL_CONTEXT_PROPERTIES, CL_CONTEXT_PLATFORM, CL_QUEUE_CONTEXT, CL_QUEUE_DEVICE, CL_QUEUE_REFERENCE_COUNT, CL_QUEUE_PROPERTIES, CL_MEM_READ_WRITE, CL_MEM_WRITE_ONLY, CL_MEM_READ_ONLY, CL_MEM_USE_HOST_PTR, CL_MEM_ALLOC_HOST_PTR, CL_MEM_COPY_HOST_PTR, CL_R, CL_A, CL_RG, CL_RA, CL_RGB, CL_RGBA, CL_BGRA, CL_ARGB, CL_INTENSITY, CL_LUMINANCE, CL_SNORM_INT8, CL_SNORM_INT16, CL_UNORM_INT8, CL_UNORM_INT16, CL_UNORM_SHORT_565, CL_UNORM_SHORT_555, CL_UNORM_INT_101010, CL_SIGNED_INT8, CL_SIGNED_INT16, CL_SIGNED_INT32, CL_UNSIGNED_INT8, CL_UNSIGNED_INT16, CL_UNSIGNED_INT32, CL_HALF_FLOAT, CL_FLOAT, CL_MEM_OBJECT_BUFFER, CL_MEM_OBJECT_IMAGE2D, CL_MEM_OBJECT_IMAGE3D, CL_MEM_TYPE, CL_MEM_FLAGS, CL_MEM_SIZECL_MEM_HOST_PTR, CL_MEM_HOST_PTR, CL_MEM_MAP_COUNT, CL_MEM_REFERENCE_COUNT, CL_MEM_CONTEXT, CL_IMAGE_FORMAT, CL_IMAGE_ELEMENT_SIZE, CL_IMAGE_ROW_PITCH, CL_IMAGE_SLICE_PITCH, CL_IMAGE_WIDTH, CL_IMAGE_HEIGHT, CL_IMAGE_DEPTH, CL_ADDRESS_NONE, CL_ADDRESS_CLAMP_TO_EDGE, CL_ADDRESS_CLAMP, CL_ADDRESS_REPEAT, CL_FILTER_NEAREST, CL_FILTER_LINEAR, CL_SAMPLER_REFERENCE_COUNT, CL_SAMPLER_CONTEXT, CL_SAMPLER_NORMALIZED_COORDS, CL_SAMPLER_ADDRESSING_MODE, CL_SAMPLER_FILTER_MODE, CL_MAP_READ, CL_MAP_WRITE, CL_PROGRAM_REFERENCE_COUNT, CL_PROGRAM_CONTEXT, CL_PROGRAM_NUM_DEVICES, CL_PROGRAM_DEVICES, CL_PROGRAM_SOURCE, CL_PROGRAM_BINARY_SIZES, CL_PROGRAM_BINARIES, CL_PROGRAM_BUILD_STATUS, CL_PROGRAM_BUILD_OPTIONS, CL_PROGRAM_BUILD_LOG, CL_BUILD_SUCCESS, CL_BUILD_NONE, CL_BUILD_ERROR, CL_BUILD_IN_PROGRESS, CL_KERNEL_FUNCTION_NAME, CL_KERNEL_NUM_ARGS, CL_KERNEL_REFERENCE_COUNT, CL_KERNEL_CONTEXT, CL_KERNEL_PROGRAM, CL_KERNEL_WORK_GROUP_SIZE, CL_KERNEL_COMPILE_WORK_GROUP_SIZE, CL_KERNEL_LOCAL_MEM_SIZE, CL_EVENT_COMMAND_QUEUE, CL_EVENT_COMMAND_TYPE, CL_EVENT_REFERENCE_COUNT, CL_EVENT_COMMAND_EXECUTION_STATUS, CL_COMMAND_NDRANGE_KERNEL, CL_COMMAND_TASK, CL_COMMAND_NATIVE_KERNEL, CL_COMMAND_READ_BUFFER, CL_COMMAND_WRITE_BUFFER, CL_COMMAND_COPY_BUFFER, CL_COMMAND_READ_IMAGE, CL_COMMAND_WRITE_IMAGE, CL_COMMAND_COPY_IMAGE, CL_COMMAND_COPY_IMAGE_TO_BUFFER, CL_COMMAND_COPY_BUFFER_TO_IMAGE, CL_COMMAND_MAP_BUFFER, CL_COMMAND_MAP_IMAGE, CL_COMMAND_UNMAP_MEM_OBJECT, CL_COMMAND_MARKER, CL_COMMAND_ACQUIRE_GL_OBJECTS, CL_COMMAND_RELEASE_GL_OBJECTS, command execution status, CL_COMPLETE, CL_RUNNING, CL_SUBMITTED, CL_QUEUED, CL_PROFILING_COMMAND_QUEUED, CL_PROFILING_COMMAND_SUBMIT, CL_PROFILING_COMMAND_START, CL_PROFILING_COMMAND_END, CL_CHAR_BIT, CL_SCHAR_MAX, CL_SCHAR_MIN, CL_CHAR_MAX, CL_CHAR_MIN, CL_UCHAR_MAX, CL_SHRT_MAX, CL_SHRT_MIN, CL_USHRT_MAX, CL_INT_MAX, CL_INT_MIN, CL_UINT_MAX, CL_LONG_MAX, CL_LONG_MIN, CL_ULONG_MAX, CL_FLT_DIG, CL_FLT_MANT_DIG, CL_FLT_MAX_10_EXP, CL_FLT_MAX_EXP, CL_FLT_MIN_10_EXP, CL_FLT_MIN_EXP, CL_FLT_RADIX, CL_FLT_MAX, CL_FLT_MIN, CL_FLT_EPSILON, CL_DBL_DIG, CL_DBL_MANT_DIG, CL_DBL_MAX_10_EXP, CL_DBL_MAX_EXP, CL_DBL_MIN_10_EXP, CL_DBL_MIN_EXP, CL_DBL_RADIX, CL_DBL_MAX, CL_DBL_MIN, CL_DBL_EPSILON, CL_SUCCESS, CL_DEVICE_NOT_FOUND, CL_DEVICE_NOT_AVAILABLE, CL_COMPILER_NOT_AVAILABLE, CL_MEM_OBJECT_ALLOCATION_FAILURE, CL_OUT_OF_RESOURCES, CL_OUT_OF_HOST_MEMORY, CL_PROFILING_INFO_NOT_AVAILABLE, CL_MEM_COPY_OVERLAP, CL_IMAGE_FORMAT_MISMATCH, CL_IMAGE_FORMAT_NOT_SUPPORTED, CL_BUILD_PROGRAM_FAILURE, CL_MAP_FAILURE, CL_INVALID_VALUE, CL_INVALID_DEVICE_TYPE, CL_INVALID_PLATFORM, CL_INVALID_DEVICE, CL_INVALID_CONTEXT, CL_INVALID_QUEUE_PROPERTIES, CL_INVALID_COMMAND_QUEUE, CL_INVALID_HOST_PTR, CL_INVALID_MEM_OBJECT, CL_INVALID_IMAGE_FORMAT_DESCRIPTOR, CL_INVALID_IMAGE_SIZE, CL_INVALID_SAMPLER, CL_INVALID_BINARY, CL_INVALID_BUILD_OPTIONS, CL_INVALID_PROGRAM, CL_INVALID_PROGRAM_EXECUTABLE, CL_INVALID_KERNEL_NAME, CL_INVALID_KERNEL_DEFINITION, CL_INVALID_KERNEL, CL_INVALID_ARG_INDEX, CL_INVALID_ARG_VALUE, CL_INVALID_ARG_SIZE, CL_INVALID_KERNEL_ARGS, CL_INVALID_WORK_DIMENSION, CL_INVALID_WORK_GROUP_SIZE, CL_INVALID_WORK_ITEM_SIZE, CL_INVALID_GLOBAL_OFFSET, CL_INVALID_EVENT_WAIT_LIST, CL_INVALID_EVENT, CL_INVALID_OPERATION, CL_INVALID_GL_OBJECT, CL_INVALID_BUFFER_SIZE, CL_INVALID_MIP_LEVEL, CL_INVALID_GLOBAL_WORK_SIZE' + # Functions: + builtins = 'clGetPlatformIDs, clGetPlatformInfo, clGetDeviceIDs, clGetDeviceInfo, clCreateContext, clCreateContextFromType, clReleaseContext, clGetContextInfo, clCreateCommandQueue, clRetainCommandQueue, clReleaseCommandQueue, clGetCommandQueueInfo, clSetCommandQueueProperty, clCreateBuffer, clCreateImage2D, clCreateImage3D, clRetainMemObject, clReleaseMemObject, clGetSupportedImageFormats, clGetMemObjectInfo, clGetImageInfo, clCreateSampler, clRetainSampler, clReleaseSampler, clGetSamplerInfo, clCreateProgramWithSource, clCreateProgramWithBinary, clRetainProgram, clReleaseProgram, clBuildProgram, clUnloadCompiler, clGetProgramInfo, clGetProgramBuildInfo, clCreateKernel, clCreateKernelsInProgram, clRetainKernel, clReleaseKernel, clSetKernelArg, clGetKernelInfo, clGetKernelWorkGroupInfo, clWaitForEvents, clGetEventInfo, clRetainEvent, clReleaseEvent, clGetEventProfilingInfo, clFlush, clFinish, clEnqueueReadBuffer, clEnqueueWriteBuffer, clEnqueueCopyBuffer, clEnqueueReadImage, clEnqueueWriteImage, clEnqueueCopyImage, clEnqueueCopyImageToBuffer, clEnqueueCopyBufferToImage, clEnqueueMapBuffer, clEnqueueMapImage, clEnqueueUnmapMemObject, clEnqueueNDRangeKernel, clEnqueueTask, clEnqueueNativeKernel, clEnqueueMarker, clEnqueueWaitForEvents, clEnqueueBarrier' + # Qualifiers: + qualifiers = '__global __local __constant __private __kernel' + keyword_list = C_KEYWORDS1+' '+C_KEYWORDS2+' '+kwstr1+' '+kwstr2 + builtin_list = C_KEYWORDS3+' '+builtins+' '+qualifiers + return make_generic_c_patterns(keyword_list, builtin_list) + +class OpenCLSH(CppSH): + """OpenCL Syntax Highlighter""" + PROG = re.compile(make_opencl_patterns(), re.S) + + +#============================================================================== +# Fortran Syntax Highlighter +#============================================================================== +def make_fortran_patterns(): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwstr = 'access action advance allocatable allocate apostrophe assign assignment associate asynchronous backspace bind blank blockdata call case character class close common complex contains continue cycle data deallocate decimal delim default dimension direct do dowhile double doubleprecision else elseif elsewhere encoding end endassociate endblockdata enddo endfile endforall endfunction endif endinterface endmodule endprogram endselect endsubroutine endtype endwhere entry eor equivalence err errmsg exist exit external file flush fmt forall form format formatted function go goto id if implicit in include inout integer inquire intent interface intrinsic iomsg iolength iostat kind len logical module name named namelist nextrec nml none nullify number only open opened operator optional out pad parameter pass pause pending pointer pos position precision print private program protected public quote read readwrite real rec recl recursive result return rewind save select selectcase selecttype sequential sign size stat status stop stream subroutine target then to type unformatted unit use value volatile wait where while write' + bistr1 = 'abs achar acos acosd adjustl adjustr aimag aimax0 aimin0 aint ajmax0 ajmin0 akmax0 akmin0 all allocated alog alog10 amax0 amax1 amin0 amin1 amod anint any asin asind associated atan atan2 atan2d atand bitest bitl bitlr bitrl bjtest bit_size bktest break btest cabs ccos cdabs cdcos cdexp cdlog cdsin cdsqrt ceiling cexp char clog cmplx conjg cos cosd cosh count cpu_time cshift csin csqrt dabs dacos dacosd dasin dasind datan datan2 datan2d datand date date_and_time dble dcmplx dconjg dcos dcosd dcosh dcotan ddim dexp dfloat dflotk dfloti dflotj digits dim dimag dint dlog dlog10 dmax1 dmin1 dmod dnint dot_product dprod dreal dsign dsin dsind dsinh dsqrt dtan dtand dtanh eoshift epsilon errsns exp exponent float floati floatj floatk floor fraction free huge iabs iachar iand ibclr ibits ibset ichar idate idim idint idnint ieor ifix iiabs iiand iibclr iibits iibset iidim iidint iidnnt iieor iifix iint iior iiqint iiqnnt iishft iishftc iisign ilen imax0 imax1 imin0 imin1 imod index inint inot int int1 int2 int4 int8 iqint iqnint ior ishft ishftc isign isnan izext jiand jibclr jibits jibset jidim jidint jidnnt jieor jifix jint jior jiqint jiqnnt jishft jishftc jisign jmax0 jmax1 jmin0 jmin1 jmod jnint jnot jzext kiabs kiand kibclr kibits kibset kidim kidint kidnnt kieor kifix kind kint kior kishft kishftc kisign kmax0 kmax1 kmin0 kmin1 kmod knint knot kzext lbound leadz len len_trim lenlge lge lgt lle llt log log10 logical lshift malloc matmul max max0 max1 maxexponent maxloc maxval merge min min0 min1 minexponent minloc minval mod modulo mvbits nearest nint not nworkers number_of_processors pack popcnt poppar precision present product radix random random_number random_seed range real repeat reshape rrspacing rshift scale scan secnds selected_int_kind selected_real_kind set_exponent shape sign sin sind sinh size sizeof sngl snglq spacing spread sqrt sum system_clock tan tand tanh tiny transfer transpose trim ubound unpack verify' + bistr2 = 'cdabs cdcos cdexp cdlog cdsin cdsqrt cotan cotand dcmplx dconjg dcotan dcotand decode dimag dll_export dll_import doublecomplex dreal dvchk encode find flen flush getarg getcharqq getcl getdat getenv gettim hfix ibchng identifier imag int1 int2 int4 intc intrup invalop iostat_msg isha ishc ishl jfix lacfar locking locnear map nargs nbreak ndperr ndpexc offset ovefl peekcharqq precfill prompt qabs qacos qacosd qasin qasind qatan qatand qatan2 qcmplx qconjg qcos qcosd qcosh qdim qexp qext qextd qfloat qimag qlog qlog10 qmax1 qmin1 qmod qreal qsign qsin qsind qsinh qsqrt qtan qtand qtanh ran rand randu rewrite segment setdat settim system timer undfl unlock union val virtual volatile zabs zcos zexp zlog zsin zsqrt' + kw = r"\b" + any("keyword", kwstr.split()) + r"\b" + builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" + comment = any("comment", [r"\![^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, comment, string, number, builtin, + any("SYNC", [r"\n"])]) + +class FortranSH(BaseSH): + """Fortran Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_fortran_patterns(), re.S|re.I) + IDPROG = re.compile(r"\s+(\w+)", re.S) + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + def __init__(self, parent, font=None, color_scheme=None): + BaseSH.__init__(self, parent, font, color_scheme) + + def highlight_block(self, text): + """Implement highlight specific for Fortran.""" + text = to_text_string(text) + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + index = 0 + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + if value: + start, end = get_span(match, key) + index += end-start + self.setFormat(start, end-start, self.formats[key]) + if value.lower() in ("subroutine", "module", "function"): + match1 = self.IDPROG.match(text, end) + if match1: + start1, end1 = get_span(match1, 1) + self.setFormat(start1, end1-start1, + self.formats["definition"]) + + self.highlight_extras(text) + +class Fortran77SH(FortranSH): + """Fortran 77 Syntax Highlighter""" + def highlight_block(self, text): + """Implement highlight specific for Fortran77.""" + text = to_text_string(text) + if text.startswith(("c", "C")): + self.setFormat(0, qstring_length(text), self.formats["comment"]) + self.highlight_extras(text) + else: + FortranSH.highlight_block(self, text) + self.setFormat(0, 5, self.formats["comment"]) + self.setFormat(73, max([73, qstring_length(text)]), + self.formats["comment"]) + + +#============================================================================== +# IDL highlighter +# +# Contribution from Stuart Mumford (Littlemumford) - 2012-02-02 +# See spyder-ide/spyder#850. +#============================================================================== +def make_idl_patterns(): + """Strongly inspired by idlelib.ColorDelegator.make_pat.""" + kwstr = 'begin of pro function endfor endif endwhile endrep endcase endswitch end if then else for do while repeat until break case switch common continue exit return goto help message print read retall stop' + bistr1 = 'a_correlate abs acos adapt_hist_equal alog alog10 amoeba arg_present arra_equal array_indices ascii_template asin assoc atan beseli beselj besel k besely beta bilinear bin_date binary_template dinfgen dinomial blk_con broyden bytarr byte bytscl c_correlate call_external call_function ceil chebyshev check_math chisqr_cvf chisqr_pdf choldc cholsol cindgen clust_wts cluster color_quan colormap_applicable comfit complex complexarr complexround compute_mesh_normals cond congrid conj convert_coord convol coord2to3 correlate cos cosh cramer create_struct crossp crvlength ct_luminance cti_test curvefit cv_coord cvttobm cw_animate cw_arcball cw_bgroup cw_clr_index cw_colorsel cw_defroi cw_field cw_filesel cw_form cw_fslider cw_light_editor cw_orient cw_palette_editor cw_pdmenu cw_rgbslider cw_tmpl cw_zoom dblarr dcindgen dcomplexarr defroi deriv derivsig determ diag_matrix dialog_message dialog_pickfile pialog_printersetup dialog_printjob dialog_read_image dialog_write_image digital_filter dilate dindgen dist double eigenql eigenvec elmhes eof erode erf erfc erfcx execute exp expand_path expint extrac extract_slice f_cvf f_pdf factorial fft file_basename file_dirname file_expand_path file_info file_same file_search file_test file_which filepath findfile findgen finite fix float floor fltarr format_axis_values fstat fulstr fv_test fx_root fz_roots gamma gauss_cvf gauss_pdf gauss2dfit gaussfit gaussint get_drive_list get_kbrd get_screen_size getenv grid_tps grid3 griddata gs_iter hanning hdf_browser hdf_read hilbert hist_2d hist_equal histogram hough hqr ibeta identity idl_validname idlitsys_createtool igamma imaginary indgen int_2d int_3d int_tabulated intarr interpol interpolate invert ioctl ishft julday keword_set krig2d kurtosis kw_test l64indgen label_date label_region ladfit laguerre la_cholmprove la_cholsol la_Determ la_eigenproblem la_eigenql la_eigenvec la_elmhes la_gm_linear_model la_hqr la_invert la_least_square_equality la_least_squares la_linear_equation la_lumprove la_lusol la_trimprove la_trisol leefit legendre linbcg lindgen linfit ll_arc_distance lmfit lmgr lngamma lnp_test locale_get logical_and logical_or logical_true lon64arr lonarr long long64 lsode lu_complex lumprove lusol m_correlate machar make_array map_2points map_image map_patch map_proj_forward map_proj_init map_proj_inverse matrix_multiply matrix_power max md_test mean meanabsdev median memory mesh_clip mesh_decimate mesh_issolid mesh_merge mesh_numtriangles mesh_smooth mesh_surfacearea mesh_validate mesh_volume min min_curve_surf moment morph_close morph_distance morph_gradient morph_histormiss morph_open morph_thin morph_tophat mpeg_open msg_cat_open n_elements n_params n_tags newton norm obj_class obj_isa obj_new obj_valid objarr p_correlate path_sep pcomp pnt_line polar_surface poly poly_2d poly_area poly_fit polyfillv ployshade primes product profile profiles project_vol ptr_new ptr_valid ptrarr qgrid3 qromb qromo qsimp query_bmp query_dicom query_image query_jpeg query_mrsid query_pict query_png query_ppm query_srf query_tiff query_wav r_correlate r_test radon randomn randomu ranks read_ascii read_binary read_bmp read_dicom read_image read_mrsid read_png read_spr read_sylk read_tiff read_wav read_xwd real_part rebin recall_commands recon3 reform region_grow regress replicate reverse rk4 roberts rot rotate round routine_info rs_test s_test savgol search2d search3d sfit shift shmdebug shmvar simplex sin sindgen sinh size skewness smooth sobel sort sph_scat spher_harm spl_init spl_interp spline spline_p sprsab sprsax sprsin sprstp sqrt standardize stddev strarr strcmp strcompress stregex string strjoin strlen strlowcase strmatch strmessage strmid strpos strsplit strtrim strupcase svdfit svsol swap_endian systime t_cvf t_pdf tag_names tan tanh temporary tetra_clip tetra_surface tetra_volume thin timegen tm_test total trace transpose tri_surf trigrid trisol ts_coef ts_diff ts_fcast ts_smooth tvrd uindgen unit uintarr ul64indgen ulindgen ulon64arr ulonarr ulong ulong64 uniq value_locate variance vert_t3d voigt voxel_proj warp_tri watershed where widget_actevix widget_base widget_button widget_combobox widget_draw widget_droplist widget_event widget_info widget_label widget_list widget_propertsheet widget_slider widget_tab widget_table widget_text widget_tree write_sylk wtn xfont xregistered xsq_test' + bistr2 = 'annotate arrow axis bar_plot blas_axpy box_cursor breakpoint byteorder caldata calendar call_method call_procedure catch cd cir_3pnt close color_convert compile_opt constrained_min contour copy_lun cpu create_view cursor cw_animate_getp cw_animate_load cw_animate_run cw_light_editor_get cw_light_editor_set cw_palette_editor_get cw_palette_editor_set define_key define_msgblk define_msgblk_from_file defsysv delvar device dfpmin dissolve dlm_load doc_librar draw_roi efont empty enable_sysrtn erase errplot expand file_chmod file_copy file_delete file_lines file_link file_mkdir file_move file_readlink flick flow3 flush forward_function free_lun funct gamma_ct get_lun grid_input h_eq_ct h_eq_int heap_free heap_gc hls hsv icontour iimage image_cont image_statistics internal_volume iplot isocontour isosurface isurface itcurrent itdelete itgetcurrent itregister itreset ivolume journal la_choldc la_ludc la_svd la_tridc la_triql la_trired linkimage loadct ludc make_dll map_continents map_grid map_proj_info map_set mesh_obj mk_html_help modifyct mpeg_close mpeg_put mpeg_save msg_cat_close msg_cat_compile multi obj_destroy on_error on_ioerror online_help openr openw openu oplot oploterr particle_trace path_cache plot plot_3dbox plot_field ploterr plots point_lun polar_contour polyfill polywarp popd powell printf printd ps_show_fonts psafm pseudo ptr_free pushd qhull rdpix readf read_interfile read_jpeg read_pict read_ppm read_srf read_wave read_x11_bitmap reads readu reduce_colors register_cursor replicate_inplace resolve_all resolve_routine restore save scale3 scale3d set_plot set_shading setenv setup_keys shade_surf shade_surf_irr shade_volume shmmap show3 showfont skip_lun slicer3 slide_image socket spawn sph_4pnt streamline stretch strput struct_assign struct_hide surface surfr svdc swap_enian_inplace t3d tek_color threed time_test2 triangulate triql trired truncate_lun tv tvcrs tvlct tvscl usersym vector_field vel velovect voronoi wait wdelete wf_draw widget_control widget_displaycontextmenu window write_bmp write_image write_jpeg write_nrif write_pict write_png write_ppm write_spr write_srf write_tiff write_wav write_wave writeu wset wshow xbm_edit xdisplayfile xdxf xinteranimate xloadct xmanager xmng_tmpl xmtool xobjview xobjview_rotate xobjview_write_image xpalette xpcolo xplot3d xroi xsurface xvaredit xvolume xyouts zoom zoom_24' + kw = r"\b" + any("keyword", kwstr.split()) + r"\b" + builtin = r"\b" + any("builtin", bistr1.split()+bistr2.split()) + r"\b" + comment = any("comment", [r"\;[^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b\.[0-9]d0|\.d0+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, comment, string, number, builtin, + any("SYNC", [r"\n"])]) + +class IdlSH(GenericSH): + """IDL Syntax Highlighter""" + PROG = re.compile(make_idl_patterns(), re.S|re.I) + + +#============================================================================== +# Diff/Patch highlighter +#============================================================================== +class DiffSH(BaseSH): + """Simple Diff/Patch Syntax Highlighter Class""" + def highlight_block(self, text): + """Implement highlight specific Diff/Patch files.""" + text = to_text_string(text) + if text.startswith("+++"): + self.setFormat(0, qstring_length(text), self.formats["keyword"]) + elif text.startswith("---"): + self.setFormat(0, qstring_length(text), self.formats["keyword"]) + elif text.startswith("+"): + self.setFormat(0, qstring_length(text), self.formats["string"]) + elif text.startswith("-"): + self.setFormat(0, qstring_length(text), self.formats["number"]) + elif text.startswith("@"): + self.setFormat(0, qstring_length(text), self.formats["builtin"]) + + self.highlight_extras(text) + +#============================================================================== +# NSIS highlighter +#============================================================================== +def make_nsis_patterns(): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwstr1 = 'Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exec ExecShell ExecWait Exch ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileSeek FileWrite FileWriteByte FindClose FindFirst FindNext FindWindow FlushINI Function FunctionEnd GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetLabelAddress GetTempFileName Goto HideWindow ChangeUI CheckBitmap Icon IfAbort IfErrors IfFileExists IfRebootFlag IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText IntCmp IntCmpU IntFmt IntOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadLanguageFile LogSet LogText MessageBox MiscButtonText Name OutFile Page PageCallbacks PageEx PageExEnd Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename ReserveFile Return RMDir SearchPath Section SectionEnd SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetPluginUnload SetRebootFlag SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCpy StrLen SubCaption SubSection SubSectionEnd UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegStr WriteUninstaller XPStyle' + kwstr2 = 'all alwaysoff ARCHIVE auto both bzip2 components current custom details directory false FILE_ATTRIBUTE_ARCHIVE FILE_ATTRIBUTE_HIDDEN FILE_ATTRIBUTE_NORMAL FILE_ATTRIBUTE_OFFLINE FILE_ATTRIBUTE_READONLY FILE_ATTRIBUTE_SYSTEM FILE_ATTRIBUTE_TEMPORARY force grey HIDDEN hide IDABORT IDCANCEL IDIGNORE IDNO IDOK IDRETRY IDYES ifdiff ifnewer instfiles instfiles lastused leave left level license listonly lzma manual MB_ABORTRETRYIGNORE MB_DEFBUTTON1 MB_DEFBUTTON2 MB_DEFBUTTON3 MB_DEFBUTTON4 MB_ICONEXCLAMATION MB_ICONINFORMATION MB_ICONQUESTION MB_ICONSTOP MB_OK MB_OKCANCEL MB_RETRYCANCEL MB_RIGHT MB_SETFOREGROUND MB_TOPMOST MB_YESNO MB_YESNOCANCEL nevershow none NORMAL off OFFLINE on READONLY right RO show silent silentlog SYSTEM TEMPORARY text textonly true try uninstConfirm windows zlib' + kwstr3 = 'MUI_ABORTWARNING MUI_ABORTWARNING_CANCEL_DEFAULT MUI_ABORTWARNING_TEXT MUI_BGCOLOR MUI_COMPONENTSPAGE_CHECKBITMAP MUI_COMPONENTSPAGE_NODESC MUI_COMPONENTSPAGE_SMALLDESC MUI_COMPONENTSPAGE_TEXT_COMPLIST MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_INFO MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_TITLE MUI_COMPONENTSPAGE_TEXT_INSTTYPE MUI_COMPONENTSPAGE_TEXT_TOP MUI_CUSTOMFUNCTION_ABORT MUI_CUSTOMFUNCTION_GUIINIT MUI_CUSTOMFUNCTION_UNABORT MUI_CUSTOMFUNCTION_UNGUIINIT MUI_DESCRIPTION_TEXT MUI_DIRECTORYPAGE_BGCOLOR MUI_DIRECTORYPAGE_TEXT_DESTINATION MUI_DIRECTORYPAGE_TEXT_TOP MUI_DIRECTORYPAGE_VARIABLE MUI_DIRECTORYPAGE_VERIFYONLEAVE MUI_FINISHPAGE_BUTTON MUI_FINISHPAGE_CANCEL_ENABLED MUI_FINISHPAGE_LINK MUI_FINISHPAGE_LINK_COLOR MUI_FINISHPAGE_LINK_LOCATION MUI_FINISHPAGE_NOAUTOCLOSE MUI_FINISHPAGE_NOREBOOTSUPPORT MUI_FINISHPAGE_REBOOTLATER_DEFAULT MUI_FINISHPAGE_RUN MUI_FINISHPAGE_RUN_FUNCTION MUI_FINISHPAGE_RUN_NOTCHECKED MUI_FINISHPAGE_RUN_PARAMETERS MUI_FINISHPAGE_RUN_TEXT MUI_FINISHPAGE_SHOWREADME MUI_FINISHPAGE_SHOWREADME_FUNCTION MUI_FINISHPAGE_SHOWREADME_NOTCHECKED MUI_FINISHPAGE_SHOWREADME_TEXT MUI_FINISHPAGE_TEXT MUI_FINISHPAGE_TEXT_LARGE MUI_FINISHPAGE_TEXT_REBOOT MUI_FINISHPAGE_TEXT_REBOOTLATER MUI_FINISHPAGE_TEXT_REBOOTNOW MUI_FINISHPAGE_TITLE MUI_FINISHPAGE_TITLE_3LINES MUI_FUNCTION_DESCRIPTION_BEGIN MUI_FUNCTION_DESCRIPTION_END MUI_HEADER_TEXT MUI_HEADER_TRANSPARENT_TEXT MUI_HEADERIMAGE MUI_HEADERIMAGE_BITMAP MUI_HEADERIMAGE_BITMAP_NOSTRETCH MUI_HEADERIMAGE_BITMAP_RTL MUI_HEADERIMAGE_BITMAP_RTL_NOSTRETCH MUI_HEADERIMAGE_RIGHT MUI_HEADERIMAGE_UNBITMAP MUI_HEADERIMAGE_UNBITMAP_NOSTRETCH MUI_HEADERIMAGE_UNBITMAP_RTL MUI_HEADERIMAGE_UNBITMAP_RTL_NOSTRETCH MUI_HWND MUI_ICON MUI_INSTALLCOLORS MUI_INSTALLOPTIONS_DISPLAY MUI_INSTALLOPTIONS_DISPLAY_RETURN MUI_INSTALLOPTIONS_EXTRACT MUI_INSTALLOPTIONS_EXTRACT_AS MUI_INSTALLOPTIONS_INITDIALOG MUI_INSTALLOPTIONS_READ MUI_INSTALLOPTIONS_SHOW MUI_INSTALLOPTIONS_SHOW_RETURN MUI_INSTALLOPTIONS_WRITE MUI_INSTFILESPAGE_ABORTHEADER_SUBTEXT MUI_INSTFILESPAGE_ABORTHEADER_TEXT MUI_INSTFILESPAGE_COLORS MUI_INSTFILESPAGE_FINISHHEADER_SUBTEXT MUI_INSTFILESPAGE_FINISHHEADER_TEXT MUI_INSTFILESPAGE_PROGRESSBAR MUI_LANGDLL_ALLLANGUAGES MUI_LANGDLL_ALWAYSSHOW MUI_LANGDLL_DISPLAY MUI_LANGDLL_INFO MUI_LANGDLL_REGISTRY_KEY MUI_LANGDLL_REGISTRY_ROOT MUI_LANGDLL_REGISTRY_VALUENAME MUI_LANGDLL_WINDOWTITLE MUI_LANGUAGE MUI_LICENSEPAGE_BGCOLOR MUI_LICENSEPAGE_BUTTON MUI_LICENSEPAGE_CHECKBOX MUI_LICENSEPAGE_CHECKBOX_TEXT MUI_LICENSEPAGE_RADIOBUTTONS MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_ACCEPT MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_DECLINE MUI_LICENSEPAGE_TEXT_BOTTOM MUI_LICENSEPAGE_TEXT_TOP MUI_PAGE_COMPONENTS MUI_PAGE_CUSTOMFUNCTION_LEAVE MUI_PAGE_CUSTOMFUNCTION_PRE MUI_PAGE_CUSTOMFUNCTION_SHOW MUI_PAGE_DIRECTORY MUI_PAGE_FINISH MUI_PAGE_HEADER_SUBTEXT MUI_PAGE_HEADER_TEXT MUI_PAGE_INSTFILES MUI_PAGE_LICENSE MUI_PAGE_STARTMENU MUI_PAGE_WELCOME MUI_RESERVEFILE_INSTALLOPTIONS MUI_RESERVEFILE_LANGDLL MUI_SPECIALINI MUI_STARTMENU_GETFOLDER MUI_STARTMENU_WRITE_BEGIN MUI_STARTMENU_WRITE_END MUI_STARTMENUPAGE_BGCOLOR MUI_STARTMENUPAGE_DEFAULTFOLDER MUI_STARTMENUPAGE_NODISABLE MUI_STARTMENUPAGE_REGISTRY_KEY MUI_STARTMENUPAGE_REGISTRY_ROOT MUI_STARTMENUPAGE_REGISTRY_VALUENAME MUI_STARTMENUPAGE_TEXT_CHECKBOX MUI_STARTMENUPAGE_TEXT_TOP MUI_UI MUI_UI_COMPONENTSPAGE_NODESC MUI_UI_COMPONENTSPAGE_SMALLDESC MUI_UI_HEADERIMAGE MUI_UI_HEADERIMAGE_RIGHT MUI_UNABORTWARNING MUI_UNABORTWARNING_CANCEL_DEFAULT MUI_UNABORTWARNING_TEXT MUI_UNCONFIRMPAGE_TEXT_LOCATION MUI_UNCONFIRMPAGE_TEXT_TOP MUI_UNFINISHPAGE_NOAUTOCLOSE MUI_UNFUNCTION_DESCRIPTION_BEGIN MUI_UNFUNCTION_DESCRIPTION_END MUI_UNGETLANGUAGE MUI_UNICON MUI_UNPAGE_COMPONENTS MUI_UNPAGE_CONFIRM MUI_UNPAGE_DIRECTORY MUI_UNPAGE_FINISH MUI_UNPAGE_INSTFILES MUI_UNPAGE_LICENSE MUI_UNPAGE_WELCOME MUI_UNWELCOMEFINISHPAGE_BITMAP MUI_UNWELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_UNWELCOMEFINISHPAGE_INI MUI_WELCOMEFINISHPAGE_BITMAP MUI_WELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_WELCOMEFINISHPAGE_CUSTOMFUNCTION_INIT MUI_WELCOMEFINISHPAGE_INI MUI_WELCOMEPAGE_TEXT MUI_WELCOMEPAGE_TITLE MUI_WELCOMEPAGE_TITLE_3LINES' + bistr = 'addincludedir addplugindir AndIf cd define echo else endif error execute If ifdef ifmacrodef ifmacrondef ifndef include insertmacro macro macroend onGUIEnd onGUIInit onInit onInstFailed onInstSuccess onMouseOverSection onRebootFailed onSelChange onUserAbort onVerifyInstDir OrIf packhdr system undef verbose warning' + instance = any("instance", [r'\$\{.*?\}', r'\$[A-Za-z0-9\_]*']) + define = any("define", [r"\![^\n]*"]) + comment = any("comment", [r"\;[^\n]*", r"\#[^\n]*", r"\/\*(.*?)\*\/"]) + return make_generic_c_patterns(kwstr1+' '+kwstr2+' '+kwstr3, bistr, + instance=instance, define=define, + comment=comment) + +class NsisSH(CppSH): + """NSIS Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_nsis_patterns(), re.S) + + +#============================================================================== +# gettext highlighter +#============================================================================== +def make_gettext_patterns(): + "Strongly inspired from idlelib.ColorDelegator.make_pat" + kwstr = 'msgid msgstr' + kw = r"\b" + any("keyword", kwstr.split()) + r"\b" + fuzzy = any("builtin", [r"#,[^\n]*"]) + links = any("normal", [r"#:[^\n]*"]) + comment = any("comment", [r"#[^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, string, number, fuzzy, links, comment, + any("SYNC", [r"\n"])]) + +class GetTextSH(GenericSH): + """gettext Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_gettext_patterns(), re.S) + +#============================================================================== +# yaml highlighter +#============================================================================== +def make_yaml_patterns(): + "Strongly inspired from sublime highlighter " + kw = any("keyword", [r":|>|-|\||\[|\]|[A-Za-z][\w\s\-\_ ]+(?=:)"]) + links = any("normal", [r"#:[^\n]*"]) + comment = any("comment", [r"#[^\n]*"]) + number = any("number", + [r"\b[+-]?[0-9]+[lL]?\b", + r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", + r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b"]) + sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' + string = any("string", [sqstring, dqstring]) + return "|".join([kw, string, number, links, comment, + any("SYNC", [r"\n"])]) + +class YamlSH(GenericSH): + """yaml Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_yaml_patterns(), re.S) + + +#============================================================================== +# HTML highlighter +#============================================================================== +class BaseWebSH(BaseSH): + """Base class for CSS and HTML syntax highlighters""" + NORMAL = 0 + COMMENT = 1 + + def __init__(self, parent, font=None, color_scheme=None): + BaseSH.__init__(self, parent, font, color_scheme) + + def highlight_block(self, text): + """Implement highlight specific for CSS and HTML.""" + text = to_text_string(text) + previous_state = tbh.get_state(self.currentBlock().previous()) + + if previous_state == self.COMMENT: + self.setFormat(0, qstring_length(text), self.formats["comment"]) + else: + previous_state = self.NORMAL + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + tbh.set_state(self.currentBlock(), previous_state) + + match_count = 0 + n_characters = qstring_length(text) + # There should never be more matches than characters in the text. + for match in self.PROG.finditer(text): + match_dict = match.groupdict() + for key, value in list(match_dict.items()): + if value: + start, end = get_span(match, key) + if previous_state == self.COMMENT: + if key == "multiline_comment_end": + tbh.set_state(self.currentBlock(), self.NORMAL) + self.setFormat(end, qstring_length(text), + self.formats["normal"]) + else: + tbh.set_state(self.currentBlock(), self.COMMENT) + self.setFormat(0, qstring_length(text), + self.formats["comment"]) + else: + if key == "multiline_comment_start": + tbh.set_state(self.currentBlock(), self.COMMENT) + self.setFormat(start, qstring_length(text), + self.formats["comment"]) + else: + tbh.set_state(self.currentBlock(), self.NORMAL) + try: + self.setFormat(start, end-start, + self.formats[key]) + except KeyError: + # Happens with unmatched end-of-comment. + # See spyder-ide/spyder#1462. + pass + match_count += 1 + if match_count >= n_characters: + break + + self.highlight_extras(text) + +def make_html_patterns(): + """Strongly inspired from idlelib.ColorDelegator.make_pat """ + tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) + keywords = any("keyword", [r" [\w:-]*?(?==)"]) + string = any("string", [r'".*?"']) + comment = any("comment", [r""]) + multiline_comment_start = any("multiline_comment_start", [r""]) + return "|".join([comment, multiline_comment_start, + multiline_comment_end, tags, keywords, string]) + +class HtmlSH(BaseWebSH): + """HTML Syntax Highlighter""" + PROG = re.compile(make_html_patterns(), re.S) + + +# ============================================================================= +# Markdown highlighter +# ============================================================================= +def make_md_patterns(): + h1 = '^#[^#]+' + h2 = '^##[^#]+' + h3 = '^###[^#]+' + h4 = '^####[^#]+' + h5 = '^#####[^#]+' + h6 = '^######[^#]+' + + titles = any('title', [h1, h2, h3, h4, h5, h6]) + + html_tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) + html_symbols = '&[^; ].+;' + html_comment = '' + + strikethrough = any('strikethrough', [r'(~~)(.*?)~~']) + strong = any('strong', [r'(\*\*)(.*?)\*\*']) + + italic = r'(__)(.*?)__' + emphasis = r'(//)(.*?)//' + italic = any('italic', [italic, emphasis]) + + # links - (links) after [] or links after []: + link_html = (r'(?<=(\]\())[^\(\)]*(?=\))|' + '(]+>)|' + '(<[^ >]+@[^ >]+>)') + # link/image references - [] or ![] + link = r'!?\[[^\[\]]*\]' + links = any('link', [link_html, link]) + + # blockquotes and lists - > or - or * or 0. + blockquotes = (r'(^>+.*)' + r'|(^(?: |\t)*[0-9]+\. )' + r'|(^(?: |\t)*- )' + r'|(^(?: |\t)*\* )') + # code + code = any('code', ['^`{3,}.*$']) + inline_code = any('inline_code', ['`[^`]*`']) + + # math - $$ + math = any('number', [r'^(?:\${2}).*$', html_symbols]) + + comment = any('comment', [blockquotes, html_comment]) + + return '|'.join([titles, comment, html_tags, math, links, italic, strong, + strikethrough, code, inline_code]) + + +class MarkdownSH(BaseSH): + """Markdown Syntax Highlighter""" + # Syntax highlighting rules: + PROG = re.compile(make_md_patterns(), re.S) + NORMAL = 0 + CODE = 1 + + def highlightBlock(self, text): + text = to_text_string(text) + previous_state = self.previousBlockState() + + if previous_state == self.CODE: + self.setFormat(0, qstring_length(text), self.formats["code"]) + else: + previous_state = self.NORMAL + self.setFormat(0, qstring_length(text), self.formats["normal"]) + + self.setCurrentBlockState(previous_state) + + match_count = 0 + n_characters = qstring_length(text) + for match in self.PROG.finditer(text): + for key, value in list(match.groupdict().items()): + start, end = get_span(match, key) + + if value: + previous_state = self.previousBlockState() + + if previous_state == self.CODE: + if key == "code": + # Change to normal + self.setFormat(0, qstring_length(text), + self.formats["normal"]) + self.setCurrentBlockState(self.NORMAL) + else: + continue + else: + if key == "code": + # Change to code + self.setFormat(0, qstring_length(text), + self.formats["code"]) + self.setCurrentBlockState(self.CODE) + continue + + self.setFormat(start, end - start, self.formats[key]) + + match_count += 1 + if match_count >= n_characters: + break + + self.highlight_extras(text) + + def setup_formats(self, font=None): + super(MarkdownSH, self).setup_formats(font) + + font = QTextCharFormat(self.formats['normal']) + font.setFontItalic(True) + self.formats['italic'] = font + + self.formats['strong'] = self.formats['definition'] + + font = QTextCharFormat(self.formats['normal']) + font.setFontStrikeOut(True) + self.formats['strikethrough'] = font + + font = QTextCharFormat(self.formats['string']) + font.setUnderlineStyle(QTextCharFormat.SingleUnderline) + self.formats['link'] = font + + self.formats['code'] = self.formats['string'] + self.formats['inline_code'] = self.formats['string'] + + font = QTextCharFormat(self.formats['keyword']) + font.setFontWeight(QFont.Bold) + self.formats['title'] = font + + +#============================================================================== +# Pygments based omni-parser +#============================================================================== +# IMPORTANT NOTE: +# -------------- +# Do not be tempted to generalize the use of PygmentsSH (that is tempting +# because it would lead to more generic and compact code, and not only in +# this very module) because this generic syntax highlighter is far slower +# than the native ones (all classes above). For example, a Python syntax +# highlighter based on PygmentsSH would be 2 to 3 times slower than the +# current native PythonSH syntax highlighter. + +class PygmentsSH(BaseSH): + """Generic Pygments syntax highlighter.""" + # Store the language name and a ref to the lexer + _lang_name = None + _lexer = None + + # Syntax highlighting states (from one text block to another): + NORMAL = 0 + def __init__(self, parent, font=None, color_scheme=None): + # Map Pygments tokens to Spyder tokens + self._tokmap = {Text: "normal", + Generic: "normal", + Other: "normal", + Keyword: "keyword", + Token.Operator: "normal", + Name.Builtin: "builtin", + Name: "normal", + Comment: "comment", + String: "string", + Number: "number"} + # Load Pygments' Lexer + if self._lang_name is not None: + self._lexer = get_lexer_by_name(self._lang_name) + + BaseSH.__init__(self, parent, font, color_scheme) + + # This worker runs in a thread to avoid blocking when doing full file + # parsing + self._worker_manager = WorkerManager() + + # Store the format for all the tokens after Pygments parsing + self._charlist = [] + + # Flag variable to avoid unnecessary highlights if the worker has not + # yet finished processing + self._allow_highlight = True + + def stop(self): + self._worker_manager.terminate_all() + + def make_charlist(self): + """Parses the complete text and stores format for each character.""" + + def worker_output(worker, output, error): + """Worker finished callback.""" + self._charlist = output + if error is None and output: + self._allow_highlight = True + self.rehighlight() + self._allow_highlight = False + + text = to_text_string(self.document().toPlainText()) + tokens = self._lexer.get_tokens(text) + + # Before starting a new worker process make sure to end previous + # incarnations + self._worker_manager.terminate_all() + + worker = self._worker_manager.create_python_worker( + self._make_charlist, + tokens, + self._tokmap, + self.formats, + ) + worker.sig_finished.connect(worker_output) + worker.start() + + def _make_charlist(self, tokens, tokmap, formats): + """ + Parses the complete text and stores format for each character. + + Uses the attached lexer to parse into a list of tokens and Pygments + token types. Then breaks tokens into individual letters, each with a + Spyder token type attached. Stores this list as self._charlist. + + It's attached to the contentsChange signal of the parent QTextDocument + so that the charlist is updated whenever the document changes. + """ + + def _get_fmt(typ): + """Get the Spyder format code for the given Pygments token type.""" + # Exact matches first + if typ in tokmap: + return tokmap[typ] + # Partial (parent-> child) matches + for key, val in tokmap.items(): + if typ in key: # Checks if typ is a subtype of key. + return val + + return 'normal' + + charlist = [] + for typ, token in tokens: + fmt = formats[_get_fmt(typ)] + for letter in token: + charlist.append((fmt, letter)) + + return charlist + + def highlightBlock(self, text): + """ Actually highlight the block""" + # Note that an undefined blockstate is equal to -1, so the first block + # will have the correct behaviour of starting at 0. + if self._allow_highlight: + start = self.previousBlockState() + 1 + end = start + qstring_length(text) + for i, (fmt, letter) in enumerate(self._charlist[start:end]): + self.setFormat(i, 1, fmt) + self.setCurrentBlockState(end) + self.highlight_extras(text) + + +class PythonLoggingLexer(RegexLexer): + """ + A lexer for logs generated by the Python builtin 'logging' library. + + Taken from + https://bitbucket.org/birkenfeld/pygments-main/pull-requests/451/add-python-logging-lexer + """ + + name = 'Python Logging' + aliases = ['pylog', 'pythonlogging'] + filenames = ['*.log'] + tokens = { + 'root': [ + (r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\,?\d*)(\s\w+)', + bygroups(Comment.Preproc, Number.Integer), 'message'), + (r'"(.*?)"|\'(.*?)\'', String), + (r'(\d)', Number.Integer), + (r'(\s.+/n)', Text) + ], + + 'message': [ + (r'(\s-)(\sDEBUG)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', + bygroups(Text, Number, Text, Name.Builtin), '#pop'), + (r'(\s-)(\sINFO\w*)(\s-)(\s*[\d\w]+([.]?[\d\w]+)+\s*)', + bygroups(Generic.Heading, Text, Text, Name.Builtin), '#pop'), + (r'(\sWARN\w*)(\s.+)', bygroups(String, String), '#pop'), + (r'(\sERROR)(\s.+)', + bygroups(Generic.Error, Name.Constant), '#pop'), + (r'(\sCRITICAL)(\s.+)', + bygroups(Generic.Error, Name.Constant), '#pop'), + (r'(\sTRACE)(\s.+)', + bygroups(Generic.Error, Name.Constant), '#pop'), + (r'(\s\w+)(\s.+)', + bygroups(Comment, Generic.Output), '#pop'), + ], + + } + + +def guess_pygments_highlighter(filename): + """ + Factory to generate syntax highlighter for the given filename. + + If a syntax highlighter is not available for a particular file, this + function will attempt to generate one based on the lexers in Pygments. If + Pygments is not available or does not have an appropriate lexer, TextSH + will be returned instead. + """ + try: + from pygments.lexers import get_lexer_for_filename, get_lexer_by_name + except Exception: + return TextSH + + root, ext = os.path.splitext(filename) + if ext == '.txt': + # Pygments assigns a lexer that doesn’t highlight anything to + # txt files. So we avoid that here. + return TextSH + elif ext in custom_extension_lexer_mapping: + try: + lexer = get_lexer_by_name(custom_extension_lexer_mapping[ext]) + except Exception: + return TextSH + elif ext == '.log': + lexer = PythonLoggingLexer() + else: + try: + lexer = get_lexer_for_filename(filename) + except Exception: + return TextSH + + class GuessedPygmentsSH(PygmentsSH): + _lexer = lexer + + return GuessedPygmentsSH diff --git a/spyder/utils/system.py b/spyder/utils/system.py index b843e7679ae..064e9c0fd50 100644 --- a/spyder/utils/system.py +++ b/spyder/utils/system.py @@ -1,65 +1,65 @@ -# -*- coding: utf-8 -*- -# -# Copyright (c) 2009- Spyder Project Contributors -# -# Distributed under the terms of the MIT License -# (see spyder/__init__.py for details) - -""" -Operating-system-specific utilities. -""" - -# Standard library imports -import os - -# Third-party imports -import psutil - - -def windows_memory_usage(): - """ - Return physical memory usage (float). - - It works on Windows platforms only and without psutil. - """ - from ctypes import windll, Structure, c_uint64, sizeof, byref - from ctypes.wintypes import DWORD - class MemoryStatus(Structure): - _fields_ = [('dwLength', DWORD), - ('dwMemoryLoad',DWORD), - ('ullTotalPhys', c_uint64), - ('ullAvailPhys', c_uint64), - ('ullTotalPageFile', c_uint64), - ('ullAvailPageFile', c_uint64), - ('ullTotalVirtual', c_uint64), - ('ullAvailVirtual', c_uint64), - ('ullAvailExtendedVirtual', c_uint64),] - memorystatus = MemoryStatus() - # MSDN documentation states that dwLength must be set to MemoryStatus - # size before calling GlobalMemoryStatusEx - # https://msdn.microsoft.com/en-us/library/aa366770(v=vs.85) - memorystatus.dwLength = sizeof(memorystatus) - windll.kernel32.GlobalMemoryStatusEx(byref(memorystatus)) - return float(memorystatus.dwMemoryLoad) - - -def memory_usage(): - """Return physical memory usage (float).""" - # This is needed to avoid a deprecation warning error with - # newer psutil versions - try: - percent = psutil.virtual_memory().percent - except: - percent = psutil.phymem_usage().percent - return percent - - -if __name__ == '__main__': - print("*"*80) # spyder: test-skip - print(memory_usage.__doc__) # spyder: test-skip - print(memory_usage()) # spyder: test-skip - if os.name == 'nt': - # windll can only be imported if os.name = 'nt' or 'ce' - print("*"*80) # spyder: test-skip - print(windows_memory_usage.__doc__) # spyder: test-skip - print(windows_memory_usage()) # spyder: test-skip +# -*- coding: utf-8 -*- +# +# Copyright (c) 2009- Spyder Project Contributors +# +# Distributed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Operating-system-specific utilities. +""" + +# Standard library imports +import os + +# Third-party imports +import psutil + + +def windows_memory_usage(): + """ + Return physical memory usage (float). + + It works on Windows platforms only and without psutil. + """ + from ctypes import windll, Structure, c_uint64, sizeof, byref + from ctypes.wintypes import DWORD + class MemoryStatus(Structure): + _fields_ = [('dwLength', DWORD), + ('dwMemoryLoad',DWORD), + ('ullTotalPhys', c_uint64), + ('ullAvailPhys', c_uint64), + ('ullTotalPageFile', c_uint64), + ('ullAvailPageFile', c_uint64), + ('ullTotalVirtual', c_uint64), + ('ullAvailVirtual', c_uint64), + ('ullAvailExtendedVirtual', c_uint64),] + memorystatus = MemoryStatus() + # MSDN documentation states that dwLength must be set to MemoryStatus + # size before calling GlobalMemoryStatusEx + # https://msdn.microsoft.com/en-us/library/aa366770(v=vs.85) + memorystatus.dwLength = sizeof(memorystatus) + windll.kernel32.GlobalMemoryStatusEx(byref(memorystatus)) + return float(memorystatus.dwMemoryLoad) + + +def memory_usage(): + """Return physical memory usage (float).""" + # This is needed to avoid a deprecation warning error with + # newer psutil versions + try: + percent = psutil.virtual_memory().percent + except: + percent = psutil.phymem_usage().percent + return percent + + +if __name__ == '__main__': + print("*"*80) # spyder: test-skip + print(memory_usage.__doc__) # spyder: test-skip + print(memory_usage()) # spyder: test-skip + if os.name == 'nt': + # windll can only be imported if os.name = 'nt' or 'ce' + print("*"*80) # spyder: test-skip + print(windows_memory_usage.__doc__) # spyder: test-skip + print(windows_memory_usage()) # spyder: test-skip diff --git a/spyder/utils/vcs.py b/spyder/utils/vcs.py index 5445f580678..7e7a6e0cb5b 100644 --- a/spyder/utils/vcs.py +++ b/spyder/utils/vcs.py @@ -1,244 +1,244 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Utilities for version control systems""" - -from __future__ import print_function - -import os -import os.path as osp -import subprocess -import sys - -# Local imports -from spyder.config.base import running_under_pytest -from spyder.utils import programs -from spyder.utils.misc import abspardir -from spyder.py3compat import PY3 - - -SUPPORTED = [ -{ - 'name': 'Mercurial', - 'rootdir': '.hg', - 'actions': dict( - commit=( ('thg', ['commit']), - ('hgtk', ['commit']) ), - browse=( ('thg', ['log']), - ('hgtk', ['log']) )) -}, { - 'name': 'Git', - 'rootdir': '.git', - 'actions': dict( - commit=( ('git', ['gui' if os.name == 'nt' else 'cola']), ), - browse=( ('gitk', []), )) -}] - - -class ActionToolNotFound(RuntimeError): - """Exception to transmit information about supported tools for - failed attempt to execute given action""" - - def __init__(self, vcsname, action, tools): - RuntimeError.__init__(self) - self.vcsname = vcsname - self.action = action - self.tools = tools - - -def get_vcs_info(path): - """Return support status dict if path is under VCS root""" - for info in SUPPORTED: - vcs_path = osp.join(path, info['rootdir']) - if osp.isdir(vcs_path): - return info - - -def get_vcs_root(path): - """Return VCS root directory path - Return None if path is not within a supported VCS repository""" - previous_path = path - while get_vcs_info(path) is None: - path = abspardir(path) - if path == previous_path: - return - else: - previous_path = path - return osp.abspath(path) - - -def is_vcs_repository(path): - """Return True if path is a supported VCS repository""" - return get_vcs_root(path) is not None - - -def run_vcs_tool(path, action): - """If path is a valid VCS repository, run the corresponding VCS tool - Supported VCS actions: 'commit', 'browse' - Return False if the VCS tool is not installed""" - info = get_vcs_info(get_vcs_root(path)) - tools = info['actions'][action] - for tool, args in tools: - if programs.find_program(tool): - if not running_under_pytest(): - programs.run_program(tool, args, cwd=path) - else: - return True - return - else: - cmdnames = [name for name, args in tools] - raise ActionToolNotFound(info['name'], action, cmdnames) - -def is_hg_installed(): - """Return True if Mercurial is installed""" - return programs.find_program('hg') is not None - - -def get_hg_revision(repopath): - """Return Mercurial revision for the repository located at repopath - Result is a tuple (global, local, branch), with None values on error - For example: - >>> get_hg_revision(".") - ('eba7273c69df+', '2015+', 'default') - """ - try: - assert osp.isdir(osp.join(repopath, '.hg')) - proc = programs.run_program('hg', ['id', '-nib', repopath]) - output, _err = proc.communicate() - # output is now: ('eba7273c69df+ 2015+ default\n', None) - # Split 2 times max to allow spaces in branch names. - return tuple(output.decode().strip().split(None, 2)) - except (subprocess.CalledProcessError, AssertionError, AttributeError, - OSError): - return (None, None, None) - - -def get_git_revision(repopath): - """ - Return Git revision for the repository located at repopath - - Result is a tuple (latest commit hash, branch), with None values on - error - """ - try: - git = programs.find_git() - assert git is not None and osp.isdir(osp.join(repopath, '.git')) - commit = programs.run_program(git, ['rev-parse', '--short', 'HEAD'], - cwd=repopath).communicate() - commit = commit[0].strip() - if PY3: - commit = commit.decode(sys.getdefaultencoding()) - - # Branch - branches = programs.run_program(git, ['branch'], - cwd=repopath).communicate() - branches = branches[0] - if PY3: - branches = branches.decode(sys.getdefaultencoding()) - branches = branches.split('\n') - active_branch = [b for b in branches if b.startswith('*')] - if len(active_branch) != 1: - branch = None - else: - branch = active_branch[0].split(None, 1)[1] - - return commit, branch - except (subprocess.CalledProcessError, AssertionError, AttributeError, - OSError): - return None, None - - -def get_git_refs(repopath): - """ - Return Git active branch, state, branches (plus tags). - """ - tags = [] - branches = [] - branch = '' - files_modifed = [] - - if os.path.isfile(repopath): - repopath = os.path.dirname(repopath) - - git = programs.find_git() - - if git: - try: - # Files modified - out, err = programs.run_program( - git, ['status', '-s'], - cwd=repopath, - ).communicate() - - if PY3: - out = out.decode(sys.getdefaultencoding()) - files_modifed = [line.strip() for line in out.split('\n') if line] - - # Tags - out, err = programs.run_program( - git, ['tag'], - cwd=repopath, - ).communicate() - - if PY3: - out = out.decode(sys.getdefaultencoding()) - tags = [line.strip() for line in out.split('\n') if line] - - # Branches - out, err = programs.run_program( - git, ['branch', '-a'], - cwd=repopath, - ).communicate() - - if PY3: - out = out.decode(sys.getdefaultencoding()) - - lines = [line.strip() for line in out.split('\n') if line] - for line in lines: - if line.startswith('*'): - line = line.replace('*', '').strip() - branch = line - - branches.append(line) - - except (subprocess.CalledProcessError, AttributeError, OSError): - pass - - return branches + tags, branch, files_modifed - - -def get_git_remotes(fpath): - """Return git remotes for repo on fpath.""" - remote_data = {} - data, __ = programs.run_program( - 'git', - ['remote', '-v'], - cwd=osp.dirname(fpath), - ).communicate() - - if PY3: - data = data.decode(sys.getdefaultencoding()) - - lines = [line.strip() for line in data.split('\n') if line] - for line in lines: - if line: - remote, value = line.split('\t') - remote_data[remote] = value.split(' ')[0] - - return remote_data - - -def remote_to_url(remote): - """Convert a git remote to a url.""" - url = '' - if remote.startswith('git@'): - url = remote.replace('git@', '') - url = url.replace(':', '/') - url = 'https://' + url.replace('.git', '') - else: - url = remote.replace('.git', '') - - return url +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Utilities for version control systems""" + +from __future__ import print_function + +import os +import os.path as osp +import subprocess +import sys + +# Local imports +from spyder.config.base import running_under_pytest +from spyder.utils import programs +from spyder.utils.misc import abspardir +from spyder.py3compat import PY3 + + +SUPPORTED = [ +{ + 'name': 'Mercurial', + 'rootdir': '.hg', + 'actions': dict( + commit=( ('thg', ['commit']), + ('hgtk', ['commit']) ), + browse=( ('thg', ['log']), + ('hgtk', ['log']) )) +}, { + 'name': 'Git', + 'rootdir': '.git', + 'actions': dict( + commit=( ('git', ['gui' if os.name == 'nt' else 'cola']), ), + browse=( ('gitk', []), )) +}] + + +class ActionToolNotFound(RuntimeError): + """Exception to transmit information about supported tools for + failed attempt to execute given action""" + + def __init__(self, vcsname, action, tools): + RuntimeError.__init__(self) + self.vcsname = vcsname + self.action = action + self.tools = tools + + +def get_vcs_info(path): + """Return support status dict if path is under VCS root""" + for info in SUPPORTED: + vcs_path = osp.join(path, info['rootdir']) + if osp.isdir(vcs_path): + return info + + +def get_vcs_root(path): + """Return VCS root directory path + Return None if path is not within a supported VCS repository""" + previous_path = path + while get_vcs_info(path) is None: + path = abspardir(path) + if path == previous_path: + return + else: + previous_path = path + return osp.abspath(path) + + +def is_vcs_repository(path): + """Return True if path is a supported VCS repository""" + return get_vcs_root(path) is not None + + +def run_vcs_tool(path, action): + """If path is a valid VCS repository, run the corresponding VCS tool + Supported VCS actions: 'commit', 'browse' + Return False if the VCS tool is not installed""" + info = get_vcs_info(get_vcs_root(path)) + tools = info['actions'][action] + for tool, args in tools: + if programs.find_program(tool): + if not running_under_pytest(): + programs.run_program(tool, args, cwd=path) + else: + return True + return + else: + cmdnames = [name for name, args in tools] + raise ActionToolNotFound(info['name'], action, cmdnames) + +def is_hg_installed(): + """Return True if Mercurial is installed""" + return programs.find_program('hg') is not None + + +def get_hg_revision(repopath): + """Return Mercurial revision for the repository located at repopath + Result is a tuple (global, local, branch), with None values on error + For example: + >>> get_hg_revision(".") + ('eba7273c69df+', '2015+', 'default') + """ + try: + assert osp.isdir(osp.join(repopath, '.hg')) + proc = programs.run_program('hg', ['id', '-nib', repopath]) + output, _err = proc.communicate() + # output is now: ('eba7273c69df+ 2015+ default\n', None) + # Split 2 times max to allow spaces in branch names. + return tuple(output.decode().strip().split(None, 2)) + except (subprocess.CalledProcessError, AssertionError, AttributeError, + OSError): + return (None, None, None) + + +def get_git_revision(repopath): + """ + Return Git revision for the repository located at repopath + + Result is a tuple (latest commit hash, branch), with None values on + error + """ + try: + git = programs.find_git() + assert git is not None and osp.isdir(osp.join(repopath, '.git')) + commit = programs.run_program(git, ['rev-parse', '--short', 'HEAD'], + cwd=repopath).communicate() + commit = commit[0].strip() + if PY3: + commit = commit.decode(sys.getdefaultencoding()) + + # Branch + branches = programs.run_program(git, ['branch'], + cwd=repopath).communicate() + branches = branches[0] + if PY3: + branches = branches.decode(sys.getdefaultencoding()) + branches = branches.split('\n') + active_branch = [b for b in branches if b.startswith('*')] + if len(active_branch) != 1: + branch = None + else: + branch = active_branch[0].split(None, 1)[1] + + return commit, branch + except (subprocess.CalledProcessError, AssertionError, AttributeError, + OSError): + return None, None + + +def get_git_refs(repopath): + """ + Return Git active branch, state, branches (plus tags). + """ + tags = [] + branches = [] + branch = '' + files_modifed = [] + + if os.path.isfile(repopath): + repopath = os.path.dirname(repopath) + + git = programs.find_git() + + if git: + try: + # Files modified + out, err = programs.run_program( + git, ['status', '-s'], + cwd=repopath, + ).communicate() + + if PY3: + out = out.decode(sys.getdefaultencoding()) + files_modifed = [line.strip() for line in out.split('\n') if line] + + # Tags + out, err = programs.run_program( + git, ['tag'], + cwd=repopath, + ).communicate() + + if PY3: + out = out.decode(sys.getdefaultencoding()) + tags = [line.strip() for line in out.split('\n') if line] + + # Branches + out, err = programs.run_program( + git, ['branch', '-a'], + cwd=repopath, + ).communicate() + + if PY3: + out = out.decode(sys.getdefaultencoding()) + + lines = [line.strip() for line in out.split('\n') if line] + for line in lines: + if line.startswith('*'): + line = line.replace('*', '').strip() + branch = line + + branches.append(line) + + except (subprocess.CalledProcessError, AttributeError, OSError): + pass + + return branches + tags, branch, files_modifed + + +def get_git_remotes(fpath): + """Return git remotes for repo on fpath.""" + remote_data = {} + data, __ = programs.run_program( + 'git', + ['remote', '-v'], + cwd=osp.dirname(fpath), + ).communicate() + + if PY3: + data = data.decode(sys.getdefaultencoding()) + + lines = [line.strip() for line in data.split('\n') if line] + for line in lines: + if line: + remote, value = line.split('\t') + remote_data[remote] = value.split(' ')[0] + + return remote_data + + +def remote_to_url(remote): + """Convert a git remote to a url.""" + url = '' + if remote.startswith('git@'): + url = remote.replace('git@', '') + url = url.replace(':', '/') + url = 'https://' + url.replace('.git', '') + else: + url = remote.replace('.git', '') + + return url diff --git a/spyder/utils/windows.py b/spyder/utils/windows.py index 0fa92559944..3ce9637ee83 100644 --- a/spyder/utils/windows.py +++ b/spyder/utils/windows.py @@ -1,48 +1,48 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Windows-specific utilities""" - - -from ctypes import windll - - -# --- Window control --- - -SW_SHOW = 5 # activate and display -SW_SHOWNA = 8 # show without activation -SW_HIDE = 0 - -GetConsoleWindow = windll.kernel32.GetConsoleWindow -ShowWindow = windll.user32.ShowWindow -IsWindowVisible = windll.user32.IsWindowVisible - -# Handle to console window associated with current Python -# interpreter procss, 0 if there is no window -console_window_handle = GetConsoleWindow() - -def set_attached_console_visible(state): - """Show/hide system console window attached to current process. - Return it's previous state. - - Availability: Windows""" - flag = {True: SW_SHOW, False: SW_HIDE} - return bool(ShowWindow(console_window_handle, flag[state])) - -def is_attached_console_visible(): - """Return True if attached console window is visible""" - return IsWindowVisible(console_window_handle) - -def set_windows_appusermodelid(): - """Make sure correct icon is used on Windows 7 taskbar""" - try: - return windll.shell32.SetCurrentProcessExplicitAppUserModelID("spyder.Spyder") - except AttributeError: - return "SetCurrentProcessExplicitAppUserModelID not found" - - -# [ ] the console state asks for a storage container -# [ ] reopen console on exit - better die open than become a zombie +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Windows-specific utilities""" + + +from ctypes import windll + + +# --- Window control --- + +SW_SHOW = 5 # activate and display +SW_SHOWNA = 8 # show without activation +SW_HIDE = 0 + +GetConsoleWindow = windll.kernel32.GetConsoleWindow +ShowWindow = windll.user32.ShowWindow +IsWindowVisible = windll.user32.IsWindowVisible + +# Handle to console window associated with current Python +# interpreter procss, 0 if there is no window +console_window_handle = GetConsoleWindow() + +def set_attached_console_visible(state): + """Show/hide system console window attached to current process. + Return it's previous state. + + Availability: Windows""" + flag = {True: SW_SHOW, False: SW_HIDE} + return bool(ShowWindow(console_window_handle, flag[state])) + +def is_attached_console_visible(): + """Return True if attached console window is visible""" + return IsWindowVisible(console_window_handle) + +def set_windows_appusermodelid(): + """Make sure correct icon is used on Windows 7 taskbar""" + try: + return windll.shell32.SetCurrentProcessExplicitAppUserModelID("spyder.Spyder") + except AttributeError: + return "SetCurrentProcessExplicitAppUserModelID not found" + + +# [ ] the console state asks for a storage container +# [ ] reopen console on exit - better die open than become a zombie diff --git a/spyder/widgets/arraybuilder.py b/spyder/widgets/arraybuilder.py index bb78bd3e5c5..a5c512db3c0 100644 --- a/spyder/widgets/arraybuilder.py +++ b/spyder/widgets/arraybuilder.py @@ -1,423 +1,423 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Array Builder Widget.""" - -# TODO: -# - Set font based on caller? editor console? and adjust size of widget -# - Fix positioning -# - Use the same font as editor/console? -# - Generalize separators -# - Generalize API for registering new array builders - -# Standard library imports -from __future__ import division -import re - -# Third party imports -from qtpy.QtCore import QEvent, QPoint, Qt -from qtpy.QtWidgets import (QDialog, QHBoxLayout, QLineEdit, QTableWidget, - QTableWidgetItem, QToolButton, QToolTip) - -# Local imports -from spyder.config.base import _ -from spyder.utils.icon_manager import ima -from spyder.utils.palette import QStylePalette -from spyder.widgets.helperwidgets import HelperToolButton - -# Constants -SHORTCUT_TABLE = "Ctrl+M" -SHORTCUT_INLINE = "Ctrl+Alt+M" - - -class ArrayBuilderType: - LANGUAGE = None - ELEMENT_SEPARATOR = None - ROW_SEPARATOR = None - BRACES = None - EXTRA_VALUES = None - ARRAY_PREFIX = None - MATRIX_PREFIX = None - - def check_values(self): - pass - - -class ArrayBuilderPython(ArrayBuilderType): - ELEMENT_SEPARATOR = ', ' - ROW_SEPARATOR = ';' - BRACES = '], [' - EXTRA_VALUES = { - 'np.nan': ['nan', 'NAN', 'NaN', 'Na', 'NA', 'na'], - 'np.inf': ['inf', 'INF'], - } - ARRAY_PREFIX = 'np.array([[' - MATRIX_PREFIX = 'np.matrix([[' - - -_REGISTERED_ARRAY_BUILDERS = { - 'python': ArrayBuilderPython, -} - - -class ArrayInline(QLineEdit): - def __init__(self, parent, options=None): - super(ArrayInline, self).__init__(parent) - self._parent = parent - self._options = options - - def keyPressEvent(self, event): - """Override Qt method.""" - if event.key() in [Qt.Key_Enter, Qt.Key_Return]: - self._parent.process_text() - if self._parent.is_valid(): - self._parent.keyPressEvent(event) - else: - super(ArrayInline, self).keyPressEvent(event) - - # To catch the Tab key event - def event(self, event): - """ - Override Qt method. - - This is needed to be able to intercept the Tab key press event. - """ - if event.type() == QEvent.KeyPress: - if (event.key() == Qt.Key_Tab or event.key() == Qt.Key_Space): - text = self.text() - cursor = self.cursorPosition() - - # Fix to include in "undo/redo" history - if cursor != 0 and text[cursor-1] == ' ': - text = (text[:cursor-1] + self._options.ROW_SEPARATOR - + ' ' + text[cursor:]) - else: - text = text[:cursor] + ' ' + text[cursor:] - self.setCursorPosition(cursor) - self.setText(text) - self.setCursorPosition(cursor + 1) - - return False - - return super(ArrayInline, self).event(event) - - -class ArrayTable(QTableWidget): - def __init__(self, parent, options=None): - super(ArrayTable, self).__init__(parent) - self._parent = parent - self._options = options - self.setRowCount(2) - self.setColumnCount(2) - self.reset_headers() - - # signals - self.cellChanged.connect(self.cell_changed) - - def keyPressEvent(self, event): - """Override Qt method.""" - super(ArrayTable, self).keyPressEvent(event) - if event.key() in [Qt.Key_Enter, Qt.Key_Return]: - # To avoid having to enter one final tab - self.setDisabled(True) - self.setDisabled(False) - self._parent.keyPressEvent(event) - - def cell_changed(self, row, col): - item = self.item(row, col) - value = None - - if item: - rows = self.rowCount() - cols = self.columnCount() - value = item.text() - - if value: - if row == rows - 1: - self.setRowCount(rows + 1) - if col == cols - 1: - self.setColumnCount(cols + 1) - self.reset_headers() - - def reset_headers(self): - """Update the column and row numbering in the headers.""" - rows = self.rowCount() - cols = self.columnCount() - - for r in range(rows): - self.setVerticalHeaderItem(r, QTableWidgetItem(str(r))) - for c in range(cols): - self.setHorizontalHeaderItem(c, QTableWidgetItem(str(c))) - self.setColumnWidth(c, 40) - - def text(self): - """Return the entered array in a parseable form.""" - text = [] - rows = self.rowCount() - cols = self.columnCount() - - # handle empty table case - if rows == 2 and cols == 2: - item = self.item(0, 0) - if item is None: - return '' - - for r in range(rows - 1): - for c in range(cols - 1): - item = self.item(r, c) - if item is not None: - value = item.text() - else: - value = '0' - - if not value.strip(): - value = '0' - - text.append(' ') - text.append(value) - text.append(self._options.ROW_SEPARATOR) - - return ''.join(text[:-1]) # Remove the final uneeded `;` - - -class ArrayBuilderDialog(QDialog): - def __init__(self, parent=None, inline=True, offset=0, force_float=False, - language='python'): - super(ArrayBuilderDialog, self).__init__(parent=parent) - self._language = language - self._options = _REGISTERED_ARRAY_BUILDERS.get('python', None) - self._parent = parent - self._text = None - self._valid = None - self._offset = offset - - # TODO: add this as an option in the General Preferences? - self._force_float = force_float - - self._help_inline = _(""" - Numpy Array/Matrix Helper
    - Type an array in Matlab : [1 2;3 4]
    - or Spyder simplified syntax : 1 2;3 4 -

    - Hit 'Enter' for array or 'Ctrl+Enter' for matrix. -

    - Hint:
    - Use two spaces or two tabs to generate a ';'. - """) - - self._help_table = _(""" - Numpy Array/Matrix Helper
    - Enter an array in the table.
    - Use Tab to move between cells. -

    - Hit 'Enter' for array or 'Ctrl+Enter' for matrix. -

    - Hint:
    - Use two tabs at the end of a row to move to the next row. - """) - - # Widgets - self._button_warning = QToolButton() - self._button_help = HelperToolButton() - self._button_help.setIcon(ima.icon('MessageBoxInformation')) - - style = ((""" - QToolButton {{ - border: 1px solid grey; - padding:0px; - border-radius: 2px; - background-color: qlineargradient(x1: 1, y1: 1, x2: 1, y2: 1, - stop: 0 {stop_0}, stop: 1 {stop_1}); - }} - """).format(stop_0=QStylePalette.COLOR_BACKGROUND_4, - stop_1=QStylePalette.COLOR_BACKGROUND_2)) - - self._button_help.setStyleSheet(style) - - if inline: - self._button_help.setToolTip(self._help_inline) - self._text = ArrayInline(self, options=self._options) - self._widget = self._text - else: - self._button_help.setToolTip(self._help_table) - self._table = ArrayTable(self, options=self._options) - self._widget = self._table - - style = """ - QDialog { - margin:0px; - border: 1px solid grey; - padding:0px; - border-radius: 2px; - }""" - self.setStyleSheet(style) - - style = """ - QToolButton { - margin:1px; - border: 0px solid grey; - padding:0px; - border-radius: 0px; - }""" - self._button_warning.setStyleSheet(style) - - # widget setup - self.setWindowFlags(Qt.Window | Qt.Dialog | Qt.FramelessWindowHint) - self.setModal(True) - self.setWindowOpacity(0.90) - self._widget.setMinimumWidth(200) - - # layout - self._layout = QHBoxLayout() - self._layout.addWidget(self._widget) - self._layout.addWidget(self._button_warning, 1, Qt.AlignTop) - self._layout.addWidget(self._button_help, 1, Qt.AlignTop) - self.setLayout(self._layout) - - self._widget.setFocus() - - def keyPressEvent(self, event): - """Override Qt method.""" - QToolTip.hideText() - ctrl = event.modifiers() & Qt.ControlModifier - - if event.key() in [Qt.Key_Enter, Qt.Key_Return]: - if ctrl: - self.process_text(array=False) - else: - self.process_text(array=True) - self.accept() - else: - super(ArrayBuilderDialog, self).keyPressEvent(event) - - def event(self, event): - """ - Override Qt method. - - Useful when in line edit mode. - """ - if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Tab: - return False - - return super(ArrayBuilderDialog, self).event(event) - - def process_text(self, array=True): - """ - Construct the text based on the entered content in the widget. - """ - if array: - prefix = self._options.ARRAY_PREFIX - else: - prefix = self._options.MATRIX_PREFIX - - suffix = ']])' - values = self._widget.text().strip() - - if values != '': - # cleans repeated spaces - exp = r'(\s*)' + self._options.ROW_SEPARATOR + r'(\s*)' - values = re.sub(exp, self._options.ROW_SEPARATOR, values) - values = re.sub(r"\s+", " ", values) - values = re.sub(r"]$", "", values) - values = re.sub(r"^\[", "", values) - values = re.sub(self._options.ROW_SEPARATOR + r'*$', '', values) - - # replaces spaces by commas - values = values.replace(' ', self._options.ELEMENT_SEPARATOR) - - # iterate to find number of rows and columns - new_values = [] - rows = values.split(self._options.ROW_SEPARATOR) - nrows = len(rows) - ncols = [] - for row in rows: - new_row = [] - elements = row.split(self._options.ELEMENT_SEPARATOR) - ncols.append(len(elements)) - for e in elements: - num = e - - # replaces not defined values - for key, values in self._options.EXTRA_VALUES.items(): - if num in values: - num = key - - # Convert numbers to floating point - if self._force_float: - try: - num = str(float(e)) - except: - pass - new_row.append(num) - new_values.append( - self._options.ELEMENT_SEPARATOR.join(new_row)) - new_values = self._options.ROW_SEPARATOR.join(new_values) - values = new_values - - # Check validity - if len(set(ncols)) == 1: - self._valid = True - else: - self._valid = False - - # Single rows are parsed as 1D arrays/matrices - if nrows == 1: - prefix = prefix[:-1] - suffix = suffix.replace("]])", "])") - - # Fix offset - offset = self._offset - braces = self._options.BRACES.replace( - ' ', - '\n' + ' '*(offset + len(prefix) - 1)) - values = values.replace(self._options.ROW_SEPARATOR, braces) - text = "{0}{1}{2}".format(prefix, values, suffix) - - self._text = text - else: - self._text = '' - - self.update_warning() - - def update_warning(self): - """ - Updates the icon and tip based on the validity of the array content. - """ - widget = self._button_warning - if not self.is_valid(): - tip = _('Array dimensions not valid') - widget.setIcon(ima.icon('MessageBoxWarning')) - widget.setToolTip(tip) - QToolTip.showText(self._widget.mapToGlobal(QPoint(0, 5)), tip) - else: - self._button_warning.setToolTip('') - - def is_valid(self): - """Return if the current array state is valid.""" - return self._valid - - def text(self): - """Return the parsed array/matrix text.""" - return self._text - - @property - def array_widget(self): - """Return the array builder widget.""" - return self._widget - - -def test(): # pragma: no cover - from spyder.utils.qthelpers import qapplication - app = qapplication() - dlg_table = ArrayBuilderDialog(None, inline=False) - dlg_inline = ArrayBuilderDialog(None, inline=True) - dlg_table.show() - dlg_inline.show() - app.exec_() - - -if __name__ == "__main__": # pragma: no cover - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Array Builder Widget.""" + +# TODO: +# - Set font based on caller? editor console? and adjust size of widget +# - Fix positioning +# - Use the same font as editor/console? +# - Generalize separators +# - Generalize API for registering new array builders + +# Standard library imports +from __future__ import division +import re + +# Third party imports +from qtpy.QtCore import QEvent, QPoint, Qt +from qtpy.QtWidgets import (QDialog, QHBoxLayout, QLineEdit, QTableWidget, + QTableWidgetItem, QToolButton, QToolTip) + +# Local imports +from spyder.config.base import _ +from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.widgets.helperwidgets import HelperToolButton + +# Constants +SHORTCUT_TABLE = "Ctrl+M" +SHORTCUT_INLINE = "Ctrl+Alt+M" + + +class ArrayBuilderType: + LANGUAGE = None + ELEMENT_SEPARATOR = None + ROW_SEPARATOR = None + BRACES = None + EXTRA_VALUES = None + ARRAY_PREFIX = None + MATRIX_PREFIX = None + + def check_values(self): + pass + + +class ArrayBuilderPython(ArrayBuilderType): + ELEMENT_SEPARATOR = ', ' + ROW_SEPARATOR = ';' + BRACES = '], [' + EXTRA_VALUES = { + 'np.nan': ['nan', 'NAN', 'NaN', 'Na', 'NA', 'na'], + 'np.inf': ['inf', 'INF'], + } + ARRAY_PREFIX = 'np.array([[' + MATRIX_PREFIX = 'np.matrix([[' + + +_REGISTERED_ARRAY_BUILDERS = { + 'python': ArrayBuilderPython, +} + + +class ArrayInline(QLineEdit): + def __init__(self, parent, options=None): + super(ArrayInline, self).__init__(parent) + self._parent = parent + self._options = options + + def keyPressEvent(self, event): + """Override Qt method.""" + if event.key() in [Qt.Key_Enter, Qt.Key_Return]: + self._parent.process_text() + if self._parent.is_valid(): + self._parent.keyPressEvent(event) + else: + super(ArrayInline, self).keyPressEvent(event) + + # To catch the Tab key event + def event(self, event): + """ + Override Qt method. + + This is needed to be able to intercept the Tab key press event. + """ + if event.type() == QEvent.KeyPress: + if (event.key() == Qt.Key_Tab or event.key() == Qt.Key_Space): + text = self.text() + cursor = self.cursorPosition() + + # Fix to include in "undo/redo" history + if cursor != 0 and text[cursor-1] == ' ': + text = (text[:cursor-1] + self._options.ROW_SEPARATOR + + ' ' + text[cursor:]) + else: + text = text[:cursor] + ' ' + text[cursor:] + self.setCursorPosition(cursor) + self.setText(text) + self.setCursorPosition(cursor + 1) + + return False + + return super(ArrayInline, self).event(event) + + +class ArrayTable(QTableWidget): + def __init__(self, parent, options=None): + super(ArrayTable, self).__init__(parent) + self._parent = parent + self._options = options + self.setRowCount(2) + self.setColumnCount(2) + self.reset_headers() + + # signals + self.cellChanged.connect(self.cell_changed) + + def keyPressEvent(self, event): + """Override Qt method.""" + super(ArrayTable, self).keyPressEvent(event) + if event.key() in [Qt.Key_Enter, Qt.Key_Return]: + # To avoid having to enter one final tab + self.setDisabled(True) + self.setDisabled(False) + self._parent.keyPressEvent(event) + + def cell_changed(self, row, col): + item = self.item(row, col) + value = None + + if item: + rows = self.rowCount() + cols = self.columnCount() + value = item.text() + + if value: + if row == rows - 1: + self.setRowCount(rows + 1) + if col == cols - 1: + self.setColumnCount(cols + 1) + self.reset_headers() + + def reset_headers(self): + """Update the column and row numbering in the headers.""" + rows = self.rowCount() + cols = self.columnCount() + + for r in range(rows): + self.setVerticalHeaderItem(r, QTableWidgetItem(str(r))) + for c in range(cols): + self.setHorizontalHeaderItem(c, QTableWidgetItem(str(c))) + self.setColumnWidth(c, 40) + + def text(self): + """Return the entered array in a parseable form.""" + text = [] + rows = self.rowCount() + cols = self.columnCount() + + # handle empty table case + if rows == 2 and cols == 2: + item = self.item(0, 0) + if item is None: + return '' + + for r in range(rows - 1): + for c in range(cols - 1): + item = self.item(r, c) + if item is not None: + value = item.text() + else: + value = '0' + + if not value.strip(): + value = '0' + + text.append(' ') + text.append(value) + text.append(self._options.ROW_SEPARATOR) + + return ''.join(text[:-1]) # Remove the final uneeded `;` + + +class ArrayBuilderDialog(QDialog): + def __init__(self, parent=None, inline=True, offset=0, force_float=False, + language='python'): + super(ArrayBuilderDialog, self).__init__(parent=parent) + self._language = language + self._options = _REGISTERED_ARRAY_BUILDERS.get('python', None) + self._parent = parent + self._text = None + self._valid = None + self._offset = offset + + # TODO: add this as an option in the General Preferences? + self._force_float = force_float + + self._help_inline = _(""" + Numpy Array/Matrix Helper
    + Type an array in Matlab : [1 2;3 4]
    + or Spyder simplified syntax : 1 2;3 4 +

    + Hit 'Enter' for array or 'Ctrl+Enter' for matrix. +

    + Hint:
    + Use two spaces or two tabs to generate a ';'. + """) + + self._help_table = _(""" + Numpy Array/Matrix Helper
    + Enter an array in the table.
    + Use Tab to move between cells. +

    + Hit 'Enter' for array or 'Ctrl+Enter' for matrix. +

    + Hint:
    + Use two tabs at the end of a row to move to the next row. + """) + + # Widgets + self._button_warning = QToolButton() + self._button_help = HelperToolButton() + self._button_help.setIcon(ima.icon('MessageBoxInformation')) + + style = ((""" + QToolButton {{ + border: 1px solid grey; + padding:0px; + border-radius: 2px; + background-color: qlineargradient(x1: 1, y1: 1, x2: 1, y2: 1, + stop: 0 {stop_0}, stop: 1 {stop_1}); + }} + """).format(stop_0=QStylePalette.COLOR_BACKGROUND_4, + stop_1=QStylePalette.COLOR_BACKGROUND_2)) + + self._button_help.setStyleSheet(style) + + if inline: + self._button_help.setToolTip(self._help_inline) + self._text = ArrayInline(self, options=self._options) + self._widget = self._text + else: + self._button_help.setToolTip(self._help_table) + self._table = ArrayTable(self, options=self._options) + self._widget = self._table + + style = """ + QDialog { + margin:0px; + border: 1px solid grey; + padding:0px; + border-radius: 2px; + }""" + self.setStyleSheet(style) + + style = """ + QToolButton { + margin:1px; + border: 0px solid grey; + padding:0px; + border-radius: 0px; + }""" + self._button_warning.setStyleSheet(style) + + # widget setup + self.setWindowFlags(Qt.Window | Qt.Dialog | Qt.FramelessWindowHint) + self.setModal(True) + self.setWindowOpacity(0.90) + self._widget.setMinimumWidth(200) + + # layout + self._layout = QHBoxLayout() + self._layout.addWidget(self._widget) + self._layout.addWidget(self._button_warning, 1, Qt.AlignTop) + self._layout.addWidget(self._button_help, 1, Qt.AlignTop) + self.setLayout(self._layout) + + self._widget.setFocus() + + def keyPressEvent(self, event): + """Override Qt method.""" + QToolTip.hideText() + ctrl = event.modifiers() & Qt.ControlModifier + + if event.key() in [Qt.Key_Enter, Qt.Key_Return]: + if ctrl: + self.process_text(array=False) + else: + self.process_text(array=True) + self.accept() + else: + super(ArrayBuilderDialog, self).keyPressEvent(event) + + def event(self, event): + """ + Override Qt method. + + Useful when in line edit mode. + """ + if event.type() == QEvent.KeyPress and event.key() == Qt.Key_Tab: + return False + + return super(ArrayBuilderDialog, self).event(event) + + def process_text(self, array=True): + """ + Construct the text based on the entered content in the widget. + """ + if array: + prefix = self._options.ARRAY_PREFIX + else: + prefix = self._options.MATRIX_PREFIX + + suffix = ']])' + values = self._widget.text().strip() + + if values != '': + # cleans repeated spaces + exp = r'(\s*)' + self._options.ROW_SEPARATOR + r'(\s*)' + values = re.sub(exp, self._options.ROW_SEPARATOR, values) + values = re.sub(r"\s+", " ", values) + values = re.sub(r"]$", "", values) + values = re.sub(r"^\[", "", values) + values = re.sub(self._options.ROW_SEPARATOR + r'*$', '', values) + + # replaces spaces by commas + values = values.replace(' ', self._options.ELEMENT_SEPARATOR) + + # iterate to find number of rows and columns + new_values = [] + rows = values.split(self._options.ROW_SEPARATOR) + nrows = len(rows) + ncols = [] + for row in rows: + new_row = [] + elements = row.split(self._options.ELEMENT_SEPARATOR) + ncols.append(len(elements)) + for e in elements: + num = e + + # replaces not defined values + for key, values in self._options.EXTRA_VALUES.items(): + if num in values: + num = key + + # Convert numbers to floating point + if self._force_float: + try: + num = str(float(e)) + except: + pass + new_row.append(num) + new_values.append( + self._options.ELEMENT_SEPARATOR.join(new_row)) + new_values = self._options.ROW_SEPARATOR.join(new_values) + values = new_values + + # Check validity + if len(set(ncols)) == 1: + self._valid = True + else: + self._valid = False + + # Single rows are parsed as 1D arrays/matrices + if nrows == 1: + prefix = prefix[:-1] + suffix = suffix.replace("]])", "])") + + # Fix offset + offset = self._offset + braces = self._options.BRACES.replace( + ' ', + '\n' + ' '*(offset + len(prefix) - 1)) + values = values.replace(self._options.ROW_SEPARATOR, braces) + text = "{0}{1}{2}".format(prefix, values, suffix) + + self._text = text + else: + self._text = '' + + self.update_warning() + + def update_warning(self): + """ + Updates the icon and tip based on the validity of the array content. + """ + widget = self._button_warning + if not self.is_valid(): + tip = _('Array dimensions not valid') + widget.setIcon(ima.icon('MessageBoxWarning')) + widget.setToolTip(tip) + QToolTip.showText(self._widget.mapToGlobal(QPoint(0, 5)), tip) + else: + self._button_warning.setToolTip('') + + def is_valid(self): + """Return if the current array state is valid.""" + return self._valid + + def text(self): + """Return the parsed array/matrix text.""" + return self._text + + @property + def array_widget(self): + """Return the array builder widget.""" + return self._widget + + +def test(): # pragma: no cover + from spyder.utils.qthelpers import qapplication + app = qapplication() + dlg_table = ArrayBuilderDialog(None, inline=False) + dlg_inline = ArrayBuilderDialog(None, inline=True) + dlg_table.show() + dlg_inline.show() + app.exec_() + + +if __name__ == "__main__": # pragma: no cover + test() diff --git a/spyder/widgets/browser.py b/spyder/widgets/browser.py index 5049e2e389c..c765c29347b 100644 --- a/spyder/widgets/browser.py +++ b/spyder/widgets/browser.py @@ -1,616 +1,616 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Simple web browser widget""" - -# Standard library imports -import re -import sre_constants -import sys - -# Third party imports -import qstylizer.style -from qtpy import PYQT5 -from qtpy.QtCore import QEvent, Qt, QUrl, Signal, Slot -from qtpy.QtGui import QFontInfo -from qtpy.QtWebEngineWidgets import (WEBENGINE, QWebEnginePage, - QWebEngineSettings, QWebEngineView) -from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QProgressBar, QWidget - -# Local imports -from spyder.api.translations import get_translation -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import DEV -from spyder.config.gui import OLD_PYQT -from spyder.py3compat import is_text_string, to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.palette import QStylePalette -from spyder.utils.qthelpers import (action2button, create_plugin_layout, - create_toolbutton) -from spyder.widgets.comboboxes import UrlComboBox -from spyder.widgets.findreplace import FindReplace - - -# Localization -_ = get_translation('spyder') - - -# --- Constants -# ---------------------------------------------------------------------------- -class WebViewActions: - ZoomIn = 'zoom_in_action' - ZoomOut = 'zoom_out_action' - Back = 'back_action' - Forward = 'forward_action' - SelectAll = 'select_all_action' - Copy = 'copy_action' - Inspect = 'inspect_action' - Stop = 'stop_action' - Refresh = 'refresh_action' - - -class WebViewMenuSections: - Move = 'move_section' - Select = 'select_section' - Zoom = 'zoom_section' - Extras = 'extras_section' - - -class WebViewMenus: - Context = 'context_menu' - - -# --- Widgets -# ---------------------------------------------------------------------------- -class WebPage(QWebEnginePage): - """ - Web page subclass to manage hyperlinks for WebEngine - - Note: This can't be used for WebKit because the - acceptNavigationRequest method has a different - functionality for it. - """ - linkClicked = Signal(QUrl) - - def acceptNavigationRequest(self, url, navigation_type, isMainFrame): - """ - Overloaded method to handle links ourselves - """ - if navigation_type == QWebEnginePage.NavigationTypeLinkClicked: - self.linkClicked.emit(url) - return False - - return super(WebPage, self).acceptNavigationRequest( - url, navigation_type, isMainFrame) - - -class WebView(QWebEngineView, SpyderWidgetMixin): - """ - Web view. - """ - sig_focus_in_event = Signal() - """ - This signal is emitted when the widget receives focus. - """ - - sig_focus_out_event = Signal() - """ - This signal is emitted when the widget loses focus. - """ - - def __init__(self, parent, handle_links=True, class_parent=None): - class_parent = parent if class_parent is None else class_parent - if PYQT5: - super().__init__(parent, class_parent=class_parent) - else: - QWebEngineView.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=class_parent) - - self.zoom_factor = 1. - self.context_menu = None - - if WEBENGINE: - if handle_links: - web_page = WebPage(self) - else: - web_page = QWebEnginePage(self) - - self.setPage(web_page) - self.source_text = '' - - def setup(self, options={}): - # Actions - original_back_action = self.pageAction(QWebEnginePage.Back) - back_action = self.create_action( - name=WebViewActions.Back, - text=_("Back"), - icon=self.create_icon('previous'), - triggered=lambda: original_back_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_forward_action = self.pageAction(QWebEnginePage.Forward) - forward_action = self.create_action( - name=WebViewActions.Forward, - text=_("Forward"), - icon=self.create_icon('next'), - triggered=lambda: original_forward_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_select_action = self.pageAction(QWebEnginePage.SelectAll) - select_all_action = self.create_action( - name=WebViewActions.SelectAll, - text=_("Select all"), - triggered=lambda: original_select_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_copy_action = self.pageAction(QWebEnginePage.Copy) - copy_action = self.create_action( - name=WebViewActions.Copy, - text=_("Copy"), - triggered=lambda: original_copy_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - self.zoom_in_action = self.create_action( - name=WebViewActions.ZoomIn, - text=_("Zoom in"), - icon=self.create_icon('zoom_in'), - triggered=self.zoom_in, - context=Qt.WidgetWithChildrenShortcut, - ) - - self.zoom_out_action = self.create_action( - name=WebViewActions.ZoomOut, - text=_("Zoom out"), - icon=self.create_icon('zoom_out'), - triggered=self.zoom_out, - context=Qt.WidgetWithChildrenShortcut, - ) - - original_inspect_action = self.pageAction( - QWebEnginePage.InspectElement) - inspect_action = self.create_action( - name=WebViewActions.Inspect, - text=_("Inspect"), - triggered=lambda: original_inspect_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_refresh_action = self.pageAction(QWebEnginePage.Reload) - self.create_action( - name=WebViewActions.Refresh, - text=_("Refresh"), - icon=self.create_icon('refresh'), - triggered=lambda: original_refresh_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - original_stop_action = self.pageAction(QWebEnginePage.Stop) - self.create_action( - name=WebViewActions.Stop, - text=_("Stop"), - icon=self.create_icon('stop'), - triggered=lambda: original_stop_action.trigger(), - context=Qt.WidgetWithChildrenShortcut, - ) - - menu = self.create_menu(WebViewMenus.Context) - self.context_menu = menu - for item in [back_action, forward_action]: - self.add_item_to_menu( - item, - menu=menu, - section=WebViewMenuSections.Move, - ) - - for item in [select_all_action, copy_action]: - self.add_item_to_menu( - item, - menu=menu, - section=WebViewMenuSections.Select, - ) - - for item in [self.zoom_in_action, self.zoom_out_action]: - self.add_item_to_menu( - item, - menu=menu, - section=WebViewMenuSections.Zoom, - ) - - self.add_item_to_menu( - inspect_action, - menu=menu, - section=WebViewMenuSections.Extras, - ) - - if DEV and not WEBENGINE: - settings = self.page().settings() - settings.setAttribute(QWebEngineSettings.DeveloperExtrasEnabled, - True) - inspect_action.setVisible(True) - else: - inspect_action.setVisible(False) - - def find_text(self, text, changed=True, forward=True, case=False, - word=False, regexp=False): - """Find text.""" - if not WEBENGINE: - findflag = QWebEnginePage.FindWrapsAroundDocument - else: - findflag = 0 - - if not forward: - findflag = findflag | QWebEnginePage.FindBackward - if case: - findflag = findflag | QWebEnginePage.FindCaseSensitively - - return self.findText(text, QWebEnginePage.FindFlags(findflag)) - - def get_selected_text(self): - """Return text selected by current text cursor""" - return self.selectedText() - - def set_source_text(self, source_text): - """Set source text of the page. Callback for QWebEngineView.""" - self.source_text = source_text - - def get_number_matches(self, pattern, source_text='', case=False, - regexp=False, word=False): - """Get the number of matches for the searched text.""" - pattern = to_text_string(pattern) - if not pattern: - return 0 - if not regexp: - pattern = re.escape(pattern) - if not source_text: - if WEBENGINE: - self.page().toPlainText(self.set_source_text) - source_text = to_text_string(self.source_text) - else: - source_text = to_text_string( - self.page().mainFrame().toPlainText()) - - if word: # match whole words only - pattern = r'\b{pattern}\b'.format(pattern=pattern) - - try: - if case: - regobj = re.compile(pattern, re.MULTILINE) - else: - regobj = re.compile(pattern, re.MULTILINE | re.IGNORECASE) - except sre_constants.error: - return - - number_matches = 0 - for match in regobj.finditer(source_text): - number_matches += 1 - - return number_matches - - def set_font(self, font, fixed_font=None): - font = QFontInfo(font) - settings = self.page().settings() - for fontfamily in (settings.StandardFont, settings.SerifFont, - settings.SansSerifFont, settings.CursiveFont, - settings.FantasyFont): - settings.setFontFamily(fontfamily, font.family()) - if fixed_font is not None: - settings.setFontFamily(settings.FixedFont, fixed_font.family()) - size = font.pixelSize() - settings.setFontSize(settings.DefaultFontSize, size) - settings.setFontSize(settings.DefaultFixedFontSize, size) - - def apply_zoom_factor(self): - """Apply zoom factor.""" - if hasattr(self, 'setZoomFactor'): - # Assuming Qt >=v4.5 - self.setZoomFactor(self.zoom_factor) - else: - # Qt v4.4 - self.setTextSizeMultiplier(self.zoom_factor) - - def set_zoom_factor(self, zoom_factor): - """Set zoom factor.""" - self.zoom_factor = zoom_factor - self.apply_zoom_factor() - - def get_zoom_factor(self): - """Return zoom factor.""" - return self.zoom_factor - - @Slot() - def zoom_out(self): - """Zoom out.""" - self.zoom_factor = max(.1, self.zoom_factor-.1) - self.apply_zoom_factor() - - @Slot() - def zoom_in(self): - """Zoom in.""" - self.zoom_factor += .1 - self.apply_zoom_factor() - - #------ QWebEngineView API ------------------------------------------------------- - def createWindow(self, webwindowtype): - import webbrowser - # See: spyder-ide/spyder#9849 - try: - webbrowser.open(to_text_string(self.url().toString())) - except ValueError: - pass - - def contextMenuEvent(self, event): - if self.context_menu: - self.context_menu.popup(event.globalPos()) - event.accept() - - def setHtml(self, html, baseUrl=QUrl()): - """ - Reimplement Qt method to prevent WebEngine to steal focus - when setting html on the page - - Solution taken from - https://bugreports.qt.io/browse/QTBUG-52999 - """ - if WEBENGINE: - if OLD_PYQT: - self.setEnabled(False) - super(WebView, self).setHtml(html, baseUrl) - if OLD_PYQT: - self.setEnabled(True) - else: - super(WebView, self).setHtml(html, baseUrl) - - # This is required to catch an error with PyQt 5.9, for which - # it seems this functionality is not working. - # Fixes spyder-ide/spyder#16703 - try: - # The event filter needs to be installed every time html is set - # because the proxy changes with new content. - self.focusProxy().installEventFilter(self) - except AttributeError: - pass - - def load(self, url): - """ - Load url. - - This is reimplemented to install our event filter after the - url is loaded. - """ - super().load(url) - self.focusProxy().installEventFilter(self) - - def eventFilter(self, widget, event): - """ - Handle events that affect the view. - - All events (e.g. focus in/out) reach the focus proxy, not this - widget itself. That's why this event filter is necessary. - """ - if self.focusProxy() is widget: - if event.type() == QEvent.FocusIn: - self.sig_focus_in_event.emit() - elif event.type() == QEvent.FocusOut: - self.sig_focus_out_event.emit() - return super().eventFilter(widget, event) - - -class WebBrowser(QWidget): - """ - Web browser widget. - """ - def __init__(self, parent=None, options_button=None, handle_links=True): - QWidget.__init__(self, parent) - - self.home_url = None - - self.webview = WebView(self, handle_links=handle_links) - self.webview.setup() - self.webview.loadFinished.connect(self.load_finished) - self.webview.titleChanged.connect(self.setWindowTitle) - self.webview.urlChanged.connect(self.url_changed) - - home_button = create_toolbutton(self, icon=ima.icon('home'), - tip=_("Home"), - triggered=self.go_home) - - zoom_out_button = action2button(self.webview.zoom_out_action) - zoom_in_button = action2button(self.webview.zoom_in_action) - - def pageact2btn(prop, icon=None): - return action2button( - self.webview.pageAction(prop), parent=self.webview, icon=icon) - - refresh_button = pageact2btn( - QWebEnginePage.Reload, icon=ima.icon('refresh')) - stop_button = pageact2btn( - QWebEnginePage.Stop, icon=ima.icon('stop')) - previous_button = pageact2btn( - QWebEnginePage.Back, icon=ima.icon('previous')) - next_button = pageact2btn( - QWebEnginePage.Forward, icon=ima.icon('next')) - - stop_button.setEnabled(False) - self.webview.loadStarted.connect(lambda: stop_button.setEnabled(True)) - self.webview.loadFinished.connect(lambda: stop_button.setEnabled(False)) - - progressbar = QProgressBar(self) - progressbar.setTextVisible(False) - progressbar.hide() - self.webview.loadStarted.connect(progressbar.show) - self.webview.loadProgress.connect(progressbar.setValue) - self.webview.loadFinished.connect(lambda _state: progressbar.hide()) - - label = QLabel(self.get_label()) - - self.url_combo = UrlComboBox(self) - self.url_combo.valid.connect(self.url_combo_activated) - if not WEBENGINE: - self.webview.iconChanged.connect(self.icon_changed) - - self.find_widget = FindReplace(self) - self.find_widget.set_editor(self.webview) - self.find_widget.hide() - - find_button = create_toolbutton(self, icon=ima.icon('find'), - tip=_("Find text"), - toggled=self.toggle_find_widget) - self.find_widget.visibility_changed.connect(find_button.setChecked) - - hlayout = QHBoxLayout() - for widget in (previous_button, next_button, home_button, find_button, - label, self.url_combo, zoom_out_button, zoom_in_button, - refresh_button, progressbar, stop_button): - hlayout.addWidget(widget) - - if options_button: - hlayout.addWidget(options_button) - - layout = create_plugin_layout(hlayout) - layout.addWidget(self.webview) - layout.addWidget(self.find_widget) - self.setLayout(layout) - - def get_label(self): - """Return address label text""" - return _("Address:") - - def set_home_url(self, text): - """Set home URL""" - self.home_url = QUrl(text) - - def set_url(self, url): - """Set current URL""" - self.url_changed(url) - self.go_to(url) - - def go_to(self, url_or_text): - """Go to page *address*""" - if is_text_string(url_or_text): - url = QUrl(url_or_text) - else: - url = url_or_text - self.webview.load(url) - - @Slot() - def go_home(self): - """Go to home page""" - if self.home_url is not None: - self.set_url(self.home_url) - - def text_to_url(self, text): - """Convert text address into QUrl object""" - return QUrl(text) - - def url_combo_activated(self, valid): - """Load URL from combo box first item""" - text = to_text_string(self.url_combo.currentText()) - self.go_to(self.text_to_url(text)) - - def load_finished(self, ok): - if not ok: - self.webview.setHtml(_("Unable to load page")) - - def url_to_text(self, url): - """Convert QUrl object to displayed text in combo box""" - return url.toString() - - def url_changed(self, url): - """Displayed URL has changed -> updating URL combo box""" - self.url_combo.add_text(self.url_to_text(url)) - - def icon_changed(self): - self.url_combo.setItemIcon(self.url_combo.currentIndex(), - self.webview.icon()) - self.setWindowIcon(self.webview.icon()) - - @Slot(bool) - def toggle_find_widget(self, state): - if state: - self.find_widget.show() - else: - self.find_widget.hide() - - -class FrameWebView(QFrame): - """ - Framed WebView for UI consistency in Spyder. - """ - linkClicked = Signal(QUrl) - - def __init__(self, parent, handle_links=True): - super().__init__(parent) - - self._webview = WebView( - self, - handle_links=handle_links, - class_parent=parent - ) - self._webview.sig_focus_in_event.connect( - lambda: self._apply_stylesheet(focus=True)) - self._webview.sig_focus_out_event.connect( - lambda: self._apply_stylesheet(focus=False)) - - layout = QHBoxLayout() - layout.addWidget(self._webview) - layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(layout) - self._apply_stylesheet() - - if handle_links: - if WEBENGINE: - self._webview.page().linkClicked.connect(self.linkClicked) - else: - self._webview.linkClicked.connect(self.linkClicked) - - def __getattr__(self, name): - if name == '_webview': - return super().__getattr__(name) - - if hasattr(self._webview, name): - return getattr(self._webview, name) - else: - return super().__getattr__(name) - - @property - def web_widget(self): - return self._webview - - def _apply_stylesheet(self, focus=False): - """Apply stylesheet according to the current focus.""" - if focus: - border_color = QStylePalette.COLOR_ACCENT_3 - else: - border_color = QStylePalette.COLOR_BACKGROUND_4 - - css = qstylizer.style.StyleSheet() - css.QFrame.setValues( - border=f'1px solid {border_color}', - margin='0px 1px 0px 1px', - padding='0px 0px 1px 0px', - borderRadius='3px' - ) - - self.setStyleSheet(css.toString()) - - -def test(): - """Run web browser""" - from spyder.utils.qthelpers import qapplication - app = qapplication(test_time=8) - widget = WebBrowser() - widget.show() - widget.set_home_url('https://www.google.com/') - widget.go_home() - sys.exit(app.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Simple web browser widget""" + +# Standard library imports +import re +import sre_constants +import sys + +# Third party imports +import qstylizer.style +from qtpy import PYQT5 +from qtpy.QtCore import QEvent, Qt, QUrl, Signal, Slot +from qtpy.QtGui import QFontInfo +from qtpy.QtWebEngineWidgets import (WEBENGINE, QWebEnginePage, + QWebEngineSettings, QWebEngineView) +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QProgressBar, QWidget + +# Local imports +from spyder.api.translations import get_translation +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import DEV +from spyder.config.gui import OLD_PYQT +from spyder.py3compat import is_text_string, to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.utils.qthelpers import (action2button, create_plugin_layout, + create_toolbutton) +from spyder.widgets.comboboxes import UrlComboBox +from spyder.widgets.findreplace import FindReplace + + +# Localization +_ = get_translation('spyder') + + +# --- Constants +# ---------------------------------------------------------------------------- +class WebViewActions: + ZoomIn = 'zoom_in_action' + ZoomOut = 'zoom_out_action' + Back = 'back_action' + Forward = 'forward_action' + SelectAll = 'select_all_action' + Copy = 'copy_action' + Inspect = 'inspect_action' + Stop = 'stop_action' + Refresh = 'refresh_action' + + +class WebViewMenuSections: + Move = 'move_section' + Select = 'select_section' + Zoom = 'zoom_section' + Extras = 'extras_section' + + +class WebViewMenus: + Context = 'context_menu' + + +# --- Widgets +# ---------------------------------------------------------------------------- +class WebPage(QWebEnginePage): + """ + Web page subclass to manage hyperlinks for WebEngine + + Note: This can't be used for WebKit because the + acceptNavigationRequest method has a different + functionality for it. + """ + linkClicked = Signal(QUrl) + + def acceptNavigationRequest(self, url, navigation_type, isMainFrame): + """ + Overloaded method to handle links ourselves + """ + if navigation_type == QWebEnginePage.NavigationTypeLinkClicked: + self.linkClicked.emit(url) + return False + + return super(WebPage, self).acceptNavigationRequest( + url, navigation_type, isMainFrame) + + +class WebView(QWebEngineView, SpyderWidgetMixin): + """ + Web view. + """ + sig_focus_in_event = Signal() + """ + This signal is emitted when the widget receives focus. + """ + + sig_focus_out_event = Signal() + """ + This signal is emitted when the widget loses focus. + """ + + def __init__(self, parent, handle_links=True, class_parent=None): + class_parent = parent if class_parent is None else class_parent + if PYQT5: + super().__init__(parent, class_parent=class_parent) + else: + QWebEngineView.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=class_parent) + + self.zoom_factor = 1. + self.context_menu = None + + if WEBENGINE: + if handle_links: + web_page = WebPage(self) + else: + web_page = QWebEnginePage(self) + + self.setPage(web_page) + self.source_text = '' + + def setup(self, options={}): + # Actions + original_back_action = self.pageAction(QWebEnginePage.Back) + back_action = self.create_action( + name=WebViewActions.Back, + text=_("Back"), + icon=self.create_icon('previous'), + triggered=lambda: original_back_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_forward_action = self.pageAction(QWebEnginePage.Forward) + forward_action = self.create_action( + name=WebViewActions.Forward, + text=_("Forward"), + icon=self.create_icon('next'), + triggered=lambda: original_forward_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_select_action = self.pageAction(QWebEnginePage.SelectAll) + select_all_action = self.create_action( + name=WebViewActions.SelectAll, + text=_("Select all"), + triggered=lambda: original_select_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_copy_action = self.pageAction(QWebEnginePage.Copy) + copy_action = self.create_action( + name=WebViewActions.Copy, + text=_("Copy"), + triggered=lambda: original_copy_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + self.zoom_in_action = self.create_action( + name=WebViewActions.ZoomIn, + text=_("Zoom in"), + icon=self.create_icon('zoom_in'), + triggered=self.zoom_in, + context=Qt.WidgetWithChildrenShortcut, + ) + + self.zoom_out_action = self.create_action( + name=WebViewActions.ZoomOut, + text=_("Zoom out"), + icon=self.create_icon('zoom_out'), + triggered=self.zoom_out, + context=Qt.WidgetWithChildrenShortcut, + ) + + original_inspect_action = self.pageAction( + QWebEnginePage.InspectElement) + inspect_action = self.create_action( + name=WebViewActions.Inspect, + text=_("Inspect"), + triggered=lambda: original_inspect_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_refresh_action = self.pageAction(QWebEnginePage.Reload) + self.create_action( + name=WebViewActions.Refresh, + text=_("Refresh"), + icon=self.create_icon('refresh'), + triggered=lambda: original_refresh_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + original_stop_action = self.pageAction(QWebEnginePage.Stop) + self.create_action( + name=WebViewActions.Stop, + text=_("Stop"), + icon=self.create_icon('stop'), + triggered=lambda: original_stop_action.trigger(), + context=Qt.WidgetWithChildrenShortcut, + ) + + menu = self.create_menu(WebViewMenus.Context) + self.context_menu = menu + for item in [back_action, forward_action]: + self.add_item_to_menu( + item, + menu=menu, + section=WebViewMenuSections.Move, + ) + + for item in [select_all_action, copy_action]: + self.add_item_to_menu( + item, + menu=menu, + section=WebViewMenuSections.Select, + ) + + for item in [self.zoom_in_action, self.zoom_out_action]: + self.add_item_to_menu( + item, + menu=menu, + section=WebViewMenuSections.Zoom, + ) + + self.add_item_to_menu( + inspect_action, + menu=menu, + section=WebViewMenuSections.Extras, + ) + + if DEV and not WEBENGINE: + settings = self.page().settings() + settings.setAttribute(QWebEngineSettings.DeveloperExtrasEnabled, + True) + inspect_action.setVisible(True) + else: + inspect_action.setVisible(False) + + def find_text(self, text, changed=True, forward=True, case=False, + word=False, regexp=False): + """Find text.""" + if not WEBENGINE: + findflag = QWebEnginePage.FindWrapsAroundDocument + else: + findflag = 0 + + if not forward: + findflag = findflag | QWebEnginePage.FindBackward + if case: + findflag = findflag | QWebEnginePage.FindCaseSensitively + + return self.findText(text, QWebEnginePage.FindFlags(findflag)) + + def get_selected_text(self): + """Return text selected by current text cursor""" + return self.selectedText() + + def set_source_text(self, source_text): + """Set source text of the page. Callback for QWebEngineView.""" + self.source_text = source_text + + def get_number_matches(self, pattern, source_text='', case=False, + regexp=False, word=False): + """Get the number of matches for the searched text.""" + pattern = to_text_string(pattern) + if not pattern: + return 0 + if not regexp: + pattern = re.escape(pattern) + if not source_text: + if WEBENGINE: + self.page().toPlainText(self.set_source_text) + source_text = to_text_string(self.source_text) + else: + source_text = to_text_string( + self.page().mainFrame().toPlainText()) + + if word: # match whole words only + pattern = r'\b{pattern}\b'.format(pattern=pattern) + + try: + if case: + regobj = re.compile(pattern, re.MULTILINE) + else: + regobj = re.compile(pattern, re.MULTILINE | re.IGNORECASE) + except sre_constants.error: + return + + number_matches = 0 + for match in regobj.finditer(source_text): + number_matches += 1 + + return number_matches + + def set_font(self, font, fixed_font=None): + font = QFontInfo(font) + settings = self.page().settings() + for fontfamily in (settings.StandardFont, settings.SerifFont, + settings.SansSerifFont, settings.CursiveFont, + settings.FantasyFont): + settings.setFontFamily(fontfamily, font.family()) + if fixed_font is not None: + settings.setFontFamily(settings.FixedFont, fixed_font.family()) + size = font.pixelSize() + settings.setFontSize(settings.DefaultFontSize, size) + settings.setFontSize(settings.DefaultFixedFontSize, size) + + def apply_zoom_factor(self): + """Apply zoom factor.""" + if hasattr(self, 'setZoomFactor'): + # Assuming Qt >=v4.5 + self.setZoomFactor(self.zoom_factor) + else: + # Qt v4.4 + self.setTextSizeMultiplier(self.zoom_factor) + + def set_zoom_factor(self, zoom_factor): + """Set zoom factor.""" + self.zoom_factor = zoom_factor + self.apply_zoom_factor() + + def get_zoom_factor(self): + """Return zoom factor.""" + return self.zoom_factor + + @Slot() + def zoom_out(self): + """Zoom out.""" + self.zoom_factor = max(.1, self.zoom_factor-.1) + self.apply_zoom_factor() + + @Slot() + def zoom_in(self): + """Zoom in.""" + self.zoom_factor += .1 + self.apply_zoom_factor() + + #------ QWebEngineView API ------------------------------------------------------- + def createWindow(self, webwindowtype): + import webbrowser + # See: spyder-ide/spyder#9849 + try: + webbrowser.open(to_text_string(self.url().toString())) + except ValueError: + pass + + def contextMenuEvent(self, event): + if self.context_menu: + self.context_menu.popup(event.globalPos()) + event.accept() + + def setHtml(self, html, baseUrl=QUrl()): + """ + Reimplement Qt method to prevent WebEngine to steal focus + when setting html on the page + + Solution taken from + https://bugreports.qt.io/browse/QTBUG-52999 + """ + if WEBENGINE: + if OLD_PYQT: + self.setEnabled(False) + super(WebView, self).setHtml(html, baseUrl) + if OLD_PYQT: + self.setEnabled(True) + else: + super(WebView, self).setHtml(html, baseUrl) + + # This is required to catch an error with PyQt 5.9, for which + # it seems this functionality is not working. + # Fixes spyder-ide/spyder#16703 + try: + # The event filter needs to be installed every time html is set + # because the proxy changes with new content. + self.focusProxy().installEventFilter(self) + except AttributeError: + pass + + def load(self, url): + """ + Load url. + + This is reimplemented to install our event filter after the + url is loaded. + """ + super().load(url) + self.focusProxy().installEventFilter(self) + + def eventFilter(self, widget, event): + """ + Handle events that affect the view. + + All events (e.g. focus in/out) reach the focus proxy, not this + widget itself. That's why this event filter is necessary. + """ + if self.focusProxy() is widget: + if event.type() == QEvent.FocusIn: + self.sig_focus_in_event.emit() + elif event.type() == QEvent.FocusOut: + self.sig_focus_out_event.emit() + return super().eventFilter(widget, event) + + +class WebBrowser(QWidget): + """ + Web browser widget. + """ + def __init__(self, parent=None, options_button=None, handle_links=True): + QWidget.__init__(self, parent) + + self.home_url = None + + self.webview = WebView(self, handle_links=handle_links) + self.webview.setup() + self.webview.loadFinished.connect(self.load_finished) + self.webview.titleChanged.connect(self.setWindowTitle) + self.webview.urlChanged.connect(self.url_changed) + + home_button = create_toolbutton(self, icon=ima.icon('home'), + tip=_("Home"), + triggered=self.go_home) + + zoom_out_button = action2button(self.webview.zoom_out_action) + zoom_in_button = action2button(self.webview.zoom_in_action) + + def pageact2btn(prop, icon=None): + return action2button( + self.webview.pageAction(prop), parent=self.webview, icon=icon) + + refresh_button = pageact2btn( + QWebEnginePage.Reload, icon=ima.icon('refresh')) + stop_button = pageact2btn( + QWebEnginePage.Stop, icon=ima.icon('stop')) + previous_button = pageact2btn( + QWebEnginePage.Back, icon=ima.icon('previous')) + next_button = pageact2btn( + QWebEnginePage.Forward, icon=ima.icon('next')) + + stop_button.setEnabled(False) + self.webview.loadStarted.connect(lambda: stop_button.setEnabled(True)) + self.webview.loadFinished.connect(lambda: stop_button.setEnabled(False)) + + progressbar = QProgressBar(self) + progressbar.setTextVisible(False) + progressbar.hide() + self.webview.loadStarted.connect(progressbar.show) + self.webview.loadProgress.connect(progressbar.setValue) + self.webview.loadFinished.connect(lambda _state: progressbar.hide()) + + label = QLabel(self.get_label()) + + self.url_combo = UrlComboBox(self) + self.url_combo.valid.connect(self.url_combo_activated) + if not WEBENGINE: + self.webview.iconChanged.connect(self.icon_changed) + + self.find_widget = FindReplace(self) + self.find_widget.set_editor(self.webview) + self.find_widget.hide() + + find_button = create_toolbutton(self, icon=ima.icon('find'), + tip=_("Find text"), + toggled=self.toggle_find_widget) + self.find_widget.visibility_changed.connect(find_button.setChecked) + + hlayout = QHBoxLayout() + for widget in (previous_button, next_button, home_button, find_button, + label, self.url_combo, zoom_out_button, zoom_in_button, + refresh_button, progressbar, stop_button): + hlayout.addWidget(widget) + + if options_button: + hlayout.addWidget(options_button) + + layout = create_plugin_layout(hlayout) + layout.addWidget(self.webview) + layout.addWidget(self.find_widget) + self.setLayout(layout) + + def get_label(self): + """Return address label text""" + return _("Address:") + + def set_home_url(self, text): + """Set home URL""" + self.home_url = QUrl(text) + + def set_url(self, url): + """Set current URL""" + self.url_changed(url) + self.go_to(url) + + def go_to(self, url_or_text): + """Go to page *address*""" + if is_text_string(url_or_text): + url = QUrl(url_or_text) + else: + url = url_or_text + self.webview.load(url) + + @Slot() + def go_home(self): + """Go to home page""" + if self.home_url is not None: + self.set_url(self.home_url) + + def text_to_url(self, text): + """Convert text address into QUrl object""" + return QUrl(text) + + def url_combo_activated(self, valid): + """Load URL from combo box first item""" + text = to_text_string(self.url_combo.currentText()) + self.go_to(self.text_to_url(text)) + + def load_finished(self, ok): + if not ok: + self.webview.setHtml(_("Unable to load page")) + + def url_to_text(self, url): + """Convert QUrl object to displayed text in combo box""" + return url.toString() + + def url_changed(self, url): + """Displayed URL has changed -> updating URL combo box""" + self.url_combo.add_text(self.url_to_text(url)) + + def icon_changed(self): + self.url_combo.setItemIcon(self.url_combo.currentIndex(), + self.webview.icon()) + self.setWindowIcon(self.webview.icon()) + + @Slot(bool) + def toggle_find_widget(self, state): + if state: + self.find_widget.show() + else: + self.find_widget.hide() + + +class FrameWebView(QFrame): + """ + Framed WebView for UI consistency in Spyder. + """ + linkClicked = Signal(QUrl) + + def __init__(self, parent, handle_links=True): + super().__init__(parent) + + self._webview = WebView( + self, + handle_links=handle_links, + class_parent=parent + ) + self._webview.sig_focus_in_event.connect( + lambda: self._apply_stylesheet(focus=True)) + self._webview.sig_focus_out_event.connect( + lambda: self._apply_stylesheet(focus=False)) + + layout = QHBoxLayout() + layout.addWidget(self._webview) + layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(layout) + self._apply_stylesheet() + + if handle_links: + if WEBENGINE: + self._webview.page().linkClicked.connect(self.linkClicked) + else: + self._webview.linkClicked.connect(self.linkClicked) + + def __getattr__(self, name): + if name == '_webview': + return super().__getattr__(name) + + if hasattr(self._webview, name): + return getattr(self._webview, name) + else: + return super().__getattr__(name) + + @property + def web_widget(self): + return self._webview + + def _apply_stylesheet(self, focus=False): + """Apply stylesheet according to the current focus.""" + if focus: + border_color = QStylePalette.COLOR_ACCENT_3 + else: + border_color = QStylePalette.COLOR_BACKGROUND_4 + + css = qstylizer.style.StyleSheet() + css.QFrame.setValues( + border=f'1px solid {border_color}', + margin='0px 1px 0px 1px', + padding='0px 0px 1px 0px', + borderRadius='3px' + ) + + self.setStyleSheet(css.toString()) + + +def test(): + """Run web browser""" + from spyder.utils.qthelpers import qapplication + app = qapplication(test_time=8) + widget = WebBrowser() + widget.show() + widget.set_home_url('https://www.google.com/') + widget.go_home() + sys.exit(app.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/widgets/collectionseditor.py b/spyder/widgets/collectionseditor.py index 6dd93920fa8..79a343894ff 100644 --- a/spyder/widgets/collectionseditor.py +++ b/spyder/widgets/collectionseditor.py @@ -1,1912 +1,1912 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ---------------------------------------------------------------------------- - -""" -Collections (i.e. dictionary, list, set and tuple) editor widget and dialog. -""" - -#TODO: Multiple selection: open as many editors (array/dict/...) as necessary, -# at the same time - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -from __future__ import print_function -import datetime -import re -import sys -import warnings - -# Third party imports -from qtpy.compat import getsavefilename, to_qvariant -from qtpy.QtCore import ( - QAbstractTableModel, QItemSelectionModel, QModelIndex, Qt, Signal, Slot) -from qtpy.QtGui import QColor, QKeySequence -from qtpy.QtWidgets import ( - QApplication, QHBoxLayout, QHeaderView, QInputDialog, QLineEdit, QMenu, - QMessageBox, QPushButton, QTableView, QVBoxLayout, QWidget) -from spyder_kernels.utils.lazymodules import ( - FakeObject, numpy as np, pandas as pd, PIL) -from spyder_kernels.utils.misc import fix_reference_name -from spyder_kernels.utils.nsview import ( - display_to_value, get_human_readable_type, get_numeric_numpy_types, - get_numpy_type_string, get_object_attrs, get_size, get_type_string, - sort_against, try_to_eval, unsorted_unique, value_to_display -) - -# Local imports -from spyder.api.config.mixins import SpyderConfigurationAccessor -from spyder.api.widgets.toolbars import SpyderToolbar -from spyder.config.base import _, running_under_pytest -from spyder.config.fonts import DEFAULT_SMALL_DELTA -from spyder.config.gui import get_font -from spyder.py3compat import (io, is_binary_string, PY3, to_text_string, - is_type_text_string, NUMERIC_TYPES) -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import ( - add_actions, create_action, MENU_SEPARATOR, mimedata2url) -from spyder.utils.stringmatching import get_search_scores, get_search_regex -from spyder.plugins.variableexplorer.widgets.collectionsdelegate import ( - CollectionsDelegate) -from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard -from spyder.widgets.helperwidgets import CustomSortFilterProxy -from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog -from spyder.utils.palette import SpyderPalette -from spyder.utils.stylesheet import PANES_TOOLBAR_STYLESHEET - - -# Maximum length of a serialized variable to be set in the kernel -MAX_SERIALIZED_LENGHT = 1e6 - -LARGE_NROWS = 100 -ROWS_TO_LOAD = 50 - - -def natsort(s): - """ - Natural sorting, e.g. test3 comes before test100. - Taken from https://stackoverflow.com/a/16090640/3110740 - """ - if not isinstance(s, (str, bytes)): - return s - x = [int(t) if t.isdigit() else t.lower() for t in re.split('([0-9]+)', s)] - return x - - -class ProxyObject(object): - """Dictionary proxy to an unknown object.""" - - def __init__(self, obj): - """Constructor.""" - self.__obj__ = obj - - def __len__(self): - """Get len according to detected attributes.""" - return len(get_object_attrs(self.__obj__)) - - def __getitem__(self, key): - """Get the attribute corresponding to the given key.""" - # Catch NotImplementedError to fix spyder-ide/spyder#6284 in pandas - # MultiIndex due to NA checking not being supported on a multiindex. - # Catch AttributeError to fix spyder-ide/spyder#5642 in certain special - # classes like xml when this method is called on certain attributes. - # Catch TypeError to prevent fatal Python crash to desktop after - # modifying certain pandas objects. Fix spyder-ide/spyder#6727. - # Catch ValueError to allow viewing and editing of pandas offsets. - # Fix spyder-ide/spyder#6728- - try: - attribute_toreturn = getattr(self.__obj__, key) - except (NotImplementedError, AttributeError, TypeError, ValueError): - attribute_toreturn = None - return attribute_toreturn - - def __setitem__(self, key, value): - """Set attribute corresponding to key with value.""" - # Catch AttributeError to gracefully handle inability to set an - # attribute due to it not being writeable or set-table. - # Fix spyder-ide/spyder#6728. - # Also, catch NotImplementedError for safety. - try: - setattr(self.__obj__, key, value) - except (TypeError, AttributeError, NotImplementedError): - pass - except Exception as e: - if "cannot set values for" not in str(e): - raise - - -class ReadOnlyCollectionsModel(QAbstractTableModel): - """CollectionsEditor Read-Only Table Model""" - - sig_setting_data = Signal() - - def __init__(self, parent, data, title="", names=False, - minmax=False, remote=False): - QAbstractTableModel.__init__(self, parent) - if data is None: - data = {} - self._parent = parent - self.scores = [] - self.names = names - self.minmax = minmax - self.remote = remote - self.header0 = None - self._data = None - self.total_rows = None - self.showndata = None - self.keys = None - self.title = to_text_string(title) # in case title is not a string - if self.title: - self.title = self.title + ' - ' - self.sizes = [] - self.types = [] - self.set_data(data) - - def get_data(self): - """Return model data""" - return self._data - - def set_data(self, data, coll_filter=None): - """Set model data""" - self._data = data - - if (coll_filter is not None and not self.remote and - isinstance(data, (tuple, list, dict, set))): - data = coll_filter(data) - self.showndata = data - - self.header0 = _("Index") - if self.names: - self.header0 = _("Name") - if isinstance(data, tuple): - self.keys = list(range(len(data))) - self.title += _("Tuple") - elif isinstance(data, list): - self.keys = list(range(len(data))) - self.title += _("List") - elif isinstance(data, set): - self.keys = list(range(len(data))) - self.title += _("Set") - self._data = list(data) - elif isinstance(data, dict): - try: - self.keys = sorted(list(data.keys()), key=natsort) - except TypeError: - # This is necessary to display dictionaries with mixed - # types as keys. - # Fixes spyder-ide/spyder#13481 - self.keys = list(data.keys()) - self.title += _("Dictionary") - if not self.names: - self.header0 = _("Key") - else: - self.keys = get_object_attrs(data) - self._data = data = self.showndata = ProxyObject(data) - if not self.names: - self.header0 = _("Attribute") - if not isinstance(self._data, ProxyObject): - if len(self.keys) > 1: - elements = _("elements") - else: - elements = _("element") - self.title += (' (' + str(len(self.keys)) + ' ' + elements + ')') - else: - data_type = get_type_string(data) - self.title += data_type - self.total_rows = len(self.keys) - if self.total_rows > LARGE_NROWS: - self.rows_loaded = ROWS_TO_LOAD - else: - self.rows_loaded = self.total_rows - self.sig_setting_data.emit() - self.set_size_and_type() - if len(self.keys): - # Needed to update search scores when - # adding values to the namespace - self.update_search_letters() - self.reset() - - def set_size_and_type(self, start=None, stop=None): - data = self._data - - if start is None and stop is None: - start = 0 - stop = self.rows_loaded - fetch_more = False - else: - fetch_more = True - - # Ignore pandas warnings that certain attributes are deprecated - # and will be removed, since they will only be accessed if they exist. - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", message=(r"^\w+\.\w+ is deprecated and " - "will be removed in a future version")) - if self.remote: - sizes = [data[self.keys[index]]['size'] - for index in range(start, stop)] - types = [data[self.keys[index]]['type'] - for index in range(start, stop)] - else: - sizes = [get_size(data[self.keys[index]]) - for index in range(start, stop)] - types = [get_human_readable_type(data[self.keys[index]]) - for index in range(start, stop)] - - if fetch_more: - self.sizes = self.sizes + sizes - self.types = self.types + types - else: - self.sizes = sizes - self.types = types - - def load_all(self): - """Load all the data.""" - self.fetchMore(number_to_fetch=self.total_rows) - - def sort(self, column, order=Qt.AscendingOrder): - """Overriding sort method""" - - def all_string(listlike): - return all([isinstance(x, str) for x in listlike]) - - reverse = (order == Qt.DescendingOrder) - sort_key = natsort if all_string(self.keys) else None - - if column == 0: - self.sizes = sort_against(self.sizes, self.keys, - reverse=reverse, - sort_key=natsort) - self.types = sort_against(self.types, self.keys, - reverse=reverse, - sort_key=natsort) - try: - self.keys.sort(reverse=reverse, key=sort_key) - except: - pass - elif column == 1: - self.keys[:self.rows_loaded] = sort_against(self.keys, - self.types, - reverse=reverse) - self.sizes = sort_against(self.sizes, self.types, reverse=reverse) - try: - self.types.sort(reverse=reverse) - except: - pass - elif column == 2: - self.keys[:self.rows_loaded] = sort_against(self.keys, - self.sizes, - reverse=reverse) - self.types = sort_against(self.types, self.sizes, reverse=reverse) - try: - self.sizes.sort(reverse=reverse) - except: - pass - elif column in [3, 4]: - values = [self._data[key] for key in self.keys] - self.keys = sort_against(self.keys, values, reverse=reverse) - self.sizes = sort_against(self.sizes, values, reverse=reverse) - self.types = sort_against(self.types, values, reverse=reverse) - self.beginResetModel() - self.endResetModel() - - def columnCount(self, qindex=QModelIndex()): - """Array column number""" - if self._parent.proxy_model: - return 5 - else: - return 4 - - def rowCount(self, index=QModelIndex()): - """Array row number""" - if self.total_rows <= self.rows_loaded: - return self.total_rows - else: - return self.rows_loaded - - def canFetchMore(self, index=QModelIndex()): - if self.total_rows > self.rows_loaded: - return True - else: - return False - - def fetchMore(self, index=QModelIndex(), number_to_fetch=None): - reminder = self.total_rows - self.rows_loaded - if number_to_fetch is not None: - items_to_fetch = min(reminder, number_to_fetch) - else: - items_to_fetch = min(reminder, ROWS_TO_LOAD) - self.set_size_and_type(self.rows_loaded, - self.rows_loaded + items_to_fetch) - self.beginInsertRows(QModelIndex(), self.rows_loaded, - self.rows_loaded + items_to_fetch - 1) - self.rows_loaded += items_to_fetch - self.endInsertRows() - - def get_index_from_key(self, key): - try: - return self.createIndex(self.keys.index(key), 0) - except (RuntimeError, ValueError): - return QModelIndex() - - def get_key(self, index): - """Return current key""" - return self.keys[index.row()] - - def get_value(self, index): - """Return current value""" - if index.column() == 0: - return self.keys[ index.row() ] - elif index.column() == 1: - return self.types[ index.row() ] - elif index.column() == 2: - return self.sizes[ index.row() ] - else: - return self._data[ self.keys[index.row()] ] - - def get_bgcolor(self, index): - """Background color depending on value""" - if index.column() == 0: - color = QColor(Qt.lightGray) - color.setAlphaF(.05) - elif index.column() < 3: - color = QColor(Qt.lightGray) - color.setAlphaF(.2) - else: - color = QColor(Qt.lightGray) - color.setAlphaF(.3) - return color - - def update_search_letters(self, text=""): - """Update search letters with text input in search box.""" - self.letters = text - names = [str(key) for key in self.keys] - results = get_search_scores(text, names, template='{0}') - if results: - self.normal_text, _, self.scores = zip(*results) - self.reset() - - def row_key(self, row_num): - """ - Get row name based on model index. - Needed for the custom proxy model. - """ - return self.keys[row_num] - - def row_type(self, row_num): - """ - Get row type based on model index. - Needed for the custom proxy model. - """ - return self.types[row_num] - - def data(self, index, role=Qt.DisplayRole): - """Cell content""" - if not index.isValid(): - return to_qvariant() - value = self.get_value(index) - if index.column() == 4 and role == Qt.DisplayRole: - # TODO: Check the effect of not hiding the column - # Treating search scores as a table column simplifies the - # sorting once a score for a specific string in the finder - # has been defined. This column however should always remain - # hidden. - return to_qvariant(self.scores[index.row()]) - if index.column() == 3 and self.remote: - value = value['view'] - if index.column() == 3: - display = value_to_display(value, minmax=self.minmax) - else: - if is_type_text_string(value): - display = to_text_string(value, encoding="utf-8") - elif not isinstance( - value, NUMERIC_TYPES + get_numeric_numpy_types() - ): - display = to_text_string(value) - else: - display = value - if role == Qt.UserRole: - if isinstance(value, NUMERIC_TYPES + get_numeric_numpy_types()): - return to_qvariant(value) - else: - return to_qvariant(display) - elif role == Qt.DisplayRole: - return to_qvariant(display) - elif role == Qt.EditRole: - return to_qvariant(value_to_display(value)) - elif role == Qt.TextAlignmentRole: - if index.column() == 3: - if len(display.splitlines()) < 3: - return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) - else: - return to_qvariant(int(Qt.AlignLeft|Qt.AlignTop)) - else: - return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) - elif role == Qt.BackgroundColorRole: - return to_qvariant( self.get_bgcolor(index) ) - elif role == Qt.FontRole: - return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) - return to_qvariant() - - def headerData(self, section, orientation, role=Qt.DisplayRole): - """Overriding method headerData""" - if role != Qt.DisplayRole: - return to_qvariant() - i_column = int(section) - if orientation == Qt.Horizontal: - headers = (self.header0, _("Type"), _("Size"), _("Value"), - _("Score")) - return to_qvariant( headers[i_column] ) - else: - return to_qvariant() - - def flags(self, index): - """Overriding method flags""" - # This method was implemented in CollectionsModel only, but to enable - # tuple exploration (even without editing), this method was moved here - if not index.isValid(): - return Qt.ItemIsEnabled - return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | - Qt.ItemIsEditable)) - - def reset(self): - self.beginResetModel() - self.endResetModel() - - -class CollectionsModel(ReadOnlyCollectionsModel): - """Collections Table Model""" - - def set_value(self, index, value): - """Set value""" - self._data[ self.keys[index.row()] ] = value - self.showndata[ self.keys[index.row()] ] = value - self.sizes[index.row()] = get_size(value) - self.types[index.row()] = get_human_readable_type(value) - self.sig_setting_data.emit() - - def type_to_color(self, python_type, numpy_type): - """Get the color that corresponds to a Python type.""" - # Color for unknown types - color = SpyderPalette.GROUP_12 - - if numpy_type != 'Unknown': - if numpy_type == 'Array': - color = SpyderPalette.GROUP_9 - elif numpy_type == 'Scalar': - color = SpyderPalette.GROUP_2 - elif python_type == 'bool': - color = SpyderPalette.GROUP_1 - elif python_type in ['int', 'float', 'complex']: - color = SpyderPalette.GROUP_2 - elif python_type in ['str', 'unicode']: - color = SpyderPalette.GROUP_3 - elif 'datetime' in python_type: - color = SpyderPalette.GROUP_4 - elif python_type == 'list': - color = SpyderPalette.GROUP_5 - elif python_type == 'set': - color = SpyderPalette.GROUP_6 - elif python_type == 'tuple': - color = SpyderPalette.GROUP_7 - elif python_type == 'dict': - color = SpyderPalette.GROUP_8 - elif python_type in ['MaskedArray', 'Matrix', 'NDArray']: - color = SpyderPalette.GROUP_9 - elif (python_type in ['DataFrame', 'Series'] or - 'Index' in python_type): - color = SpyderPalette.GROUP_10 - elif python_type == 'PIL.Image.Image': - color = SpyderPalette.GROUP_11 - else: - color = SpyderPalette.GROUP_12 - - return color - - def get_bgcolor(self, index): - """Background color depending on value.""" - value = self.get_value(index) - if index.column() < 3: - color = ReadOnlyCollectionsModel.get_bgcolor(self, index) - else: - if self.remote: - python_type = value['python_type'] - numpy_type = value['numpy_type'] - else: - python_type = get_type_string(value) - numpy_type = get_numpy_type_string(value) - color_name = self.type_to_color(python_type, numpy_type) - color = QColor(color_name) - color.setAlphaF(0.5) - return color - - def setData(self, index, value, role=Qt.EditRole): - """Cell content change""" - if not index.isValid(): - return False - if index.column() < 3: - return False - value = display_to_value(value, self.get_value(index), - ignore_errors=True) - self.set_value(index, value) - self.dataChanged.emit(index, index) - return True - - -class BaseHeaderView(QHeaderView): - """ - A header view for the BaseTableView that emits a signal when the width of - one of its sections is resized by the user. - """ - sig_user_resized_section = Signal(int, int, int) - - def __init__(self, parent=None): - super(BaseHeaderView, self).__init__(Qt.Horizontal, parent) - self._handle_section_is_pressed = False - self.sectionResized.connect(self.sectionResizeEvent) - # Needed to enable sorting by column - # See spyder-ide/spyder#9835 - self.setSectionsClickable(True) - - def mousePressEvent(self, e): - super(BaseHeaderView, self).mousePressEvent(e) - self._handle_section_is_pressed = (self.cursor().shape() == - Qt.SplitHCursor) - - def mouseReleaseEvent(self, e): - super(BaseHeaderView, self).mouseReleaseEvent(e) - self._handle_section_is_pressed = False - - def sectionResizeEvent(self, logicalIndex, oldSize, newSize): - if self._handle_section_is_pressed: - self.sig_user_resized_section.emit(logicalIndex, oldSize, newSize) - - -class BaseTableView(QTableView, SpyderConfigurationAccessor): - """Base collection editor table view""" - CONF_SECTION = 'variable_explorer' - - sig_files_dropped = Signal(list) - redirect_stdio = Signal(bool) - sig_free_memory_requested = Signal() - sig_editor_creation_started = Signal() - sig_editor_shown = Signal() - - def __init__(self, parent): - super().__init__(parent=parent) - - self.array_filename = None - self.menu = None - self.menu_actions = [] - self.empty_ws_menu = None - self.paste_action = None - self.copy_action = None - self.edit_action = None - self.plot_action = None - self.hist_action = None - self.imshow_action = None - self.save_array_action = None - self.insert_action = None - self.insert_action_above = None - self.insert_action_below = None - self.remove_action = None - self.minmax_action = None - self.rename_action = None - self.duplicate_action = None - self.last_regex = '' - self.view_action = None - self.delegate = None - self.proxy_model = None - self.source_model = None - self.setAcceptDrops(True) - self.automatic_column_width = True - self.setHorizontalHeader(BaseHeaderView(parent=self)) - self.horizontalHeader().sig_user_resized_section.connect( - self.user_resize_columns) - - def setup_table(self): - """Setup table""" - self.horizontalHeader().setStretchLastSection(True) - self.horizontalHeader().setSectionsMovable(True) - self.adjust_columns() - # Sorting columns - self.setSortingEnabled(True) - self.sortByColumn(0, Qt.AscendingOrder) - self.selectionModel().selectionChanged.connect(self.refresh_menu) - - def setup_menu(self): - """Setup context menu""" - resize_action = create_action(self, _("Resize rows to contents"), - icon=ima.icon('collapse_row'), - triggered=self.resizeRowsToContents) - resize_columns_action = create_action( - self, - _("Resize columns to contents"), - icon=ima.icon('collapse_column'), - triggered=self.resize_column_contents) - self.paste_action = create_action(self, _("Paste"), - icon=ima.icon('editpaste'), - triggered=self.paste) - self.copy_action = create_action(self, _("Copy"), - icon=ima.icon('editcopy'), - triggered=self.copy) - self.edit_action = create_action(self, _("Edit"), - icon=ima.icon('edit'), - triggered=self.edit_item) - self.plot_action = create_action(self, _("Plot"), - icon=ima.icon('plot'), - triggered=lambda: self.plot_item('plot')) - self.plot_action.setVisible(False) - self.hist_action = create_action(self, _("Histogram"), - icon=ima.icon('hist'), - triggered=lambda: self.plot_item('hist')) - self.hist_action.setVisible(False) - self.imshow_action = create_action(self, _("Show image"), - icon=ima.icon('imshow'), - triggered=self.imshow_item) - self.imshow_action.setVisible(False) - self.save_array_action = create_action(self, _("Save array"), - icon=ima.icon('filesave'), - triggered=self.save_array) - self.save_array_action.setVisible(False) - self.insert_action = create_action( - self, _("Insert"), - icon=ima.icon('insert'), - triggered=lambda: self.insert_item(below=False) - ) - self.insert_action_above = create_action( - self, _("Insert above"), - icon=ima.icon('insert_above'), - triggered=lambda: self.insert_item(below=False) - ) - self.insert_action_below = create_action( - self, _("Insert below"), - icon=ima.icon('insert_below'), - triggered=lambda: self.insert_item(below=True) - ) - self.remove_action = create_action(self, _("Remove"), - icon=ima.icon('editdelete'), - triggered=self.remove_item) - self.rename_action = create_action(self, _("Rename"), - icon=ima.icon('rename'), - triggered=self.rename_item) - self.duplicate_action = create_action(self, _("Duplicate"), - icon=ima.icon('edit_add'), - triggered=self.duplicate_item) - self.view_action = create_action( - self, - _("View with the Object Explorer"), - icon=ima.icon('outline_explorer'), - triggered=self.view_item) - - menu = QMenu(self) - self.menu_actions = [ - self.edit_action, - self.copy_action, - self.paste_action, - self.rename_action, - self.remove_action, - self.save_array_action, - MENU_SEPARATOR, - self.insert_action, - self.insert_action_above, - self.insert_action_below, - self.duplicate_action, - MENU_SEPARATOR, - self.view_action, - self.plot_action, - self.hist_action, - self.imshow_action, - MENU_SEPARATOR, - resize_action, - resize_columns_action - ] - add_actions(menu, self.menu_actions) - - self.empty_ws_menu = QMenu(self) - add_actions( - self.empty_ws_menu, - [self.insert_action, self.paste_action] - ) - - return menu - - - # ------ Remote/local API ------------------------------------------------- - def remove_values(self, keys): - """Remove values from data""" - raise NotImplementedError - - def copy_value(self, orig_key, new_key): - """Copy value""" - raise NotImplementedError - - def new_value(self, key, value): - """Create new value in data""" - raise NotImplementedError - - def is_list(self, key): - """Return True if variable is a list, a set or a tuple""" - raise NotImplementedError - - def get_len(self, key): - """Return sequence length""" - raise NotImplementedError - - def is_array(self, key): - """Return True if variable is a numpy array""" - raise NotImplementedError - - def is_image(self, key): - """Return True if variable is a PIL.Image image""" - raise NotImplementedError - - def is_dict(self, key): - """Return True if variable is a dictionary""" - raise NotImplementedError - - def get_array_shape(self, key): - """Return array's shape""" - raise NotImplementedError - - def get_array_ndim(self, key): - """Return array's ndim""" - raise NotImplementedError - - def oedit(self, key): - """Edit item""" - raise NotImplementedError - - def plot(self, key, funcname): - """Plot item""" - raise NotImplementedError - - def imshow(self, key): - """Show item's image""" - raise NotImplementedError - - def show_image(self, key): - """Show image (item is a PIL image)""" - raise NotImplementedError - #-------------------------------------------------------------------------- - - def refresh_menu(self): - """Refresh context menu""" - index = self.currentIndex() - data = self.source_model.get_data() - is_list_instance = isinstance(data, list) - is_dict_instance = isinstance(data, dict) - - def indexes_in_same_row(): - indexes = self.selectedIndexes() - if len(indexes) > 1: - rows = [idx.row() for idx in indexes] - return len(set(rows)) == 1 - else: - return True - - # Enable/disable actions - condition_edit = ( - (not isinstance(data, (tuple, set))) and - index.isValid() and - (len(self.selectedIndexes()) > 0) and - indexes_in_same_row() and - not self.readonly - ) - self.edit_action.setEnabled(condition_edit) - self.insert_action_above.setEnabled(condition_edit) - self.insert_action_below.setEnabled(condition_edit) - self.duplicate_action.setEnabled(condition_edit) - self.rename_action.setEnabled(condition_edit) - self.plot_action.setEnabled(condition_edit) - self.hist_action.setEnabled(condition_edit) - self.imshow_action.setEnabled(condition_edit) - self.save_array_action.setEnabled(condition_edit) - - condition_select = ( - index.isValid() and - (len(self.selectedIndexes()) > 0) - ) - self.view_action.setEnabled( - condition_select and indexes_in_same_row()) - self.copy_action.setEnabled(condition_select) - - condition_remove = ( - (not isinstance(data, (tuple, set))) and - index.isValid() and - (len(self.selectedIndexes()) > 0) and - not self.readonly - ) - self.remove_action.setEnabled(condition_remove) - - self.insert_action.setEnabled( - is_dict_instance and not self.readonly) - self.paste_action.setEnabled( - is_dict_instance and not self.readonly) - - # Hide/show actions - if index.isValid(): - if self.proxy_model: - key = self.proxy_model.get_key(index) - else: - key = self.source_model.get_key(index) - is_list = self.is_list(key) - is_array = self.is_array(key) and self.get_len(key) != 0 - condition_plot = (is_array and len(self.get_array_shape(key)) <= 2) - condition_hist = (is_array and self.get_array_ndim(key) == 1) - condition_imshow = condition_plot and self.get_array_ndim(key) == 2 - condition_imshow = condition_imshow or self.is_image(key) - else: - is_array = condition_plot = condition_imshow = is_list \ - = condition_hist = False - - self.plot_action.setVisible(condition_plot or is_list) - self.hist_action.setVisible(condition_hist or is_list) - self.insert_action.setVisible(is_dict_instance) - self.insert_action_above.setVisible(is_list_instance) - self.insert_action_below.setVisible(is_list_instance) - self.rename_action.setVisible(is_dict_instance) - self.paste_action.setVisible(is_dict_instance) - self.imshow_action.setVisible(condition_imshow) - self.save_array_action.setVisible(is_array) - - def resize_column_contents(self): - """Resize columns to contents.""" - self.automatic_column_width = True - self.adjust_columns() - - def user_resize_columns(self, logical_index, old_size, new_size): - """Handle the user resize action.""" - self.automatic_column_width = False - - def adjust_columns(self): - """Resize two first columns to contents""" - if self.automatic_column_width: - for col in range(3): - self.resizeColumnToContents(col) - - def set_data(self, data): - """Set table data""" - if data is not None: - self.source_model.set_data(data, self.dictfilter) - self.source_model.reset() - self.sortByColumn(0, Qt.AscendingOrder) - - def mousePressEvent(self, event): - """Reimplement Qt method""" - if event.button() != Qt.LeftButton: - QTableView.mousePressEvent(self, event) - return - index_clicked = self.indexAt(event.pos()) - if index_clicked.isValid(): - if index_clicked == self.currentIndex() \ - and index_clicked in self.selectedIndexes(): - self.clearSelection() - else: - QTableView.mousePressEvent(self, event) - else: - self.clearSelection() - event.accept() - - def mouseDoubleClickEvent(self, event): - """Reimplement Qt method""" - index_clicked = self.indexAt(event.pos()) - if index_clicked.isValid(): - row = index_clicked.row() - # TODO: Remove hard coded "Value" column number (3 here) - index_clicked = index_clicked.child(row, 3) - self.edit(index_clicked) - else: - event.accept() - - def keyPressEvent(self, event): - """Reimplement Qt methods""" - if event.key() == Qt.Key_Delete: - self.remove_item() - elif event.key() == Qt.Key_F2: - self.rename_item() - elif event == QKeySequence.Copy: - self.copy() - elif event == QKeySequence.Paste: - self.paste() - else: - QTableView.keyPressEvent(self, event) - - def contextMenuEvent(self, event): - """Reimplement Qt method""" - if self.source_model.showndata: - self.refresh_menu() - self.menu.popup(event.globalPos()) - event.accept() - else: - self.empty_ws_menu.popup(event.globalPos()) - event.accept() - - def dragEnterEvent(self, event): - """Allow user to drag files""" - if mimedata2url(event.mimeData()): - event.accept() - else: - event.ignore() - - def dragMoveEvent(self, event): - """Allow user to move files""" - if mimedata2url(event.mimeData()): - event.setDropAction(Qt.CopyAction) - event.accept() - else: - event.ignore() - - def dropEvent(self, event): - """Allow user to drop supported files""" - urls = mimedata2url(event.mimeData()) - if urls: - event.setDropAction(Qt.CopyAction) - event.accept() - self.sig_files_dropped.emit(urls) - else: - event.ignore() - - def _deselect_index(self, index): - """ - Deselect index after any operation that adds or removes rows to/from - the editor. - - Notes - ----- - * This avoids showing the wrong buttons in the editor's toolbar when - the operation is completed. - * Also, if we leave something selected, then the next operation won't - introduce the item in the expected row. That's why we need to force - users to select a row again after this. - """ - self.selectionModel().select(index, QItemSelectionModel.Select) - self.selectionModel().select(index, QItemSelectionModel.Deselect) - - @Slot() - def edit_item(self): - """Edit item""" - index = self.currentIndex() - if not index.isValid(): - return - # TODO: Remove hard coded "Value" column number (3 here) - self.edit(index.child(index.row(), 3)) - - @Slot() - def remove_item(self, force=False): - """Remove item""" - current_index = self.currentIndex() - indexes = self.selectedIndexes() - - if not indexes: - return - - for index in indexes: - if not index.isValid(): - return - - if not force: - one = _("Do you want to remove the selected item?") - more = _("Do you want to remove all selected items?") - answer = QMessageBox.question(self, _("Remove"), - one if len(indexes) == 1 else more, - QMessageBox.Yes | QMessageBox.No) - - if force or answer == QMessageBox.Yes: - if self.proxy_model: - idx_rows = unsorted_unique( - [self.proxy_model.mapToSource(idx).row() - for idx in indexes]) - else: - idx_rows = unsorted_unique([idx.row() for idx in indexes]) - keys = [self.source_model.keys[idx_row] for idx_row in idx_rows] - self.remove_values(keys) - - # This avoids a segfault in our tests that doesn't happen when - # removing items manually. - if not running_under_pytest(): - self._deselect_index(current_index) - - def copy_item(self, erase_original=False, new_name=None): - """Copy item""" - current_index = self.currentIndex() - indexes = self.selectedIndexes() - - if not indexes: - return - - if self.proxy_model: - idx_rows = unsorted_unique( - [self.proxy_model.mapToSource(idx).row() for idx in indexes]) - else: - idx_rows = unsorted_unique([idx.row() for idx in indexes]) - - if len(idx_rows) > 1 or not indexes[0].isValid(): - return - - orig_key = self.source_model.keys[idx_rows[0]] - if erase_original: - if not isinstance(orig_key, str): - QMessageBox.warning( - self, - _("Warning"), - _("You can only rename keys that are strings") - ) - return - - title = _('Rename') - field_text = _('New variable name:') - else: - title = _('Duplicate') - field_text = _('Variable name:') - - data = self.source_model.get_data() - if isinstance(data, (list, set)): - new_key, valid = len(data), True - elif new_name is not None: - new_key, valid = new_name, True - else: - new_key, valid = QInputDialog.getText(self, title, field_text, - QLineEdit.Normal, orig_key) - - if valid and to_text_string(new_key): - new_key = try_to_eval(to_text_string(new_key)) - if new_key == orig_key: - return - self.copy_value(orig_key, new_key) - if erase_original: - self.remove_values([orig_key]) - - self._deselect_index(current_index) - - @Slot() - def duplicate_item(self): - """Duplicate item""" - self.copy_item() - - @Slot() - def rename_item(self, new_name=None): - """Rename item""" - self.copy_item(erase_original=True, new_name=new_name) - - @Slot() - def insert_item(self, below=True): - """Insert item""" - index = self.currentIndex() - if not index.isValid(): - row = self.source_model.rowCount() - else: - if self.proxy_model: - if below: - row = self.proxy_model.mapToSource(index).row() + 1 - else: - row = self.proxy_model.mapToSource(index).row() - else: - if below: - row = index.row() + 1 - else: - row = index.row() - data = self.source_model.get_data() - - if isinstance(data, list): - key = row - data.insert(row, '') - elif isinstance(data, dict): - key, valid = QInputDialog.getText(self, _('Insert'), _('Key:'), - QLineEdit.Normal) - if valid and to_text_string(key): - key = try_to_eval(to_text_string(key)) - else: - return - else: - return - - value, valid = QInputDialog.getText(self, _('Insert'), _('Value:'), - QLineEdit.Normal) - - if valid and to_text_string(value): - self.new_value(key, try_to_eval(to_text_string(value))) - - @Slot() - def view_item(self): - """View item with the Object Explorer""" - index = self.currentIndex() - if not index.isValid(): - return - # TODO: Remove hard coded "Value" column number (3 here) - index = index.child(index.row(), 3) - self.delegate.createEditor(self, None, index, object_explorer=True) - - def __prepare_plot(self): - try: - import guiqwt.pyplot #analysis:ignore - return True - except: - try: - if 'matplotlib' not in sys.modules: - import matplotlib - return True - except Exception: - QMessageBox.warning(self, _("Import error"), - _("Please install matplotlib" - " or guiqwt.")) - - def plot_item(self, funcname): - """Plot item""" - index = self.currentIndex() - if self.__prepare_plot(): - if self.proxy_model: - key = self.source_model.get_key( - self.proxy_model.mapToSource(index)) - else: - key = self.source_model.get_key(index) - try: - self.plot(key, funcname) - except (ValueError, TypeError) as error: - QMessageBox.critical(self, _( "Plot"), - _("Unable to plot data." - "

    Error message:
    %s" - ) % str(error)) - - @Slot() - def imshow_item(self): - """Imshow item""" - index = self.currentIndex() - if self.__prepare_plot(): - if self.proxy_model: - key = self.source_model.get_key( - self.proxy_model.mapToSource(index)) - else: - key = self.source_model.get_key(index) - try: - if self.is_image(key): - self.show_image(key) - else: - self.imshow(key) - except (ValueError, TypeError) as error: - QMessageBox.critical(self, _( "Plot"), - _("Unable to show image." - "

    Error message:
    %s" - ) % str(error)) - - @Slot() - def save_array(self): - """Save array""" - title = _( "Save array") - if self.array_filename is None: - self.array_filename = getcwd_or_home() - self.redirect_stdio.emit(False) - filename, _selfilter = getsavefilename(self, title, - self.array_filename, - _("NumPy arrays")+" (*.npy)") - self.redirect_stdio.emit(True) - if filename: - self.array_filename = filename - data = self.delegate.get_value( self.currentIndex() ) - try: - import numpy as np - np.save(self.array_filename, data) - except Exception as error: - QMessageBox.critical(self, title, - _("Unable to save array" - "

    Error message:
    %s" - ) % str(error)) - - @Slot() - def copy(self): - """Copy text to clipboard""" - clipboard = QApplication.clipboard() - clipl = [] - for idx in self.selectedIndexes(): - if not idx.isValid(): - continue - obj = self.delegate.get_value(idx) - # Check if we are trying to copy a numpy array, and if so make sure - # to copy the whole thing in a tab separated format - if (isinstance(obj, (np.ndarray, np.ma.MaskedArray)) and - np.ndarray is not FakeObject): - if PY3: - output = io.BytesIO() - else: - output = io.StringIO() - try: - np.savetxt(output, obj, delimiter='\t') - except Exception: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy " - "this array")) - return - obj = output.getvalue().decode('utf-8') - output.close() - elif (isinstance(obj, (pd.DataFrame, pd.Series)) and - pd.DataFrame is not FakeObject): - output = io.StringIO() - try: - obj.to_csv(output, sep='\t', index=True, header=True) - except Exception: - QMessageBox.warning(self, _("Warning"), - _("It was not possible to copy " - "this dataframe")) - return - if PY3: - obj = output.getvalue() - else: - obj = output.getvalue().decode('utf-8') - output.close() - elif is_binary_string(obj): - obj = to_text_string(obj, 'utf8') - else: - obj = to_text_string(obj) - clipl.append(obj) - clipboard.setText('\n'.join(clipl)) - - def import_from_string(self, text, title=None): - """Import data from string""" - data = self.source_model.get_data() - # Check if data is a dict - if not hasattr(data, "keys"): - return - editor = ImportWizard( - self, text, title=title, contents_title=_("Clipboard contents"), - varname=fix_reference_name("data", blacklist=list(data.keys()))) - if editor.exec_(): - var_name, clip_data = editor.get_data() - self.new_value(var_name, clip_data) - - @Slot() - def paste(self): - """Import text/data/code from clipboard""" - clipboard = QApplication.clipboard() - cliptext = '' - if clipboard.mimeData().hasText(): - cliptext = to_text_string(clipboard.text()) - if cliptext.strip(): - self.import_from_string(cliptext, title=_("Import from clipboard")) - else: - QMessageBox.warning(self, _( "Empty clipboard"), - _("Nothing to be imported from clipboard.")) - - -class CollectionsEditorTableView(BaseTableView): - """CollectionsEditor table view""" - def __init__(self, parent, data, readonly=False, title="", - names=False): - BaseTableView.__init__(self, parent) - self.dictfilter = None - self.readonly = readonly or isinstance(data, (tuple, set)) - CollectionsModelClass = (ReadOnlyCollectionsModel if self.readonly - else CollectionsModel) - self.source_model = CollectionsModelClass( - self, - data, - title, - names=names, - minmax=self.get_conf('minmax') - ) - self.model = self.source_model - self.setModel(self.source_model) - self.delegate = CollectionsDelegate(self) - self.setItemDelegate(self.delegate) - - self.setup_table() - self.menu = self.setup_menu() - if isinstance(data, set): - self.horizontalHeader().hideSection(0) - - #------ Remote/local API -------------------------------------------------- - def remove_values(self, keys): - """Remove values from data""" - data = self.source_model.get_data() - for key in sorted(keys, reverse=True): - data.pop(key) - self.set_data(data) - - def copy_value(self, orig_key, new_key): - """Copy value""" - data = self.source_model.get_data() - if isinstance(data, list): - data.append(data[orig_key]) - if isinstance(data, set): - data.add(data[orig_key]) - else: - data[new_key] = data[orig_key] - self.set_data(data) - - def new_value(self, key, value): - """Create new value in data""" - index = self.currentIndex() - data = self.source_model.get_data() - data[key] = value - self.set_data(data) - self._deselect_index(index) - - def is_list(self, key): - """Return True if variable is a list or a tuple""" - data = self.source_model.get_data() - return isinstance(data[key], (tuple, list)) - - def is_set(self, key): - """Return True if variable is a set""" - data = self.source_model.get_data() - return isinstance(data[key], set) - - def get_len(self, key): - """Return sequence length""" - data = self.source_model.get_data() - return len(data[key]) - - def is_array(self, key): - """Return True if variable is a numpy array""" - data = self.source_model.get_data() - return isinstance(data[key], (np.ndarray, np.ma.MaskedArray)) - - def is_image(self, key): - """Return True if variable is a PIL.Image image""" - data = self.source_model.get_data() - return isinstance(data[key], PIL.Image.Image) - - def is_dict(self, key): - """Return True if variable is a dictionary""" - data = self.source_model.get_data() - return isinstance(data[key], dict) - - def get_array_shape(self, key): - """Return array's shape""" - data = self.source_model.get_data() - return data[key].shape - - def get_array_ndim(self, key): - """Return array's ndim""" - data = self.source_model.get_data() - return data[key].ndim - - def oedit(self, key): - """Edit item""" - data = self.source_model.get_data() - from spyder.plugins.variableexplorer.widgets.objecteditor import ( - oedit) - oedit(data[key]) - - def plot(self, key, funcname): - """Plot item""" - data = self.source_model.get_data() - import spyder.pyplot as plt - plt.figure() - getattr(plt, funcname)(data[key]) - plt.show() - - def imshow(self, key): - """Show item's image""" - data = self.source_model.get_data() - import spyder.pyplot as plt - plt.figure() - plt.imshow(data[key]) - plt.show() - - def show_image(self, key): - """Show image (item is a PIL image)""" - data = self.source_model.get_data() - data[key].show() - #-------------------------------------------------------------------------- - - def set_filter(self, dictfilter=None): - """Set table dict filter""" - self.dictfilter = dictfilter - - -class CollectionsEditorWidget(QWidget): - """Dictionary Editor Widget""" - def __init__(self, parent, data, readonly=False, title="", remote=False): - QWidget.__init__(self, parent) - if remote: - self.editor = RemoteCollectionsEditorTableView(self, data, readonly) - else: - self.editor = CollectionsEditorTableView(self, data, readonly, - title) - - toolbar = SpyderToolbar(parent=None, title='Editor toolbar') - toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) - - for item in self.editor.menu_actions: - if item is not None: - toolbar.addAction(item) - - # Update the toolbar actions state - self.editor.refresh_menu() - layout = QVBoxLayout() - layout.addWidget(toolbar) - layout.addWidget(self.editor) - self.setLayout(layout) - - def set_data(self, data): - """Set DictEditor data""" - self.editor.set_data(data) - - def get_title(self): - """Get model title""" - return self.editor.source_model.title - - -class CollectionsEditor(BaseDialog): - """Collections Editor Dialog""" - def __init__(self, parent=None): - super().__init__(parent) - - # Destroying the C++ object right after closing the dialog box, - # otherwise it may be garbage-collected in another QThread - # (e.g. the editor's analysis thread in Spyder), thus leading to - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - - self.data_copy = None - self.widget = None - self.btn_save_and_close = None - self.btn_close = None - - def setup(self, data, title='', readonly=False, remote=False, - icon=None, parent=None): - """Setup editor.""" - if isinstance(data, (dict, set)): - # dictionary, set - self.data_copy = data.copy() - datalen = len(data) - elif isinstance(data, (tuple, list)): - # list, tuple - self.data_copy = data[:] - datalen = len(data) - else: - # unknown object - import copy - try: - self.data_copy = copy.deepcopy(data) - except NotImplementedError: - self.data_copy = copy.copy(data) - except (TypeError, AttributeError): - readonly = True - self.data_copy = data - datalen = len(get_object_attrs(data)) - - # If the copy has a different type, then do not allow editing, because - # this would change the type after saving; cf. spyder-ide/spyder#6936. - if type(self.data_copy) != type(data): - readonly = True - - self.widget = CollectionsEditorWidget(self, self.data_copy, - title=title, readonly=readonly, - remote=remote) - self.widget.editor.source_model.sig_setting_data.connect( - self.save_and_close_enable) - layout = QVBoxLayout() - layout.addWidget(self.widget) - self.setLayout(layout) - - # Buttons configuration - btn_layout = QHBoxLayout() - btn_layout.setContentsMargins(4, 4, 4, 4) - btn_layout.addStretch() - - if not readonly: - self.btn_save_and_close = QPushButton(_('Save and Close')) - self.btn_save_and_close.setDisabled(True) - self.btn_save_and_close.clicked.connect(self.accept) - btn_layout.addWidget(self.btn_save_and_close) - - self.btn_close = QPushButton(_('Close')) - self.btn_close.setAutoDefault(True) - self.btn_close.setDefault(True) - self.btn_close.clicked.connect(self.reject) - btn_layout.addWidget(self.btn_close) - - layout.addLayout(btn_layout) - - self.setWindowTitle(self.widget.get_title()) - if icon is None: - self.setWindowIcon(ima.icon('dictedit')) - - if sys.platform == 'darwin': - # See spyder-ide/spyder#9051 - self.setWindowFlags(Qt.Tool) - else: - # Make the dialog act as a window - self.setWindowFlags(Qt.Window) - - @Slot() - def save_and_close_enable(self): - """Handle the data change event to enable the save and close button.""" - if self.btn_save_and_close: - self.btn_save_and_close.setEnabled(True) - self.btn_save_and_close.setAutoDefault(True) - self.btn_save_and_close.setDefault(True) - - def get_value(self): - """Return modified copy of dictionary or list""" - # It is import to avoid accessing Qt C++ object as it has probably - # already been destroyed, due to the Qt.WA_DeleteOnClose attribute - return self.data_copy - - -#============================================================================== -# Remote versions of CollectionsDelegate and CollectionsEditorTableView -#============================================================================== -class RemoteCollectionsDelegate(CollectionsDelegate): - """CollectionsEditor Item Delegate""" - def __init__(self, parent=None): - CollectionsDelegate.__init__(self, parent) - - def get_value(self, index): - if index.isValid(): - source_index = index.model().mapToSource(index) - name = source_index.model().keys[source_index.row()] - return self.parent().get_value(name) - - def set_value(self, index, value): - if index.isValid(): - source_index = index.model().mapToSource(index) - name = source_index.model().keys[source_index.row()] - self.parent().new_value(name, value) - - -class RemoteCollectionsEditorTableView(BaseTableView): - """DictEditor table view""" - def __init__(self, parent, data, shellwidget=None, remote_editing=False, - create_menu=False): - BaseTableView.__init__(self, parent) - - self.shellwidget = shellwidget - self.var_properties = {} - self.dictfilter = None - self.delegate = None - self.readonly = False - self.finder = None - - self.source_model = CollectionsModel( - self, data, names=True, - minmax=self.get_conf('minmax'), - remote=True) - - self.horizontalHeader().sectionClicked.connect( - self.source_model.load_all) - - self.proxy_model = CollectionsCustomSortFilterProxy(self) - self.model = self.proxy_model - - self.proxy_model.setSourceModel(self.source_model) - self.proxy_model.setDynamicSortFilter(True) - self.proxy_model.setFilterKeyColumn(0) # Col 0 for Name - self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) - self.proxy_model.setSortRole(Qt.UserRole) - self.setModel(self.proxy_model) - - self.hideColumn(4) # Column 4 for Score - - self.delegate = RemoteCollectionsDelegate(self) - self.delegate.sig_free_memory_requested.connect( - self.sig_free_memory_requested) - self.delegate.sig_editor_creation_started.connect( - self.sig_editor_creation_started) - self.delegate.sig_editor_shown.connect(self.sig_editor_shown) - self.setItemDelegate(self.delegate) - - self.setup_table() - - if create_menu: - self.menu = self.setup_menu() - - # ------ Remote/local API ------------------------------------------------- - def get_value(self, name): - """Get the value of a variable""" - value = self.shellwidget.get_value(name) - return value - - def new_value(self, name, value): - """Create new value in data""" - try: - self.shellwidget.set_value(name, value) - except TypeError as e: - QMessageBox.critical(self, _("Error"), - "TypeError: %s" % to_text_string(e)) - self.shellwidget.refresh_namespacebrowser() - - def remove_values(self, names): - """Remove values from data""" - for name in names: - self.shellwidget.remove_value(name) - self.shellwidget.refresh_namespacebrowser() - - def copy_value(self, orig_name, new_name): - """Copy value""" - self.shellwidget.copy_value(orig_name, new_name) - self.shellwidget.refresh_namespacebrowser() - - def is_list(self, name): - """Return True if variable is a list, a tuple or a set""" - return self.var_properties[name]['is_list'] - - def is_dict(self, name): - """Return True if variable is a dictionary""" - return self.var_properties[name]['is_dict'] - - def get_len(self, name): - """Return sequence length""" - return self.var_properties[name]['len'] - - def is_array(self, name): - """Return True if variable is a NumPy array""" - return self.var_properties[name]['is_array'] - - def is_image(self, name): - """Return True if variable is a PIL.Image image""" - return self.var_properties[name]['is_image'] - - def is_data_frame(self, name): - """Return True if variable is a DataFrame""" - return self.var_properties[name]['is_data_frame'] - - def is_series(self, name): - """Return True if variable is a Series""" - return self.var_properties[name]['is_series'] - - def get_array_shape(self, name): - """Return array's shape""" - return self.var_properties[name]['array_shape'] - - def get_array_ndim(self, name): - """Return array's ndim""" - return self.var_properties[name]['array_ndim'] - - def plot(self, name, funcname): - """Plot item""" - sw = self.shellwidget - sw.execute("%%varexp --%s %s" % (funcname, name)) - - def imshow(self, name): - """Show item's image""" - sw = self.shellwidget - sw.execute("%%varexp --imshow %s" % name) - - def show_image(self, name): - """Show image (item is a PIL image)""" - command = "%s.show()" % name - sw = self.shellwidget - sw.execute(command) - - # ------ Other ------------------------------------------------------------ - def setup_menu(self): - """Setup context menu.""" - menu = BaseTableView.setup_menu(self) - return menu - - def refresh_menu(self): - if self.var_properties: - super().refresh_menu() - - def set_regex(self, regex=None, reset=False): - """Update the regex text for the variable finder.""" - if reset or self.finder is None or not self.finder.text(): - text = '' - else: - text = self.finder.text().replace(' ', '').lower() - - self.proxy_model.set_filter(text) - self.source_model.update_search_letters(text) - - if text: - # TODO: Use constants for column numbers - self.sortByColumn(4, Qt.DescendingOrder) # Col 4 for index - - self.last_regex = regex - - def next_row(self): - """Move to next row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row + 1 == rows: - row = -1 - self.selectRow(row + 1) - - def previous_row(self): - """Move to previous row from currently selected row.""" - row = self.currentIndex().row() - rows = self.proxy_model.rowCount() - if row == 0: - row = rows - self.selectRow(row - 1) - - -class CollectionsCustomSortFilterProxy(CustomSortFilterProxy): - """ - Custom column filter based on regex and model data. - - Reimplements 'filterAcceptsRow' to follow NamespaceBrowser model. - Reimplements 'set_filter' to allow sorting while filtering - """ - - def get_key(self, index): - """Return current key from source model.""" - source_index = self.mapToSource(index) - return self.sourceModel().get_key(source_index) - - def get_index_from_key(self, key): - """Return index using key from source model.""" - source_index = self.sourceModel().get_index_from_key(key) - return self.mapFromSource(source_index) - - def get_value(self, index): - """Return current value from source model.""" - source_index = self.mapToSource(index) - return self.sourceModel().get_value(source_index) - - def set_value(self, index, value): - """Set value in source model.""" - try: - source_index = self.mapToSource(index) - self.sourceModel().set_value(source_index, value) - except AttributeError: - # Read-only models don't have set_value method - pass - - def set_filter(self, text): - """Set regular expression for filter.""" - self.pattern = get_search_regex(text) - self.invalidateFilter() - - def filterAcceptsRow(self, row_num, parent): - """ - Qt override. - - Reimplemented from base class to allow the use of custom filtering - using to columns (name and type). - """ - model = self.sourceModel() - name = to_text_string(model.row_key(row_num)) - variable_type = to_text_string(model.row_type(row_num)) - r_name = re.search(self.pattern, name) - r_type = re.search(self.pattern, variable_type) - - if r_name is None and r_type is None: - return False - else: - return True - - def lessThan(self, left, right): - """ - Implements ordering in a natural way, as a human would sort. - This functions enables sorting of the main variable editor table, - which does not rely on 'self.sort()'. - """ - leftData = self.sourceModel().data(left) - rightData = self.sourceModel().data(right) - try: - if isinstance(leftData, str) and isinstance(rightData, str): - return natsort(leftData) < natsort(rightData) - else: - return leftData < rightData - except TypeError: - # This is needed so all the elements that cannot be compared such - # as dataframes and numpy arrays are grouped together in the - # variable explorer. For more info see spyder-ide/spyder#14527 - return True - - -# ============================================================================= -# Tests -# ============================================================================= -def get_test_data(): - """Create test data.""" - image = PIL.Image.fromarray(np.random.randint(256, size=(100, 100)), - mode='P') - testdict = {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]} - testdate = datetime.date(1945, 5, 8) - test_timedelta = datetime.timedelta(days=-1, minutes=42, seconds=13) - - try: - import pandas as pd - except (ModuleNotFoundError, ImportError): - test_df = None - test_timestamp = test_pd_td = test_dtindex = test_series = None - else: - test_timestamp = pd.Timestamp("1945-05-08T23:01:00.12345") - test_pd_td = pd.Timedelta(days=2193, hours=12) - test_dtindex = pd.date_range(start="1939-09-01T", - end="1939-10-06", - freq="12H") - test_series = pd.Series({"series_name": [0, 1, 2, 3, 4, 5]}) - test_df = pd.DataFrame({"string_col": ["a", "b", "c", "d"], - "int_col": [0, 1, 2, 3], - "float_col": [1.1, 2.2, 3.3, 4.4], - "bool_col": [True, False, False, True]}) - - class Foobar(object): - - def __init__(self): - self.text = "toto" - self.testdict = testdict - self.testdate = testdate - - foobar = Foobar() - return {'object': foobar, - 'module': np, - 'str': 'kjkj kj k j j kj k jkj', - 'unicode': to_text_string('éù', 'utf-8'), - 'list': [1, 3, [sorted, 5, 6], 'kjkj', None], - 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, - 'tuple': ([1, testdate, testdict, test_timedelta], 'kjkj', None), - 'dict': testdict, - 'float': 1.2233, - 'int': 223, - 'bool': True, - 'array': np.random.rand(10, 10).astype(np.int64), - 'masked_array': np.ma.array([[1, 0], [1, 0]], - mask=[[True, False], [False, False]]), - '1D-array': np.linspace(-10, 10).astype(np.float16), - '3D-array': np.random.randint(2, size=(5, 5, 5)).astype(np.bool_), - 'empty_array': np.array([]), - 'image': image, - 'date': testdate, - 'datetime': datetime.datetime(1945, 5, 8, 23, 1, 0, int(1.5e5)), - 'timedelta': test_timedelta, - 'complex': 2+1j, - 'complex64': np.complex64(2+1j), - 'complex128': np.complex128(9j), - 'int8_scalar': np.int8(8), - 'int16_scalar': np.int16(16), - 'int32_scalar': np.int32(32), - 'int64_scalar': np.int64(64), - 'float16_scalar': np.float16(16), - 'float32_scalar': np.float32(32), - 'float64_scalar': np.float64(64), - 'bool_scalar': np.bool(8), - 'bool__scalar': np.bool_(8), - 'timestamp': test_timestamp, - 'timedelta_pd': test_pd_td, - 'datetimeindex': test_dtindex, - 'series': test_series, - 'ddataframe': test_df, - 'None': None, - 'unsupported1': np.arccos, - 'unsupported2': np.cast, - # Test for spyder-ide/spyder#3518. - 'big_struct_array': np.zeros(1000, dtype=[('ID', 'f8'), - ('param1', 'f8', 5000)]), - } - - -def editor_test(): - """Test Collections editor.""" - dialog = CollectionsEditor() - dialog.setup(get_test_data()) - dialog.show() - - -def remote_editor_test(): - """Test remote collections editor.""" - from spyder.config.manager import CONF - from spyder_kernels.utils.nsview import (make_remote_view, - REMOTE_SETTINGS) - - settings = {} - for name in REMOTE_SETTINGS: - settings[name] = CONF.get('variable_explorer', name) - - remote = make_remote_view(get_test_data(), settings) - dialog = CollectionsEditor() - dialog.setup(remote, remote=True) - dialog.show() - - -if __name__ == "__main__": - from spyder.utils.qthelpers import qapplication - - app = qapplication() # analysis:ignore - editor_test() - remote_editor_test() - app.exec_() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ---------------------------------------------------------------------------- + +""" +Collections (i.e. dictionary, list, set and tuple) editor widget and dialog. +""" + +#TODO: Multiple selection: open as many editors (array/dict/...) as necessary, +# at the same time + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +from __future__ import print_function +import datetime +import re +import sys +import warnings + +# Third party imports +from qtpy.compat import getsavefilename, to_qvariant +from qtpy.QtCore import ( + QAbstractTableModel, QItemSelectionModel, QModelIndex, Qt, Signal, Slot) +from qtpy.QtGui import QColor, QKeySequence +from qtpy.QtWidgets import ( + QApplication, QHBoxLayout, QHeaderView, QInputDialog, QLineEdit, QMenu, + QMessageBox, QPushButton, QTableView, QVBoxLayout, QWidget) +from spyder_kernels.utils.lazymodules import ( + FakeObject, numpy as np, pandas as pd, PIL) +from spyder_kernels.utils.misc import fix_reference_name +from spyder_kernels.utils.nsview import ( + display_to_value, get_human_readable_type, get_numeric_numpy_types, + get_numpy_type_string, get_object_attrs, get_size, get_type_string, + sort_against, try_to_eval, unsorted_unique, value_to_display +) + +# Local imports +from spyder.api.config.mixins import SpyderConfigurationAccessor +from spyder.api.widgets.toolbars import SpyderToolbar +from spyder.config.base import _, running_under_pytest +from spyder.config.fonts import DEFAULT_SMALL_DELTA +from spyder.config.gui import get_font +from spyder.py3compat import (io, is_binary_string, PY3, to_text_string, + is_type_text_string, NUMERIC_TYPES) +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import ( + add_actions, create_action, MENU_SEPARATOR, mimedata2url) +from spyder.utils.stringmatching import get_search_scores, get_search_regex +from spyder.plugins.variableexplorer.widgets.collectionsdelegate import ( + CollectionsDelegate) +from spyder.plugins.variableexplorer.widgets.importwizard import ImportWizard +from spyder.widgets.helperwidgets import CustomSortFilterProxy +from spyder.plugins.variableexplorer.widgets.basedialog import BaseDialog +from spyder.utils.palette import SpyderPalette +from spyder.utils.stylesheet import PANES_TOOLBAR_STYLESHEET + + +# Maximum length of a serialized variable to be set in the kernel +MAX_SERIALIZED_LENGHT = 1e6 + +LARGE_NROWS = 100 +ROWS_TO_LOAD = 50 + + +def natsort(s): + """ + Natural sorting, e.g. test3 comes before test100. + Taken from https://stackoverflow.com/a/16090640/3110740 + """ + if not isinstance(s, (str, bytes)): + return s + x = [int(t) if t.isdigit() else t.lower() for t in re.split('([0-9]+)', s)] + return x + + +class ProxyObject(object): + """Dictionary proxy to an unknown object.""" + + def __init__(self, obj): + """Constructor.""" + self.__obj__ = obj + + def __len__(self): + """Get len according to detected attributes.""" + return len(get_object_attrs(self.__obj__)) + + def __getitem__(self, key): + """Get the attribute corresponding to the given key.""" + # Catch NotImplementedError to fix spyder-ide/spyder#6284 in pandas + # MultiIndex due to NA checking not being supported on a multiindex. + # Catch AttributeError to fix spyder-ide/spyder#5642 in certain special + # classes like xml when this method is called on certain attributes. + # Catch TypeError to prevent fatal Python crash to desktop after + # modifying certain pandas objects. Fix spyder-ide/spyder#6727. + # Catch ValueError to allow viewing and editing of pandas offsets. + # Fix spyder-ide/spyder#6728- + try: + attribute_toreturn = getattr(self.__obj__, key) + except (NotImplementedError, AttributeError, TypeError, ValueError): + attribute_toreturn = None + return attribute_toreturn + + def __setitem__(self, key, value): + """Set attribute corresponding to key with value.""" + # Catch AttributeError to gracefully handle inability to set an + # attribute due to it not being writeable or set-table. + # Fix spyder-ide/spyder#6728. + # Also, catch NotImplementedError for safety. + try: + setattr(self.__obj__, key, value) + except (TypeError, AttributeError, NotImplementedError): + pass + except Exception as e: + if "cannot set values for" not in str(e): + raise + + +class ReadOnlyCollectionsModel(QAbstractTableModel): + """CollectionsEditor Read-Only Table Model""" + + sig_setting_data = Signal() + + def __init__(self, parent, data, title="", names=False, + minmax=False, remote=False): + QAbstractTableModel.__init__(self, parent) + if data is None: + data = {} + self._parent = parent + self.scores = [] + self.names = names + self.minmax = minmax + self.remote = remote + self.header0 = None + self._data = None + self.total_rows = None + self.showndata = None + self.keys = None + self.title = to_text_string(title) # in case title is not a string + if self.title: + self.title = self.title + ' - ' + self.sizes = [] + self.types = [] + self.set_data(data) + + def get_data(self): + """Return model data""" + return self._data + + def set_data(self, data, coll_filter=None): + """Set model data""" + self._data = data + + if (coll_filter is not None and not self.remote and + isinstance(data, (tuple, list, dict, set))): + data = coll_filter(data) + self.showndata = data + + self.header0 = _("Index") + if self.names: + self.header0 = _("Name") + if isinstance(data, tuple): + self.keys = list(range(len(data))) + self.title += _("Tuple") + elif isinstance(data, list): + self.keys = list(range(len(data))) + self.title += _("List") + elif isinstance(data, set): + self.keys = list(range(len(data))) + self.title += _("Set") + self._data = list(data) + elif isinstance(data, dict): + try: + self.keys = sorted(list(data.keys()), key=natsort) + except TypeError: + # This is necessary to display dictionaries with mixed + # types as keys. + # Fixes spyder-ide/spyder#13481 + self.keys = list(data.keys()) + self.title += _("Dictionary") + if not self.names: + self.header0 = _("Key") + else: + self.keys = get_object_attrs(data) + self._data = data = self.showndata = ProxyObject(data) + if not self.names: + self.header0 = _("Attribute") + if not isinstance(self._data, ProxyObject): + if len(self.keys) > 1: + elements = _("elements") + else: + elements = _("element") + self.title += (' (' + str(len(self.keys)) + ' ' + elements + ')') + else: + data_type = get_type_string(data) + self.title += data_type + self.total_rows = len(self.keys) + if self.total_rows > LARGE_NROWS: + self.rows_loaded = ROWS_TO_LOAD + else: + self.rows_loaded = self.total_rows + self.sig_setting_data.emit() + self.set_size_and_type() + if len(self.keys): + # Needed to update search scores when + # adding values to the namespace + self.update_search_letters() + self.reset() + + def set_size_and_type(self, start=None, stop=None): + data = self._data + + if start is None and stop is None: + start = 0 + stop = self.rows_loaded + fetch_more = False + else: + fetch_more = True + + # Ignore pandas warnings that certain attributes are deprecated + # and will be removed, since they will only be accessed if they exist. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message=(r"^\w+\.\w+ is deprecated and " + "will be removed in a future version")) + if self.remote: + sizes = [data[self.keys[index]]['size'] + for index in range(start, stop)] + types = [data[self.keys[index]]['type'] + for index in range(start, stop)] + else: + sizes = [get_size(data[self.keys[index]]) + for index in range(start, stop)] + types = [get_human_readable_type(data[self.keys[index]]) + for index in range(start, stop)] + + if fetch_more: + self.sizes = self.sizes + sizes + self.types = self.types + types + else: + self.sizes = sizes + self.types = types + + def load_all(self): + """Load all the data.""" + self.fetchMore(number_to_fetch=self.total_rows) + + def sort(self, column, order=Qt.AscendingOrder): + """Overriding sort method""" + + def all_string(listlike): + return all([isinstance(x, str) for x in listlike]) + + reverse = (order == Qt.DescendingOrder) + sort_key = natsort if all_string(self.keys) else None + + if column == 0: + self.sizes = sort_against(self.sizes, self.keys, + reverse=reverse, + sort_key=natsort) + self.types = sort_against(self.types, self.keys, + reverse=reverse, + sort_key=natsort) + try: + self.keys.sort(reverse=reverse, key=sort_key) + except: + pass + elif column == 1: + self.keys[:self.rows_loaded] = sort_against(self.keys, + self.types, + reverse=reverse) + self.sizes = sort_against(self.sizes, self.types, reverse=reverse) + try: + self.types.sort(reverse=reverse) + except: + pass + elif column == 2: + self.keys[:self.rows_loaded] = sort_against(self.keys, + self.sizes, + reverse=reverse) + self.types = sort_against(self.types, self.sizes, reverse=reverse) + try: + self.sizes.sort(reverse=reverse) + except: + pass + elif column in [3, 4]: + values = [self._data[key] for key in self.keys] + self.keys = sort_against(self.keys, values, reverse=reverse) + self.sizes = sort_against(self.sizes, values, reverse=reverse) + self.types = sort_against(self.types, values, reverse=reverse) + self.beginResetModel() + self.endResetModel() + + def columnCount(self, qindex=QModelIndex()): + """Array column number""" + if self._parent.proxy_model: + return 5 + else: + return 4 + + def rowCount(self, index=QModelIndex()): + """Array row number""" + if self.total_rows <= self.rows_loaded: + return self.total_rows + else: + return self.rows_loaded + + def canFetchMore(self, index=QModelIndex()): + if self.total_rows > self.rows_loaded: + return True + else: + return False + + def fetchMore(self, index=QModelIndex(), number_to_fetch=None): + reminder = self.total_rows - self.rows_loaded + if number_to_fetch is not None: + items_to_fetch = min(reminder, number_to_fetch) + else: + items_to_fetch = min(reminder, ROWS_TO_LOAD) + self.set_size_and_type(self.rows_loaded, + self.rows_loaded + items_to_fetch) + self.beginInsertRows(QModelIndex(), self.rows_loaded, + self.rows_loaded + items_to_fetch - 1) + self.rows_loaded += items_to_fetch + self.endInsertRows() + + def get_index_from_key(self, key): + try: + return self.createIndex(self.keys.index(key), 0) + except (RuntimeError, ValueError): + return QModelIndex() + + def get_key(self, index): + """Return current key""" + return self.keys[index.row()] + + def get_value(self, index): + """Return current value""" + if index.column() == 0: + return self.keys[ index.row() ] + elif index.column() == 1: + return self.types[ index.row() ] + elif index.column() == 2: + return self.sizes[ index.row() ] + else: + return self._data[ self.keys[index.row()] ] + + def get_bgcolor(self, index): + """Background color depending on value""" + if index.column() == 0: + color = QColor(Qt.lightGray) + color.setAlphaF(.05) + elif index.column() < 3: + color = QColor(Qt.lightGray) + color.setAlphaF(.2) + else: + color = QColor(Qt.lightGray) + color.setAlphaF(.3) + return color + + def update_search_letters(self, text=""): + """Update search letters with text input in search box.""" + self.letters = text + names = [str(key) for key in self.keys] + results = get_search_scores(text, names, template='{0}') + if results: + self.normal_text, _, self.scores = zip(*results) + self.reset() + + def row_key(self, row_num): + """ + Get row name based on model index. + Needed for the custom proxy model. + """ + return self.keys[row_num] + + def row_type(self, row_num): + """ + Get row type based on model index. + Needed for the custom proxy model. + """ + return self.types[row_num] + + def data(self, index, role=Qt.DisplayRole): + """Cell content""" + if not index.isValid(): + return to_qvariant() + value = self.get_value(index) + if index.column() == 4 and role == Qt.DisplayRole: + # TODO: Check the effect of not hiding the column + # Treating search scores as a table column simplifies the + # sorting once a score for a specific string in the finder + # has been defined. This column however should always remain + # hidden. + return to_qvariant(self.scores[index.row()]) + if index.column() == 3 and self.remote: + value = value['view'] + if index.column() == 3: + display = value_to_display(value, minmax=self.minmax) + else: + if is_type_text_string(value): + display = to_text_string(value, encoding="utf-8") + elif not isinstance( + value, NUMERIC_TYPES + get_numeric_numpy_types() + ): + display = to_text_string(value) + else: + display = value + if role == Qt.UserRole: + if isinstance(value, NUMERIC_TYPES + get_numeric_numpy_types()): + return to_qvariant(value) + else: + return to_qvariant(display) + elif role == Qt.DisplayRole: + return to_qvariant(display) + elif role == Qt.EditRole: + return to_qvariant(value_to_display(value)) + elif role == Qt.TextAlignmentRole: + if index.column() == 3: + if len(display.splitlines()) < 3: + return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) + else: + return to_qvariant(int(Qt.AlignLeft|Qt.AlignTop)) + else: + return to_qvariant(int(Qt.AlignLeft|Qt.AlignVCenter)) + elif role == Qt.BackgroundColorRole: + return to_qvariant( self.get_bgcolor(index) ) + elif role == Qt.FontRole: + return to_qvariant(get_font(font_size_delta=DEFAULT_SMALL_DELTA)) + return to_qvariant() + + def headerData(self, section, orientation, role=Qt.DisplayRole): + """Overriding method headerData""" + if role != Qt.DisplayRole: + return to_qvariant() + i_column = int(section) + if orientation == Qt.Horizontal: + headers = (self.header0, _("Type"), _("Size"), _("Value"), + _("Score")) + return to_qvariant( headers[i_column] ) + else: + return to_qvariant() + + def flags(self, index): + """Overriding method flags""" + # This method was implemented in CollectionsModel only, but to enable + # tuple exploration (even without editing), this method was moved here + if not index.isValid(): + return Qt.ItemIsEnabled + return Qt.ItemFlags(int(QAbstractTableModel.flags(self, index) | + Qt.ItemIsEditable)) + + def reset(self): + self.beginResetModel() + self.endResetModel() + + +class CollectionsModel(ReadOnlyCollectionsModel): + """Collections Table Model""" + + def set_value(self, index, value): + """Set value""" + self._data[ self.keys[index.row()] ] = value + self.showndata[ self.keys[index.row()] ] = value + self.sizes[index.row()] = get_size(value) + self.types[index.row()] = get_human_readable_type(value) + self.sig_setting_data.emit() + + def type_to_color(self, python_type, numpy_type): + """Get the color that corresponds to a Python type.""" + # Color for unknown types + color = SpyderPalette.GROUP_12 + + if numpy_type != 'Unknown': + if numpy_type == 'Array': + color = SpyderPalette.GROUP_9 + elif numpy_type == 'Scalar': + color = SpyderPalette.GROUP_2 + elif python_type == 'bool': + color = SpyderPalette.GROUP_1 + elif python_type in ['int', 'float', 'complex']: + color = SpyderPalette.GROUP_2 + elif python_type in ['str', 'unicode']: + color = SpyderPalette.GROUP_3 + elif 'datetime' in python_type: + color = SpyderPalette.GROUP_4 + elif python_type == 'list': + color = SpyderPalette.GROUP_5 + elif python_type == 'set': + color = SpyderPalette.GROUP_6 + elif python_type == 'tuple': + color = SpyderPalette.GROUP_7 + elif python_type == 'dict': + color = SpyderPalette.GROUP_8 + elif python_type in ['MaskedArray', 'Matrix', 'NDArray']: + color = SpyderPalette.GROUP_9 + elif (python_type in ['DataFrame', 'Series'] or + 'Index' in python_type): + color = SpyderPalette.GROUP_10 + elif python_type == 'PIL.Image.Image': + color = SpyderPalette.GROUP_11 + else: + color = SpyderPalette.GROUP_12 + + return color + + def get_bgcolor(self, index): + """Background color depending on value.""" + value = self.get_value(index) + if index.column() < 3: + color = ReadOnlyCollectionsModel.get_bgcolor(self, index) + else: + if self.remote: + python_type = value['python_type'] + numpy_type = value['numpy_type'] + else: + python_type = get_type_string(value) + numpy_type = get_numpy_type_string(value) + color_name = self.type_to_color(python_type, numpy_type) + color = QColor(color_name) + color.setAlphaF(0.5) + return color + + def setData(self, index, value, role=Qt.EditRole): + """Cell content change""" + if not index.isValid(): + return False + if index.column() < 3: + return False + value = display_to_value(value, self.get_value(index), + ignore_errors=True) + self.set_value(index, value) + self.dataChanged.emit(index, index) + return True + + +class BaseHeaderView(QHeaderView): + """ + A header view for the BaseTableView that emits a signal when the width of + one of its sections is resized by the user. + """ + sig_user_resized_section = Signal(int, int, int) + + def __init__(self, parent=None): + super(BaseHeaderView, self).__init__(Qt.Horizontal, parent) + self._handle_section_is_pressed = False + self.sectionResized.connect(self.sectionResizeEvent) + # Needed to enable sorting by column + # See spyder-ide/spyder#9835 + self.setSectionsClickable(True) + + def mousePressEvent(self, e): + super(BaseHeaderView, self).mousePressEvent(e) + self._handle_section_is_pressed = (self.cursor().shape() == + Qt.SplitHCursor) + + def mouseReleaseEvent(self, e): + super(BaseHeaderView, self).mouseReleaseEvent(e) + self._handle_section_is_pressed = False + + def sectionResizeEvent(self, logicalIndex, oldSize, newSize): + if self._handle_section_is_pressed: + self.sig_user_resized_section.emit(logicalIndex, oldSize, newSize) + + +class BaseTableView(QTableView, SpyderConfigurationAccessor): + """Base collection editor table view""" + CONF_SECTION = 'variable_explorer' + + sig_files_dropped = Signal(list) + redirect_stdio = Signal(bool) + sig_free_memory_requested = Signal() + sig_editor_creation_started = Signal() + sig_editor_shown = Signal() + + def __init__(self, parent): + super().__init__(parent=parent) + + self.array_filename = None + self.menu = None + self.menu_actions = [] + self.empty_ws_menu = None + self.paste_action = None + self.copy_action = None + self.edit_action = None + self.plot_action = None + self.hist_action = None + self.imshow_action = None + self.save_array_action = None + self.insert_action = None + self.insert_action_above = None + self.insert_action_below = None + self.remove_action = None + self.minmax_action = None + self.rename_action = None + self.duplicate_action = None + self.last_regex = '' + self.view_action = None + self.delegate = None + self.proxy_model = None + self.source_model = None + self.setAcceptDrops(True) + self.automatic_column_width = True + self.setHorizontalHeader(BaseHeaderView(parent=self)) + self.horizontalHeader().sig_user_resized_section.connect( + self.user_resize_columns) + + def setup_table(self): + """Setup table""" + self.horizontalHeader().setStretchLastSection(True) + self.horizontalHeader().setSectionsMovable(True) + self.adjust_columns() + # Sorting columns + self.setSortingEnabled(True) + self.sortByColumn(0, Qt.AscendingOrder) + self.selectionModel().selectionChanged.connect(self.refresh_menu) + + def setup_menu(self): + """Setup context menu""" + resize_action = create_action(self, _("Resize rows to contents"), + icon=ima.icon('collapse_row'), + triggered=self.resizeRowsToContents) + resize_columns_action = create_action( + self, + _("Resize columns to contents"), + icon=ima.icon('collapse_column'), + triggered=self.resize_column_contents) + self.paste_action = create_action(self, _("Paste"), + icon=ima.icon('editpaste'), + triggered=self.paste) + self.copy_action = create_action(self, _("Copy"), + icon=ima.icon('editcopy'), + triggered=self.copy) + self.edit_action = create_action(self, _("Edit"), + icon=ima.icon('edit'), + triggered=self.edit_item) + self.plot_action = create_action(self, _("Plot"), + icon=ima.icon('plot'), + triggered=lambda: self.plot_item('plot')) + self.plot_action.setVisible(False) + self.hist_action = create_action(self, _("Histogram"), + icon=ima.icon('hist'), + triggered=lambda: self.plot_item('hist')) + self.hist_action.setVisible(False) + self.imshow_action = create_action(self, _("Show image"), + icon=ima.icon('imshow'), + triggered=self.imshow_item) + self.imshow_action.setVisible(False) + self.save_array_action = create_action(self, _("Save array"), + icon=ima.icon('filesave'), + triggered=self.save_array) + self.save_array_action.setVisible(False) + self.insert_action = create_action( + self, _("Insert"), + icon=ima.icon('insert'), + triggered=lambda: self.insert_item(below=False) + ) + self.insert_action_above = create_action( + self, _("Insert above"), + icon=ima.icon('insert_above'), + triggered=lambda: self.insert_item(below=False) + ) + self.insert_action_below = create_action( + self, _("Insert below"), + icon=ima.icon('insert_below'), + triggered=lambda: self.insert_item(below=True) + ) + self.remove_action = create_action(self, _("Remove"), + icon=ima.icon('editdelete'), + triggered=self.remove_item) + self.rename_action = create_action(self, _("Rename"), + icon=ima.icon('rename'), + triggered=self.rename_item) + self.duplicate_action = create_action(self, _("Duplicate"), + icon=ima.icon('edit_add'), + triggered=self.duplicate_item) + self.view_action = create_action( + self, + _("View with the Object Explorer"), + icon=ima.icon('outline_explorer'), + triggered=self.view_item) + + menu = QMenu(self) + self.menu_actions = [ + self.edit_action, + self.copy_action, + self.paste_action, + self.rename_action, + self.remove_action, + self.save_array_action, + MENU_SEPARATOR, + self.insert_action, + self.insert_action_above, + self.insert_action_below, + self.duplicate_action, + MENU_SEPARATOR, + self.view_action, + self.plot_action, + self.hist_action, + self.imshow_action, + MENU_SEPARATOR, + resize_action, + resize_columns_action + ] + add_actions(menu, self.menu_actions) + + self.empty_ws_menu = QMenu(self) + add_actions( + self.empty_ws_menu, + [self.insert_action, self.paste_action] + ) + + return menu + + + # ------ Remote/local API ------------------------------------------------- + def remove_values(self, keys): + """Remove values from data""" + raise NotImplementedError + + def copy_value(self, orig_key, new_key): + """Copy value""" + raise NotImplementedError + + def new_value(self, key, value): + """Create new value in data""" + raise NotImplementedError + + def is_list(self, key): + """Return True if variable is a list, a set or a tuple""" + raise NotImplementedError + + def get_len(self, key): + """Return sequence length""" + raise NotImplementedError + + def is_array(self, key): + """Return True if variable is a numpy array""" + raise NotImplementedError + + def is_image(self, key): + """Return True if variable is a PIL.Image image""" + raise NotImplementedError + + def is_dict(self, key): + """Return True if variable is a dictionary""" + raise NotImplementedError + + def get_array_shape(self, key): + """Return array's shape""" + raise NotImplementedError + + def get_array_ndim(self, key): + """Return array's ndim""" + raise NotImplementedError + + def oedit(self, key): + """Edit item""" + raise NotImplementedError + + def plot(self, key, funcname): + """Plot item""" + raise NotImplementedError + + def imshow(self, key): + """Show item's image""" + raise NotImplementedError + + def show_image(self, key): + """Show image (item is a PIL image)""" + raise NotImplementedError + #-------------------------------------------------------------------------- + + def refresh_menu(self): + """Refresh context menu""" + index = self.currentIndex() + data = self.source_model.get_data() + is_list_instance = isinstance(data, list) + is_dict_instance = isinstance(data, dict) + + def indexes_in_same_row(): + indexes = self.selectedIndexes() + if len(indexes) > 1: + rows = [idx.row() for idx in indexes] + return len(set(rows)) == 1 + else: + return True + + # Enable/disable actions + condition_edit = ( + (not isinstance(data, (tuple, set))) and + index.isValid() and + (len(self.selectedIndexes()) > 0) and + indexes_in_same_row() and + not self.readonly + ) + self.edit_action.setEnabled(condition_edit) + self.insert_action_above.setEnabled(condition_edit) + self.insert_action_below.setEnabled(condition_edit) + self.duplicate_action.setEnabled(condition_edit) + self.rename_action.setEnabled(condition_edit) + self.plot_action.setEnabled(condition_edit) + self.hist_action.setEnabled(condition_edit) + self.imshow_action.setEnabled(condition_edit) + self.save_array_action.setEnabled(condition_edit) + + condition_select = ( + index.isValid() and + (len(self.selectedIndexes()) > 0) + ) + self.view_action.setEnabled( + condition_select and indexes_in_same_row()) + self.copy_action.setEnabled(condition_select) + + condition_remove = ( + (not isinstance(data, (tuple, set))) and + index.isValid() and + (len(self.selectedIndexes()) > 0) and + not self.readonly + ) + self.remove_action.setEnabled(condition_remove) + + self.insert_action.setEnabled( + is_dict_instance and not self.readonly) + self.paste_action.setEnabled( + is_dict_instance and not self.readonly) + + # Hide/show actions + if index.isValid(): + if self.proxy_model: + key = self.proxy_model.get_key(index) + else: + key = self.source_model.get_key(index) + is_list = self.is_list(key) + is_array = self.is_array(key) and self.get_len(key) != 0 + condition_plot = (is_array and len(self.get_array_shape(key)) <= 2) + condition_hist = (is_array and self.get_array_ndim(key) == 1) + condition_imshow = condition_plot and self.get_array_ndim(key) == 2 + condition_imshow = condition_imshow or self.is_image(key) + else: + is_array = condition_plot = condition_imshow = is_list \ + = condition_hist = False + + self.plot_action.setVisible(condition_plot or is_list) + self.hist_action.setVisible(condition_hist or is_list) + self.insert_action.setVisible(is_dict_instance) + self.insert_action_above.setVisible(is_list_instance) + self.insert_action_below.setVisible(is_list_instance) + self.rename_action.setVisible(is_dict_instance) + self.paste_action.setVisible(is_dict_instance) + self.imshow_action.setVisible(condition_imshow) + self.save_array_action.setVisible(is_array) + + def resize_column_contents(self): + """Resize columns to contents.""" + self.automatic_column_width = True + self.adjust_columns() + + def user_resize_columns(self, logical_index, old_size, new_size): + """Handle the user resize action.""" + self.automatic_column_width = False + + def adjust_columns(self): + """Resize two first columns to contents""" + if self.automatic_column_width: + for col in range(3): + self.resizeColumnToContents(col) + + def set_data(self, data): + """Set table data""" + if data is not None: + self.source_model.set_data(data, self.dictfilter) + self.source_model.reset() + self.sortByColumn(0, Qt.AscendingOrder) + + def mousePressEvent(self, event): + """Reimplement Qt method""" + if event.button() != Qt.LeftButton: + QTableView.mousePressEvent(self, event) + return + index_clicked = self.indexAt(event.pos()) + if index_clicked.isValid(): + if index_clicked == self.currentIndex() \ + and index_clicked in self.selectedIndexes(): + self.clearSelection() + else: + QTableView.mousePressEvent(self, event) + else: + self.clearSelection() + event.accept() + + def mouseDoubleClickEvent(self, event): + """Reimplement Qt method""" + index_clicked = self.indexAt(event.pos()) + if index_clicked.isValid(): + row = index_clicked.row() + # TODO: Remove hard coded "Value" column number (3 here) + index_clicked = index_clicked.child(row, 3) + self.edit(index_clicked) + else: + event.accept() + + def keyPressEvent(self, event): + """Reimplement Qt methods""" + if event.key() == Qt.Key_Delete: + self.remove_item() + elif event.key() == Qt.Key_F2: + self.rename_item() + elif event == QKeySequence.Copy: + self.copy() + elif event == QKeySequence.Paste: + self.paste() + else: + QTableView.keyPressEvent(self, event) + + def contextMenuEvent(self, event): + """Reimplement Qt method""" + if self.source_model.showndata: + self.refresh_menu() + self.menu.popup(event.globalPos()) + event.accept() + else: + self.empty_ws_menu.popup(event.globalPos()) + event.accept() + + def dragEnterEvent(self, event): + """Allow user to drag files""" + if mimedata2url(event.mimeData()): + event.accept() + else: + event.ignore() + + def dragMoveEvent(self, event): + """Allow user to move files""" + if mimedata2url(event.mimeData()): + event.setDropAction(Qt.CopyAction) + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + """Allow user to drop supported files""" + urls = mimedata2url(event.mimeData()) + if urls: + event.setDropAction(Qt.CopyAction) + event.accept() + self.sig_files_dropped.emit(urls) + else: + event.ignore() + + def _deselect_index(self, index): + """ + Deselect index after any operation that adds or removes rows to/from + the editor. + + Notes + ----- + * This avoids showing the wrong buttons in the editor's toolbar when + the operation is completed. + * Also, if we leave something selected, then the next operation won't + introduce the item in the expected row. That's why we need to force + users to select a row again after this. + """ + self.selectionModel().select(index, QItemSelectionModel.Select) + self.selectionModel().select(index, QItemSelectionModel.Deselect) + + @Slot() + def edit_item(self): + """Edit item""" + index = self.currentIndex() + if not index.isValid(): + return + # TODO: Remove hard coded "Value" column number (3 here) + self.edit(index.child(index.row(), 3)) + + @Slot() + def remove_item(self, force=False): + """Remove item""" + current_index = self.currentIndex() + indexes = self.selectedIndexes() + + if not indexes: + return + + for index in indexes: + if not index.isValid(): + return + + if not force: + one = _("Do you want to remove the selected item?") + more = _("Do you want to remove all selected items?") + answer = QMessageBox.question(self, _("Remove"), + one if len(indexes) == 1 else more, + QMessageBox.Yes | QMessageBox.No) + + if force or answer == QMessageBox.Yes: + if self.proxy_model: + idx_rows = unsorted_unique( + [self.proxy_model.mapToSource(idx).row() + for idx in indexes]) + else: + idx_rows = unsorted_unique([idx.row() for idx in indexes]) + keys = [self.source_model.keys[idx_row] for idx_row in idx_rows] + self.remove_values(keys) + + # This avoids a segfault in our tests that doesn't happen when + # removing items manually. + if not running_under_pytest(): + self._deselect_index(current_index) + + def copy_item(self, erase_original=False, new_name=None): + """Copy item""" + current_index = self.currentIndex() + indexes = self.selectedIndexes() + + if not indexes: + return + + if self.proxy_model: + idx_rows = unsorted_unique( + [self.proxy_model.mapToSource(idx).row() for idx in indexes]) + else: + idx_rows = unsorted_unique([idx.row() for idx in indexes]) + + if len(idx_rows) > 1 or not indexes[0].isValid(): + return + + orig_key = self.source_model.keys[idx_rows[0]] + if erase_original: + if not isinstance(orig_key, str): + QMessageBox.warning( + self, + _("Warning"), + _("You can only rename keys that are strings") + ) + return + + title = _('Rename') + field_text = _('New variable name:') + else: + title = _('Duplicate') + field_text = _('Variable name:') + + data = self.source_model.get_data() + if isinstance(data, (list, set)): + new_key, valid = len(data), True + elif new_name is not None: + new_key, valid = new_name, True + else: + new_key, valid = QInputDialog.getText(self, title, field_text, + QLineEdit.Normal, orig_key) + + if valid and to_text_string(new_key): + new_key = try_to_eval(to_text_string(new_key)) + if new_key == orig_key: + return + self.copy_value(orig_key, new_key) + if erase_original: + self.remove_values([orig_key]) + + self._deselect_index(current_index) + + @Slot() + def duplicate_item(self): + """Duplicate item""" + self.copy_item() + + @Slot() + def rename_item(self, new_name=None): + """Rename item""" + self.copy_item(erase_original=True, new_name=new_name) + + @Slot() + def insert_item(self, below=True): + """Insert item""" + index = self.currentIndex() + if not index.isValid(): + row = self.source_model.rowCount() + else: + if self.proxy_model: + if below: + row = self.proxy_model.mapToSource(index).row() + 1 + else: + row = self.proxy_model.mapToSource(index).row() + else: + if below: + row = index.row() + 1 + else: + row = index.row() + data = self.source_model.get_data() + + if isinstance(data, list): + key = row + data.insert(row, '') + elif isinstance(data, dict): + key, valid = QInputDialog.getText(self, _('Insert'), _('Key:'), + QLineEdit.Normal) + if valid and to_text_string(key): + key = try_to_eval(to_text_string(key)) + else: + return + else: + return + + value, valid = QInputDialog.getText(self, _('Insert'), _('Value:'), + QLineEdit.Normal) + + if valid and to_text_string(value): + self.new_value(key, try_to_eval(to_text_string(value))) + + @Slot() + def view_item(self): + """View item with the Object Explorer""" + index = self.currentIndex() + if not index.isValid(): + return + # TODO: Remove hard coded "Value" column number (3 here) + index = index.child(index.row(), 3) + self.delegate.createEditor(self, None, index, object_explorer=True) + + def __prepare_plot(self): + try: + import guiqwt.pyplot #analysis:ignore + return True + except: + try: + if 'matplotlib' not in sys.modules: + import matplotlib + return True + except Exception: + QMessageBox.warning(self, _("Import error"), + _("Please install matplotlib" + " or guiqwt.")) + + def plot_item(self, funcname): + """Plot item""" + index = self.currentIndex() + if self.__prepare_plot(): + if self.proxy_model: + key = self.source_model.get_key( + self.proxy_model.mapToSource(index)) + else: + key = self.source_model.get_key(index) + try: + self.plot(key, funcname) + except (ValueError, TypeError) as error: + QMessageBox.critical(self, _( "Plot"), + _("Unable to plot data." + "

    Error message:
    %s" + ) % str(error)) + + @Slot() + def imshow_item(self): + """Imshow item""" + index = self.currentIndex() + if self.__prepare_plot(): + if self.proxy_model: + key = self.source_model.get_key( + self.proxy_model.mapToSource(index)) + else: + key = self.source_model.get_key(index) + try: + if self.is_image(key): + self.show_image(key) + else: + self.imshow(key) + except (ValueError, TypeError) as error: + QMessageBox.critical(self, _( "Plot"), + _("Unable to show image." + "

    Error message:
    %s" + ) % str(error)) + + @Slot() + def save_array(self): + """Save array""" + title = _( "Save array") + if self.array_filename is None: + self.array_filename = getcwd_or_home() + self.redirect_stdio.emit(False) + filename, _selfilter = getsavefilename(self, title, + self.array_filename, + _("NumPy arrays")+" (*.npy)") + self.redirect_stdio.emit(True) + if filename: + self.array_filename = filename + data = self.delegate.get_value( self.currentIndex() ) + try: + import numpy as np + np.save(self.array_filename, data) + except Exception as error: + QMessageBox.critical(self, title, + _("Unable to save array" + "

    Error message:
    %s" + ) % str(error)) + + @Slot() + def copy(self): + """Copy text to clipboard""" + clipboard = QApplication.clipboard() + clipl = [] + for idx in self.selectedIndexes(): + if not idx.isValid(): + continue + obj = self.delegate.get_value(idx) + # Check if we are trying to copy a numpy array, and if so make sure + # to copy the whole thing in a tab separated format + if (isinstance(obj, (np.ndarray, np.ma.MaskedArray)) and + np.ndarray is not FakeObject): + if PY3: + output = io.BytesIO() + else: + output = io.StringIO() + try: + np.savetxt(output, obj, delimiter='\t') + except Exception: + QMessageBox.warning(self, _("Warning"), + _("It was not possible to copy " + "this array")) + return + obj = output.getvalue().decode('utf-8') + output.close() + elif (isinstance(obj, (pd.DataFrame, pd.Series)) and + pd.DataFrame is not FakeObject): + output = io.StringIO() + try: + obj.to_csv(output, sep='\t', index=True, header=True) + except Exception: + QMessageBox.warning(self, _("Warning"), + _("It was not possible to copy " + "this dataframe")) + return + if PY3: + obj = output.getvalue() + else: + obj = output.getvalue().decode('utf-8') + output.close() + elif is_binary_string(obj): + obj = to_text_string(obj, 'utf8') + else: + obj = to_text_string(obj) + clipl.append(obj) + clipboard.setText('\n'.join(clipl)) + + def import_from_string(self, text, title=None): + """Import data from string""" + data = self.source_model.get_data() + # Check if data is a dict + if not hasattr(data, "keys"): + return + editor = ImportWizard( + self, text, title=title, contents_title=_("Clipboard contents"), + varname=fix_reference_name("data", blacklist=list(data.keys()))) + if editor.exec_(): + var_name, clip_data = editor.get_data() + self.new_value(var_name, clip_data) + + @Slot() + def paste(self): + """Import text/data/code from clipboard""" + clipboard = QApplication.clipboard() + cliptext = '' + if clipboard.mimeData().hasText(): + cliptext = to_text_string(clipboard.text()) + if cliptext.strip(): + self.import_from_string(cliptext, title=_("Import from clipboard")) + else: + QMessageBox.warning(self, _( "Empty clipboard"), + _("Nothing to be imported from clipboard.")) + + +class CollectionsEditorTableView(BaseTableView): + """CollectionsEditor table view""" + def __init__(self, parent, data, readonly=False, title="", + names=False): + BaseTableView.__init__(self, parent) + self.dictfilter = None + self.readonly = readonly or isinstance(data, (tuple, set)) + CollectionsModelClass = (ReadOnlyCollectionsModel if self.readonly + else CollectionsModel) + self.source_model = CollectionsModelClass( + self, + data, + title, + names=names, + minmax=self.get_conf('minmax') + ) + self.model = self.source_model + self.setModel(self.source_model) + self.delegate = CollectionsDelegate(self) + self.setItemDelegate(self.delegate) + + self.setup_table() + self.menu = self.setup_menu() + if isinstance(data, set): + self.horizontalHeader().hideSection(0) + + #------ Remote/local API -------------------------------------------------- + def remove_values(self, keys): + """Remove values from data""" + data = self.source_model.get_data() + for key in sorted(keys, reverse=True): + data.pop(key) + self.set_data(data) + + def copy_value(self, orig_key, new_key): + """Copy value""" + data = self.source_model.get_data() + if isinstance(data, list): + data.append(data[orig_key]) + if isinstance(data, set): + data.add(data[orig_key]) + else: + data[new_key] = data[orig_key] + self.set_data(data) + + def new_value(self, key, value): + """Create new value in data""" + index = self.currentIndex() + data = self.source_model.get_data() + data[key] = value + self.set_data(data) + self._deselect_index(index) + + def is_list(self, key): + """Return True if variable is a list or a tuple""" + data = self.source_model.get_data() + return isinstance(data[key], (tuple, list)) + + def is_set(self, key): + """Return True if variable is a set""" + data = self.source_model.get_data() + return isinstance(data[key], set) + + def get_len(self, key): + """Return sequence length""" + data = self.source_model.get_data() + return len(data[key]) + + def is_array(self, key): + """Return True if variable is a numpy array""" + data = self.source_model.get_data() + return isinstance(data[key], (np.ndarray, np.ma.MaskedArray)) + + def is_image(self, key): + """Return True if variable is a PIL.Image image""" + data = self.source_model.get_data() + return isinstance(data[key], PIL.Image.Image) + + def is_dict(self, key): + """Return True if variable is a dictionary""" + data = self.source_model.get_data() + return isinstance(data[key], dict) + + def get_array_shape(self, key): + """Return array's shape""" + data = self.source_model.get_data() + return data[key].shape + + def get_array_ndim(self, key): + """Return array's ndim""" + data = self.source_model.get_data() + return data[key].ndim + + def oedit(self, key): + """Edit item""" + data = self.source_model.get_data() + from spyder.plugins.variableexplorer.widgets.objecteditor import ( + oedit) + oedit(data[key]) + + def plot(self, key, funcname): + """Plot item""" + data = self.source_model.get_data() + import spyder.pyplot as plt + plt.figure() + getattr(plt, funcname)(data[key]) + plt.show() + + def imshow(self, key): + """Show item's image""" + data = self.source_model.get_data() + import spyder.pyplot as plt + plt.figure() + plt.imshow(data[key]) + plt.show() + + def show_image(self, key): + """Show image (item is a PIL image)""" + data = self.source_model.get_data() + data[key].show() + #-------------------------------------------------------------------------- + + def set_filter(self, dictfilter=None): + """Set table dict filter""" + self.dictfilter = dictfilter + + +class CollectionsEditorWidget(QWidget): + """Dictionary Editor Widget""" + def __init__(self, parent, data, readonly=False, title="", remote=False): + QWidget.__init__(self, parent) + if remote: + self.editor = RemoteCollectionsEditorTableView(self, data, readonly) + else: + self.editor = CollectionsEditorTableView(self, data, readonly, + title) + + toolbar = SpyderToolbar(parent=None, title='Editor toolbar') + toolbar.setStyleSheet(str(PANES_TOOLBAR_STYLESHEET)) + + for item in self.editor.menu_actions: + if item is not None: + toolbar.addAction(item) + + # Update the toolbar actions state + self.editor.refresh_menu() + layout = QVBoxLayout() + layout.addWidget(toolbar) + layout.addWidget(self.editor) + self.setLayout(layout) + + def set_data(self, data): + """Set DictEditor data""" + self.editor.set_data(data) + + def get_title(self): + """Get model title""" + return self.editor.source_model.title + + +class CollectionsEditor(BaseDialog): + """Collections Editor Dialog""" + def __init__(self, parent=None): + super().__init__(parent) + + # Destroying the C++ object right after closing the dialog box, + # otherwise it may be garbage-collected in another QThread + # (e.g. the editor's analysis thread in Spyder), thus leading to + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + + self.data_copy = None + self.widget = None + self.btn_save_and_close = None + self.btn_close = None + + def setup(self, data, title='', readonly=False, remote=False, + icon=None, parent=None): + """Setup editor.""" + if isinstance(data, (dict, set)): + # dictionary, set + self.data_copy = data.copy() + datalen = len(data) + elif isinstance(data, (tuple, list)): + # list, tuple + self.data_copy = data[:] + datalen = len(data) + else: + # unknown object + import copy + try: + self.data_copy = copy.deepcopy(data) + except NotImplementedError: + self.data_copy = copy.copy(data) + except (TypeError, AttributeError): + readonly = True + self.data_copy = data + datalen = len(get_object_attrs(data)) + + # If the copy has a different type, then do not allow editing, because + # this would change the type after saving; cf. spyder-ide/spyder#6936. + if type(self.data_copy) != type(data): + readonly = True + + self.widget = CollectionsEditorWidget(self, self.data_copy, + title=title, readonly=readonly, + remote=remote) + self.widget.editor.source_model.sig_setting_data.connect( + self.save_and_close_enable) + layout = QVBoxLayout() + layout.addWidget(self.widget) + self.setLayout(layout) + + # Buttons configuration + btn_layout = QHBoxLayout() + btn_layout.setContentsMargins(4, 4, 4, 4) + btn_layout.addStretch() + + if not readonly: + self.btn_save_and_close = QPushButton(_('Save and Close')) + self.btn_save_and_close.setDisabled(True) + self.btn_save_and_close.clicked.connect(self.accept) + btn_layout.addWidget(self.btn_save_and_close) + + self.btn_close = QPushButton(_('Close')) + self.btn_close.setAutoDefault(True) + self.btn_close.setDefault(True) + self.btn_close.clicked.connect(self.reject) + btn_layout.addWidget(self.btn_close) + + layout.addLayout(btn_layout) + + self.setWindowTitle(self.widget.get_title()) + if icon is None: + self.setWindowIcon(ima.icon('dictedit')) + + if sys.platform == 'darwin': + # See spyder-ide/spyder#9051 + self.setWindowFlags(Qt.Tool) + else: + # Make the dialog act as a window + self.setWindowFlags(Qt.Window) + + @Slot() + def save_and_close_enable(self): + """Handle the data change event to enable the save and close button.""" + if self.btn_save_and_close: + self.btn_save_and_close.setEnabled(True) + self.btn_save_and_close.setAutoDefault(True) + self.btn_save_and_close.setDefault(True) + + def get_value(self): + """Return modified copy of dictionary or list""" + # It is import to avoid accessing Qt C++ object as it has probably + # already been destroyed, due to the Qt.WA_DeleteOnClose attribute + return self.data_copy + + +#============================================================================== +# Remote versions of CollectionsDelegate and CollectionsEditorTableView +#============================================================================== +class RemoteCollectionsDelegate(CollectionsDelegate): + """CollectionsEditor Item Delegate""" + def __init__(self, parent=None): + CollectionsDelegate.__init__(self, parent) + + def get_value(self, index): + if index.isValid(): + source_index = index.model().mapToSource(index) + name = source_index.model().keys[source_index.row()] + return self.parent().get_value(name) + + def set_value(self, index, value): + if index.isValid(): + source_index = index.model().mapToSource(index) + name = source_index.model().keys[source_index.row()] + self.parent().new_value(name, value) + + +class RemoteCollectionsEditorTableView(BaseTableView): + """DictEditor table view""" + def __init__(self, parent, data, shellwidget=None, remote_editing=False, + create_menu=False): + BaseTableView.__init__(self, parent) + + self.shellwidget = shellwidget + self.var_properties = {} + self.dictfilter = None + self.delegate = None + self.readonly = False + self.finder = None + + self.source_model = CollectionsModel( + self, data, names=True, + minmax=self.get_conf('minmax'), + remote=True) + + self.horizontalHeader().sectionClicked.connect( + self.source_model.load_all) + + self.proxy_model = CollectionsCustomSortFilterProxy(self) + self.model = self.proxy_model + + self.proxy_model.setSourceModel(self.source_model) + self.proxy_model.setDynamicSortFilter(True) + self.proxy_model.setFilterKeyColumn(0) # Col 0 for Name + self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) + self.proxy_model.setSortRole(Qt.UserRole) + self.setModel(self.proxy_model) + + self.hideColumn(4) # Column 4 for Score + + self.delegate = RemoteCollectionsDelegate(self) + self.delegate.sig_free_memory_requested.connect( + self.sig_free_memory_requested) + self.delegate.sig_editor_creation_started.connect( + self.sig_editor_creation_started) + self.delegate.sig_editor_shown.connect(self.sig_editor_shown) + self.setItemDelegate(self.delegate) + + self.setup_table() + + if create_menu: + self.menu = self.setup_menu() + + # ------ Remote/local API ------------------------------------------------- + def get_value(self, name): + """Get the value of a variable""" + value = self.shellwidget.get_value(name) + return value + + def new_value(self, name, value): + """Create new value in data""" + try: + self.shellwidget.set_value(name, value) + except TypeError as e: + QMessageBox.critical(self, _("Error"), + "TypeError: %s" % to_text_string(e)) + self.shellwidget.refresh_namespacebrowser() + + def remove_values(self, names): + """Remove values from data""" + for name in names: + self.shellwidget.remove_value(name) + self.shellwidget.refresh_namespacebrowser() + + def copy_value(self, orig_name, new_name): + """Copy value""" + self.shellwidget.copy_value(orig_name, new_name) + self.shellwidget.refresh_namespacebrowser() + + def is_list(self, name): + """Return True if variable is a list, a tuple or a set""" + return self.var_properties[name]['is_list'] + + def is_dict(self, name): + """Return True if variable is a dictionary""" + return self.var_properties[name]['is_dict'] + + def get_len(self, name): + """Return sequence length""" + return self.var_properties[name]['len'] + + def is_array(self, name): + """Return True if variable is a NumPy array""" + return self.var_properties[name]['is_array'] + + def is_image(self, name): + """Return True if variable is a PIL.Image image""" + return self.var_properties[name]['is_image'] + + def is_data_frame(self, name): + """Return True if variable is a DataFrame""" + return self.var_properties[name]['is_data_frame'] + + def is_series(self, name): + """Return True if variable is a Series""" + return self.var_properties[name]['is_series'] + + def get_array_shape(self, name): + """Return array's shape""" + return self.var_properties[name]['array_shape'] + + def get_array_ndim(self, name): + """Return array's ndim""" + return self.var_properties[name]['array_ndim'] + + def plot(self, name, funcname): + """Plot item""" + sw = self.shellwidget + sw.execute("%%varexp --%s %s" % (funcname, name)) + + def imshow(self, name): + """Show item's image""" + sw = self.shellwidget + sw.execute("%%varexp --imshow %s" % name) + + def show_image(self, name): + """Show image (item is a PIL image)""" + command = "%s.show()" % name + sw = self.shellwidget + sw.execute(command) + + # ------ Other ------------------------------------------------------------ + def setup_menu(self): + """Setup context menu.""" + menu = BaseTableView.setup_menu(self) + return menu + + def refresh_menu(self): + if self.var_properties: + super().refresh_menu() + + def set_regex(self, regex=None, reset=False): + """Update the regex text for the variable finder.""" + if reset or self.finder is None or not self.finder.text(): + text = '' + else: + text = self.finder.text().replace(' ', '').lower() + + self.proxy_model.set_filter(text) + self.source_model.update_search_letters(text) + + if text: + # TODO: Use constants for column numbers + self.sortByColumn(4, Qt.DescendingOrder) # Col 4 for index + + self.last_regex = regex + + def next_row(self): + """Move to next row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row + 1 == rows: + row = -1 + self.selectRow(row + 1) + + def previous_row(self): + """Move to previous row from currently selected row.""" + row = self.currentIndex().row() + rows = self.proxy_model.rowCount() + if row == 0: + row = rows + self.selectRow(row - 1) + + +class CollectionsCustomSortFilterProxy(CustomSortFilterProxy): + """ + Custom column filter based on regex and model data. + + Reimplements 'filterAcceptsRow' to follow NamespaceBrowser model. + Reimplements 'set_filter' to allow sorting while filtering + """ + + def get_key(self, index): + """Return current key from source model.""" + source_index = self.mapToSource(index) + return self.sourceModel().get_key(source_index) + + def get_index_from_key(self, key): + """Return index using key from source model.""" + source_index = self.sourceModel().get_index_from_key(key) + return self.mapFromSource(source_index) + + def get_value(self, index): + """Return current value from source model.""" + source_index = self.mapToSource(index) + return self.sourceModel().get_value(source_index) + + def set_value(self, index, value): + """Set value in source model.""" + try: + source_index = self.mapToSource(index) + self.sourceModel().set_value(source_index, value) + except AttributeError: + # Read-only models don't have set_value method + pass + + def set_filter(self, text): + """Set regular expression for filter.""" + self.pattern = get_search_regex(text) + self.invalidateFilter() + + def filterAcceptsRow(self, row_num, parent): + """ + Qt override. + + Reimplemented from base class to allow the use of custom filtering + using to columns (name and type). + """ + model = self.sourceModel() + name = to_text_string(model.row_key(row_num)) + variable_type = to_text_string(model.row_type(row_num)) + r_name = re.search(self.pattern, name) + r_type = re.search(self.pattern, variable_type) + + if r_name is None and r_type is None: + return False + else: + return True + + def lessThan(self, left, right): + """ + Implements ordering in a natural way, as a human would sort. + This functions enables sorting of the main variable editor table, + which does not rely on 'self.sort()'. + """ + leftData = self.sourceModel().data(left) + rightData = self.sourceModel().data(right) + try: + if isinstance(leftData, str) and isinstance(rightData, str): + return natsort(leftData) < natsort(rightData) + else: + return leftData < rightData + except TypeError: + # This is needed so all the elements that cannot be compared such + # as dataframes and numpy arrays are grouped together in the + # variable explorer. For more info see spyder-ide/spyder#14527 + return True + + +# ============================================================================= +# Tests +# ============================================================================= +def get_test_data(): + """Create test data.""" + image = PIL.Image.fromarray(np.random.randint(256, size=(100, 100)), + mode='P') + testdict = {'d': 1, 'a': np.random.rand(10, 10), 'b': [1, 2]} + testdate = datetime.date(1945, 5, 8) + test_timedelta = datetime.timedelta(days=-1, minutes=42, seconds=13) + + try: + import pandas as pd + except (ModuleNotFoundError, ImportError): + test_df = None + test_timestamp = test_pd_td = test_dtindex = test_series = None + else: + test_timestamp = pd.Timestamp("1945-05-08T23:01:00.12345") + test_pd_td = pd.Timedelta(days=2193, hours=12) + test_dtindex = pd.date_range(start="1939-09-01T", + end="1939-10-06", + freq="12H") + test_series = pd.Series({"series_name": [0, 1, 2, 3, 4, 5]}) + test_df = pd.DataFrame({"string_col": ["a", "b", "c", "d"], + "int_col": [0, 1, 2, 3], + "float_col": [1.1, 2.2, 3.3, 4.4], + "bool_col": [True, False, False, True]}) + + class Foobar(object): + + def __init__(self): + self.text = "toto" + self.testdict = testdict + self.testdate = testdate + + foobar = Foobar() + return {'object': foobar, + 'module': np, + 'str': 'kjkj kj k j j kj k jkj', + 'unicode': to_text_string('éù', 'utf-8'), + 'list': [1, 3, [sorted, 5, 6], 'kjkj', None], + 'set': {1, 2, 1, 3, None, 'A', 'B', 'C', True, False}, + 'tuple': ([1, testdate, testdict, test_timedelta], 'kjkj', None), + 'dict': testdict, + 'float': 1.2233, + 'int': 223, + 'bool': True, + 'array': np.random.rand(10, 10).astype(np.int64), + 'masked_array': np.ma.array([[1, 0], [1, 0]], + mask=[[True, False], [False, False]]), + '1D-array': np.linspace(-10, 10).astype(np.float16), + '3D-array': np.random.randint(2, size=(5, 5, 5)).astype(np.bool_), + 'empty_array': np.array([]), + 'image': image, + 'date': testdate, + 'datetime': datetime.datetime(1945, 5, 8, 23, 1, 0, int(1.5e5)), + 'timedelta': test_timedelta, + 'complex': 2+1j, + 'complex64': np.complex64(2+1j), + 'complex128': np.complex128(9j), + 'int8_scalar': np.int8(8), + 'int16_scalar': np.int16(16), + 'int32_scalar': np.int32(32), + 'int64_scalar': np.int64(64), + 'float16_scalar': np.float16(16), + 'float32_scalar': np.float32(32), + 'float64_scalar': np.float64(64), + 'bool_scalar': np.bool(8), + 'bool__scalar': np.bool_(8), + 'timestamp': test_timestamp, + 'timedelta_pd': test_pd_td, + 'datetimeindex': test_dtindex, + 'series': test_series, + 'ddataframe': test_df, + 'None': None, + 'unsupported1': np.arccos, + 'unsupported2': np.cast, + # Test for spyder-ide/spyder#3518. + 'big_struct_array': np.zeros(1000, dtype=[('ID', 'f8'), + ('param1', 'f8', 5000)]), + } + + +def editor_test(): + """Test Collections editor.""" + dialog = CollectionsEditor() + dialog.setup(get_test_data()) + dialog.show() + + +def remote_editor_test(): + """Test remote collections editor.""" + from spyder.config.manager import CONF + from spyder_kernels.utils.nsview import (make_remote_view, + REMOTE_SETTINGS) + + settings = {} + for name in REMOTE_SETTINGS: + settings[name] = CONF.get('variable_explorer', name) + + remote = make_remote_view(get_test_data(), settings) + dialog = CollectionsEditor() + dialog.setup(remote, remote=True) + dialog.show() + + +if __name__ == "__main__": + from spyder.utils.qthelpers import qapplication + + app = qapplication() # analysis:ignore + editor_test() + remote_editor_test() + app.exec_() diff --git a/spyder/widgets/colors.py b/spyder/widgets/colors.py index ddf45e791ad..57b5f9cfc2a 100644 --- a/spyder/widgets/colors.py +++ b/spyder/widgets/colors.py @@ -1,95 +1,95 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -# Third party imports -from qtpy.QtCore import Property, QSize, Signal, Slot -from qtpy.QtGui import QColor, QIcon, QPixmap -from qtpy.QtWidgets import QColorDialog, QHBoxLayout, QLineEdit, QToolButton - -# Local imports -from spyder.py3compat import is_text_string - - -class ColorButton(QToolButton): - """ - Color choosing push button - """ - colorChanged = Signal(QColor) - - def __init__(self, parent=None): - QToolButton.__init__(self, parent) - self.setFixedSize(20, 20) - self.setIconSize(QSize(12, 12)) - self.clicked.connect(self.choose_color) - self._color = QColor() - - def choose_color(self): - color = QColorDialog.getColor(self._color, self.parentWidget(), - 'Select Color', - QColorDialog.ShowAlphaChannel) - if color.isValid(): - self.set_color(color) - - def get_color(self): - return self._color - - @Slot(QColor) - def set_color(self, color): - if color != self._color: - self._color = color - self.colorChanged.emit(self._color) - pixmap = QPixmap(self.iconSize()) - pixmap.fill(color) - self.setIcon(QIcon(pixmap)) - - color = Property("QColor", get_color, set_color) - - -def text_to_qcolor(text): - """ - Create a QColor from specified string - Avoid warning from Qt when an invalid QColor is instantiated - """ - color = QColor() - text = str(text) - if not is_text_string(text): - return color - if text.startswith('#') and len(text)==7: - correct = '#0123456789abcdef' - for char in text: - if char.lower() not in correct: - return color - elif text not in list(QColor.colorNames()): - return color - color.setNamedColor(text) - return color - - -class ColorLayout(QHBoxLayout): - """Color-specialized QLineEdit layout""" - def __init__(self, color, parent=None): - QHBoxLayout.__init__(self) - assert isinstance(color, QColor) - self.lineedit = QLineEdit(color.name(), parent) - fm = self.lineedit.fontMetrics() - self.lineedit.setMinimumWidth(int(fm.width(color.name()) * 1.2)) - self.lineedit.textChanged.connect(self.update_color) - self.addWidget(self.lineedit) - self.colorbtn = ColorButton(parent) - self.colorbtn.color = color - self.colorbtn.colorChanged.connect(self.update_text) - self.addWidget(self.colorbtn) - - def update_color(self, text): - color = text_to_qcolor(text) - if color.isValid(): - self.colorbtn.color = color - - def update_text(self, color): - self.lineedit.setText(color.name()) - - def text(self): - return self.lineedit.text() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Third party imports +from qtpy.QtCore import Property, QSize, Signal, Slot +from qtpy.QtGui import QColor, QIcon, QPixmap +from qtpy.QtWidgets import QColorDialog, QHBoxLayout, QLineEdit, QToolButton + +# Local imports +from spyder.py3compat import is_text_string + + +class ColorButton(QToolButton): + """ + Color choosing push button + """ + colorChanged = Signal(QColor) + + def __init__(self, parent=None): + QToolButton.__init__(self, parent) + self.setFixedSize(20, 20) + self.setIconSize(QSize(12, 12)) + self.clicked.connect(self.choose_color) + self._color = QColor() + + def choose_color(self): + color = QColorDialog.getColor(self._color, self.parentWidget(), + 'Select Color', + QColorDialog.ShowAlphaChannel) + if color.isValid(): + self.set_color(color) + + def get_color(self): + return self._color + + @Slot(QColor) + def set_color(self, color): + if color != self._color: + self._color = color + self.colorChanged.emit(self._color) + pixmap = QPixmap(self.iconSize()) + pixmap.fill(color) + self.setIcon(QIcon(pixmap)) + + color = Property("QColor", get_color, set_color) + + +def text_to_qcolor(text): + """ + Create a QColor from specified string + Avoid warning from Qt when an invalid QColor is instantiated + """ + color = QColor() + text = str(text) + if not is_text_string(text): + return color + if text.startswith('#') and len(text)==7: + correct = '#0123456789abcdef' + for char in text: + if char.lower() not in correct: + return color + elif text not in list(QColor.colorNames()): + return color + color.setNamedColor(text) + return color + + +class ColorLayout(QHBoxLayout): + """Color-specialized QLineEdit layout""" + def __init__(self, color, parent=None): + QHBoxLayout.__init__(self) + assert isinstance(color, QColor) + self.lineedit = QLineEdit(color.name(), parent) + fm = self.lineedit.fontMetrics() + self.lineedit.setMinimumWidth(int(fm.width(color.name()) * 1.2)) + self.lineedit.textChanged.connect(self.update_color) + self.addWidget(self.lineedit) + self.colorbtn = ColorButton(parent) + self.colorbtn.color = color + self.colorbtn.colorChanged.connect(self.update_text) + self.addWidget(self.colorbtn) + + def update_color(self, text): + color = text_to_qcolor(text) + if color.isValid(): + self.colorbtn.color = color + + def update_text(self, color): + self.lineedit.setText(color.name()) + + def text(self): + return self.lineedit.text() diff --git a/spyder/widgets/comboboxes.py b/spyder/widgets/comboboxes.py index b6d1cbe864b..ba3037d2691 100644 --- a/spyder/widgets/comboboxes.py +++ b/spyder/widgets/comboboxes.py @@ -1,418 +1,418 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Customized combobox widgets.""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - - -# Standard library imports -import glob -import os -import os.path as osp - -# Third party imports -from qtpy.QtCore import QEvent, Qt, QTimer, QUrl, Signal, QSize -from qtpy.QtGui import QFont -from qtpy.QtWidgets import (QComboBox, QCompleter, QLineEdit, - QSizePolicy, QToolTip) - -# Local imports -from spyder.config.base import _ -from spyder.py3compat import to_text_string -from spyder.utils.stylesheet import APP_STYLESHEET -from spyder.widgets.helperwidgets import IconLineEdit - - -class BaseComboBox(QComboBox): - """Editable combo box base class""" - valid = Signal(bool, bool) - sig_tab_pressed = Signal(bool) - - sig_resized = Signal(QSize, QSize) - """ - This signal is emitted to inform the widget has been resized. - - Parameters - ---------- - size: QSize - The new size of the widget. - old_size: QSize - The previous size of the widget. - """ - - def __init__(self, parent): - QComboBox.__init__(self, parent) - self.setEditable(True) - self.setCompleter(QCompleter(self)) - self.selected_text = self.currentText() - - # --- Qt overrides - def event(self, event): - """Qt Override. - - Filter tab keys and process double tab keys. - """ - - # Type check: Prevent error in PySide where 'event' may be of type - # QtGui.QPainter (for whatever reason). - if not isinstance(event, QEvent): - return True - - if (event.type() == QEvent.KeyPress) and (event.key() == Qt.Key_Tab): - self.sig_tab_pressed.emit(True) - return True - return QComboBox.event(self, event) - - def keyPressEvent(self, event): - """Qt Override. - - Handle key press events. - """ - if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: - if self.add_current_text_if_valid(): - self.selected() - self.hide_completer() - elif event.key() == Qt.Key_Escape: - self.set_current_text(self.selected_text) - self.hide_completer() - else: - QComboBox.keyPressEvent(self, event) - - def resizeEvent(self, event): - """ - Emit a resize signal for widgets that need to adapt its size. - """ - super().resizeEvent(event) - self.sig_resized.emit(event.size(), event.oldSize()) - - # --- Own methods - def is_valid(self, qstr): - """ - Return True if string is valid - Return None if validation can't be done - """ - pass - - def selected(self): - """Action to be executed when a valid item has been selected""" - self.valid.emit(True, True) - - def add_text(self, text): - """Add text to combo box: add a new item if text is not found in - combo box items.""" - index = self.findText(text) - while index != -1: - self.removeItem(index) - index = self.findText(text) - self.insertItem(0, text) - index = self.findText('') - if index != -1: - self.removeItem(index) - self.insertItem(0, '') - if text != '': - self.setCurrentIndex(1) - else: - self.setCurrentIndex(0) - else: - self.setCurrentIndex(0) - - def set_current_text(self, text): - """Sets the text of the QLineEdit of the QComboBox.""" - self.lineEdit().setText(to_text_string(text)) - - def add_current_text(self): - """Add current text to combo box history (convenient method)""" - text = self.currentText() - self.add_text(text) - - def add_current_text_if_valid(self): - """Add current text to combo box history if valid""" - valid = self.is_valid(self.currentText()) - if valid or valid is None: - self.add_current_text() - return True - else: - self.set_current_text(self.selected_text) - - def hide_completer(self): - """Hides the completion widget.""" - self.setCompleter(QCompleter([], self)) - - -class PatternComboBox(BaseComboBox): - """Search pattern combo box""" - - def __init__(self, parent, items=None, tip=None, - adjust_to_minimum=True, id_=None): - BaseComboBox.__init__(self, parent) - if hasattr(self.lineEdit(), 'setClearButtonEnabled'): # only Qt >= 5.2 - self.lineEdit().setClearButtonEnabled(True) - if adjust_to_minimum: - self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - if items is not None: - self.addItems(items) - if tip is not None: - self.setToolTip(tip) - if id_ is not None: - self.ID = id_ - - -class EditableComboBox(BaseComboBox): - """ - Editable combo box + Validate - """ - - def __init__(self, parent): - BaseComboBox.__init__(self, parent) - self.font = QFont() - self.selected_text = self.currentText() - - # Widget setup - self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) - - # Signals - self.editTextChanged.connect(self.validate) - self.tips = {True: _("Press enter to validate this entry"), - False: _('This entry is incorrect')} - - def show_tip(self, tip=""): - """Show tip""" - QToolTip.showText(self.mapToGlobal(self.pos()), tip, self) - - def selected(self): - """Action to be executed when a valid item has been selected""" - BaseComboBox.selected(self) - self.selected_text = self.currentText() - - def validate(self, qstr, editing=True): - """Validate entered path""" - if self.selected_text == qstr and qstr != '': - self.valid.emit(True, True) - return - - valid = self.is_valid(qstr) - if editing: - if valid: - self.valid.emit(True, False) - else: - self.valid.emit(False, False) - - -class PathComboBox(EditableComboBox): - """ - QComboBox handling path locations - """ - open_dir = Signal(str) - - def __init__(self, parent, adjust_to_contents=False, id_=None): - EditableComboBox.__init__(self, parent) - - # Replace the default lineedit by a custom one with icon display - lineedit = IconLineEdit(self) - - # Widget setup - if adjust_to_contents: - self.setSizeAdjustPolicy(QComboBox.AdjustToContents) - else: - self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.tips = {True: _("Press enter to validate this path"), - False: ''} - self.setLineEdit(lineedit) - - # Signals - self.highlighted.connect(self.add_tooltip_to_highlighted_item) - self.sig_tab_pressed.connect(self.tab_complete) - self.valid.connect(lineedit.update_status) - - if id_ is not None: - self.ID = id_ - - # --- Qt overrides - def focusInEvent(self, event): - """Handle focus in event restoring to display the status icon.""" - show_status = getattr(self.lineEdit(), 'show_status_icon', None) - if show_status: - show_status() - QComboBox.focusInEvent(self, event) - - def focusOutEvent(self, event): - """Handle focus out event restoring the last valid selected path.""" - # Calling asynchronously the 'add_current_text' to avoid crash - # https://groups.google.com/group/spyderlib/browse_thread/thread/2257abf530e210bd - if not self.is_valid(): - lineedit = self.lineEdit() - QTimer.singleShot(50, lambda: lineedit.setText(self.selected_text)) - - hide_status = getattr(self.lineEdit(), 'hide_status_icon', None) - if hide_status: - hide_status() - QComboBox.focusOutEvent(self, event) - - # --- Own methods - def _complete_options(self): - """Find available completion options.""" - text = to_text_string(self.currentText()) - opts = glob.glob(text + "*") - opts = sorted([opt for opt in opts if osp.isdir(opt)]) - - completer = QCompleter(opts, self) - qss = str(APP_STYLESHEET) - completer.popup().setStyleSheet(qss) - self.setCompleter(completer) - - return opts - - def tab_complete(self): - """ - If there is a single option available one tab completes the option. - """ - opts = self._complete_options() - if len(opts) == 1: - self.set_current_text(opts[0] + os.sep) - self.hide_completer() - else: - self.completer().complete() - - def is_valid(self, qstr=None): - """Return True if string is valid""" - if qstr is None: - qstr = self.currentText() - return osp.isdir(to_text_string(qstr)) - - def selected(self): - """Action to be executed when a valid item has been selected""" - self.selected_text = self.currentText() - self.valid.emit(True, True) - self.open_dir.emit(self.selected_text) - - def add_current_text(self): - """ - Add current text to combo box history (convenient method). - If path ends in os separator ("\" windows, "/" unix) remove it. - """ - text = self.currentText() - if osp.isdir(text) and text: - if text[-1] == os.sep: - text = text[:-1] - self.add_text(text) - - def add_tooltip_to_highlighted_item(self, index): - """ - Add a tooltip showing the full path of the currently highlighted item - of the PathComboBox. - """ - self.setItemData(index, self.itemText(index), Qt.ToolTipRole) - - -class UrlComboBox(PathComboBox): - """ - QComboBox handling urls - """ - def __init__(self, parent, adjust_to_contents=False, id_=None): - PathComboBox.__init__(self, parent, adjust_to_contents) - line_edit = QLineEdit(self) - self.setLineEdit(line_edit) - self.editTextChanged.disconnect(self.validate) - - if id_ is not None: - self.ID = id_ - - def is_valid(self, qstr=None): - """Return True if string is valid""" - if qstr is None: - qstr = self.currentText() - return QUrl(qstr).isValid() - - -class FileComboBox(PathComboBox): - """ - QComboBox handling File paths - """ - def __init__(self, parent=None, adjust_to_contents=False, - default_line_edit=False): - PathComboBox.__init__(self, parent, adjust_to_contents) - - if default_line_edit: - line_edit = QLineEdit(self) - self.setLineEdit(line_edit) - - # Widget setup - if adjust_to_contents: - self.setSizeAdjustPolicy(QComboBox.AdjustToContents) - else: - self.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - def is_valid(self, qstr=None): - """Return True if string is valid.""" - if qstr is None: - qstr = self.currentText() - valid = (osp.isfile(to_text_string(qstr)) or - osp.isdir(to_text_string(qstr))) - return valid - - def tab_complete(self): - """ - If there is a single option available one tab completes the option. - """ - opts = self._complete_options() - if len(opts) == 1: - text = opts[0] - if osp.isdir(text): - text = text + os.sep - self.set_current_text(text) - self.hide_completer() - else: - self.completer().complete() - - def _complete_options(self): - """Find available completion options.""" - text = to_text_string(self.currentText()) - opts = glob.glob(text + "*") - opts = sorted([opt for opt in opts - if osp.isdir(opt) or osp.isfile(opt)]) - - completer = QCompleter(opts, self) - qss = str(APP_STYLESHEET) - completer.popup().setStyleSheet(qss) - self.setCompleter(completer) - - return opts - - -def is_module_or_package(path): - """Return True if path is a Python module/package""" - is_module = osp.isfile(path) and osp.splitext(path)[1] in ('.py', '.pyw') - is_package = osp.isdir(path) and osp.isfile(osp.join(path, '__init__.py')) - return is_module or is_package - - -class PythonModulesComboBox(PathComboBox): - """ - QComboBox handling Python modules or packages path - (i.e. .py, .pyw files *and* directories containing __init__.py) - """ - def __init__(self, parent, adjust_to_contents=False, id_=None): - PathComboBox.__init__(self, parent, adjust_to_contents) - if id_ is not None: - self.ID = id_ - - def is_valid(self, qstr=None): - """Return True if string is valid""" - if qstr is None: - qstr = self.currentText() - return is_module_or_package(to_text_string(qstr)) - - def selected(self): - """Action to be executed when a valid item has been selected""" - EditableComboBox.selected(self) - self.open_dir.emit(self.currentText()) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Customized combobox widgets.""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + + +# Standard library imports +import glob +import os +import os.path as osp + +# Third party imports +from qtpy.QtCore import QEvent, Qt, QTimer, QUrl, Signal, QSize +from qtpy.QtGui import QFont +from qtpy.QtWidgets import (QComboBox, QCompleter, QLineEdit, + QSizePolicy, QToolTip) + +# Local imports +from spyder.config.base import _ +from spyder.py3compat import to_text_string +from spyder.utils.stylesheet import APP_STYLESHEET +from spyder.widgets.helperwidgets import IconLineEdit + + +class BaseComboBox(QComboBox): + """Editable combo box base class""" + valid = Signal(bool, bool) + sig_tab_pressed = Signal(bool) + + sig_resized = Signal(QSize, QSize) + """ + This signal is emitted to inform the widget has been resized. + + Parameters + ---------- + size: QSize + The new size of the widget. + old_size: QSize + The previous size of the widget. + """ + + def __init__(self, parent): + QComboBox.__init__(self, parent) + self.setEditable(True) + self.setCompleter(QCompleter(self)) + self.selected_text = self.currentText() + + # --- Qt overrides + def event(self, event): + """Qt Override. + + Filter tab keys and process double tab keys. + """ + + # Type check: Prevent error in PySide where 'event' may be of type + # QtGui.QPainter (for whatever reason). + if not isinstance(event, QEvent): + return True + + if (event.type() == QEvent.KeyPress) and (event.key() == Qt.Key_Tab): + self.sig_tab_pressed.emit(True) + return True + return QComboBox.event(self, event) + + def keyPressEvent(self, event): + """Qt Override. + + Handle key press events. + """ + if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: + if self.add_current_text_if_valid(): + self.selected() + self.hide_completer() + elif event.key() == Qt.Key_Escape: + self.set_current_text(self.selected_text) + self.hide_completer() + else: + QComboBox.keyPressEvent(self, event) + + def resizeEvent(self, event): + """ + Emit a resize signal for widgets that need to adapt its size. + """ + super().resizeEvent(event) + self.sig_resized.emit(event.size(), event.oldSize()) + + # --- Own methods + def is_valid(self, qstr): + """ + Return True if string is valid + Return None if validation can't be done + """ + pass + + def selected(self): + """Action to be executed when a valid item has been selected""" + self.valid.emit(True, True) + + def add_text(self, text): + """Add text to combo box: add a new item if text is not found in + combo box items.""" + index = self.findText(text) + while index != -1: + self.removeItem(index) + index = self.findText(text) + self.insertItem(0, text) + index = self.findText('') + if index != -1: + self.removeItem(index) + self.insertItem(0, '') + if text != '': + self.setCurrentIndex(1) + else: + self.setCurrentIndex(0) + else: + self.setCurrentIndex(0) + + def set_current_text(self, text): + """Sets the text of the QLineEdit of the QComboBox.""" + self.lineEdit().setText(to_text_string(text)) + + def add_current_text(self): + """Add current text to combo box history (convenient method)""" + text = self.currentText() + self.add_text(text) + + def add_current_text_if_valid(self): + """Add current text to combo box history if valid""" + valid = self.is_valid(self.currentText()) + if valid or valid is None: + self.add_current_text() + return True + else: + self.set_current_text(self.selected_text) + + def hide_completer(self): + """Hides the completion widget.""" + self.setCompleter(QCompleter([], self)) + + +class PatternComboBox(BaseComboBox): + """Search pattern combo box""" + + def __init__(self, parent, items=None, tip=None, + adjust_to_minimum=True, id_=None): + BaseComboBox.__init__(self, parent) + if hasattr(self.lineEdit(), 'setClearButtonEnabled'): # only Qt >= 5.2 + self.lineEdit().setClearButtonEnabled(True) + if adjust_to_minimum: + self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + if items is not None: + self.addItems(items) + if tip is not None: + self.setToolTip(tip) + if id_ is not None: + self.ID = id_ + + +class EditableComboBox(BaseComboBox): + """ + Editable combo box + Validate + """ + + def __init__(self, parent): + BaseComboBox.__init__(self, parent) + self.font = QFont() + self.selected_text = self.currentText() + + # Widget setup + self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + + # Signals + self.editTextChanged.connect(self.validate) + self.tips = {True: _("Press enter to validate this entry"), + False: _('This entry is incorrect')} + + def show_tip(self, tip=""): + """Show tip""" + QToolTip.showText(self.mapToGlobal(self.pos()), tip, self) + + def selected(self): + """Action to be executed when a valid item has been selected""" + BaseComboBox.selected(self) + self.selected_text = self.currentText() + + def validate(self, qstr, editing=True): + """Validate entered path""" + if self.selected_text == qstr and qstr != '': + self.valid.emit(True, True) + return + + valid = self.is_valid(qstr) + if editing: + if valid: + self.valid.emit(True, False) + else: + self.valid.emit(False, False) + + +class PathComboBox(EditableComboBox): + """ + QComboBox handling path locations + """ + open_dir = Signal(str) + + def __init__(self, parent, adjust_to_contents=False, id_=None): + EditableComboBox.__init__(self, parent) + + # Replace the default lineedit by a custom one with icon display + lineedit = IconLineEdit(self) + + # Widget setup + if adjust_to_contents: + self.setSizeAdjustPolicy(QComboBox.AdjustToContents) + else: + self.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLength) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.tips = {True: _("Press enter to validate this path"), + False: ''} + self.setLineEdit(lineedit) + + # Signals + self.highlighted.connect(self.add_tooltip_to_highlighted_item) + self.sig_tab_pressed.connect(self.tab_complete) + self.valid.connect(lineedit.update_status) + + if id_ is not None: + self.ID = id_ + + # --- Qt overrides + def focusInEvent(self, event): + """Handle focus in event restoring to display the status icon.""" + show_status = getattr(self.lineEdit(), 'show_status_icon', None) + if show_status: + show_status() + QComboBox.focusInEvent(self, event) + + def focusOutEvent(self, event): + """Handle focus out event restoring the last valid selected path.""" + # Calling asynchronously the 'add_current_text' to avoid crash + # https://groups.google.com/group/spyderlib/browse_thread/thread/2257abf530e210bd + if not self.is_valid(): + lineedit = self.lineEdit() + QTimer.singleShot(50, lambda: lineedit.setText(self.selected_text)) + + hide_status = getattr(self.lineEdit(), 'hide_status_icon', None) + if hide_status: + hide_status() + QComboBox.focusOutEvent(self, event) + + # --- Own methods + def _complete_options(self): + """Find available completion options.""" + text = to_text_string(self.currentText()) + opts = glob.glob(text + "*") + opts = sorted([opt for opt in opts if osp.isdir(opt)]) + + completer = QCompleter(opts, self) + qss = str(APP_STYLESHEET) + completer.popup().setStyleSheet(qss) + self.setCompleter(completer) + + return opts + + def tab_complete(self): + """ + If there is a single option available one tab completes the option. + """ + opts = self._complete_options() + if len(opts) == 1: + self.set_current_text(opts[0] + os.sep) + self.hide_completer() + else: + self.completer().complete() + + def is_valid(self, qstr=None): + """Return True if string is valid""" + if qstr is None: + qstr = self.currentText() + return osp.isdir(to_text_string(qstr)) + + def selected(self): + """Action to be executed when a valid item has been selected""" + self.selected_text = self.currentText() + self.valid.emit(True, True) + self.open_dir.emit(self.selected_text) + + def add_current_text(self): + """ + Add current text to combo box history (convenient method). + If path ends in os separator ("\" windows, "/" unix) remove it. + """ + text = self.currentText() + if osp.isdir(text) and text: + if text[-1] == os.sep: + text = text[:-1] + self.add_text(text) + + def add_tooltip_to_highlighted_item(self, index): + """ + Add a tooltip showing the full path of the currently highlighted item + of the PathComboBox. + """ + self.setItemData(index, self.itemText(index), Qt.ToolTipRole) + + +class UrlComboBox(PathComboBox): + """ + QComboBox handling urls + """ + def __init__(self, parent, adjust_to_contents=False, id_=None): + PathComboBox.__init__(self, parent, adjust_to_contents) + line_edit = QLineEdit(self) + self.setLineEdit(line_edit) + self.editTextChanged.disconnect(self.validate) + + if id_ is not None: + self.ID = id_ + + def is_valid(self, qstr=None): + """Return True if string is valid""" + if qstr is None: + qstr = self.currentText() + return QUrl(qstr).isValid() + + +class FileComboBox(PathComboBox): + """ + QComboBox handling File paths + """ + def __init__(self, parent=None, adjust_to_contents=False, + default_line_edit=False): + PathComboBox.__init__(self, parent, adjust_to_contents) + + if default_line_edit: + line_edit = QLineEdit(self) + self.setLineEdit(line_edit) + + # Widget setup + if adjust_to_contents: + self.setSizeAdjustPolicy(QComboBox.AdjustToContents) + else: + self.setSizeAdjustPolicy(QComboBox.AdjustToContentsOnFirstShow) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + def is_valid(self, qstr=None): + """Return True if string is valid.""" + if qstr is None: + qstr = self.currentText() + valid = (osp.isfile(to_text_string(qstr)) or + osp.isdir(to_text_string(qstr))) + return valid + + def tab_complete(self): + """ + If there is a single option available one tab completes the option. + """ + opts = self._complete_options() + if len(opts) == 1: + text = opts[0] + if osp.isdir(text): + text = text + os.sep + self.set_current_text(text) + self.hide_completer() + else: + self.completer().complete() + + def _complete_options(self): + """Find available completion options.""" + text = to_text_string(self.currentText()) + opts = glob.glob(text + "*") + opts = sorted([opt for opt in opts + if osp.isdir(opt) or osp.isfile(opt)]) + + completer = QCompleter(opts, self) + qss = str(APP_STYLESHEET) + completer.popup().setStyleSheet(qss) + self.setCompleter(completer) + + return opts + + +def is_module_or_package(path): + """Return True if path is a Python module/package""" + is_module = osp.isfile(path) and osp.splitext(path)[1] in ('.py', '.pyw') + is_package = osp.isdir(path) and osp.isfile(osp.join(path, '__init__.py')) + return is_module or is_package + + +class PythonModulesComboBox(PathComboBox): + """ + QComboBox handling Python modules or packages path + (i.e. .py, .pyw files *and* directories containing __init__.py) + """ + def __init__(self, parent, adjust_to_contents=False, id_=None): + PathComboBox.__init__(self, parent, adjust_to_contents) + if id_ is not None: + self.ID = id_ + + def is_valid(self, qstr=None): + """Return True if string is valid""" + if qstr is None: + qstr = self.currentText() + return is_module_or_package(to_text_string(qstr)) + + def selected(self): + """Action to be executed when a valid item has been selected""" + EditableComboBox.selected(self) + self.open_dir.emit(self.currentText()) diff --git a/spyder/widgets/dependencies.py b/spyder/widgets/dependencies.py index 188c4b165bd..0b0620aae2b 100644 --- a/spyder/widgets/dependencies.py +++ b/spyder/widgets/dependencies.py @@ -1,156 +1,156 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Module checking Spyder runtime dependencies""" - -# Standard library imports -import sys - -# Third party imports -from qtpy.QtCore import Qt -from qtpy.QtGui import QColor -from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, - QHBoxLayout, QVBoxLayout, QLabel, QPushButton, - QTreeWidget, QTreeWidgetItem) - -# Local imports -from spyder import __version__ -from spyder.config.base import _ -from spyder.dependencies import MANDATORY, OPTIONAL, PLUGIN -from spyder.utils.icon_manager import ima -from spyder.utils.palette import SpyderPalette - - -class DependenciesTreeWidget(QTreeWidget): - - def update_dependencies(self, dependencies): - self.clear() - headers = (_("Module"), _("Package name"), _(" Required "), - _(" Installed "), _("Provided features")) - self.setHeaderLabels(headers) - - # Mandatory items - mandatory_item = QTreeWidgetItem([_("Mandatory")]) - font = mandatory_item.font(0) - font.setBold(True) - mandatory_item.setFont(0, font) - - # Optional items - optional_item = QTreeWidgetItem([_("Optional")]) - optional_item.setFont(0, font) - - # Spyder plugins - spyder_plugins = QTreeWidgetItem([_("Spyder plugins")]) - spyder_plugins.setFont(0, font) - - self.addTopLevelItems([mandatory_item, optional_item, spyder_plugins]) - - for dependency in sorted(dependencies, - key=lambda x: x.modname.lower()): - item = QTreeWidgetItem([dependency.modname, - dependency.package_name, - dependency.required_version, - dependency.installed_version, - dependency.features]) - # Format content - if dependency.check(): - item.setIcon(0, ima.icon('dependency_ok')) - elif dependency.kind == OPTIONAL: - item.setIcon(0, ima.icon('dependency_warning')) - item.setForeground(2, QColor(SpyderPalette.COLOR_WARN_1)) - else: - item.setIcon(0, ima.icon('dependency_error')) - item.setForeground(2, QColor(SpyderPalette.COLOR_ERROR_1)) - - # Add to tree - if dependency.kind == OPTIONAL: - optional_item.addChild(item) - elif dependency.kind == PLUGIN: - spyder_plugins.addChild(item) - else: - mandatory_item.addChild(item) - - self.expandAll() - - def resize_columns_to_contents(self): - for col in range(self.columnCount()): - self.resizeColumnToContents(col) - - -class DependenciesDialog(QDialog): - - def __init__(self, parent): - QDialog.__init__(self, parent) - - # Widgets - self.label = QLabel(_("Optional modules are not required to run " - "Spyder but enhance its functions.")) - self.label2 = QLabel(_("Note: New dependencies or changed ones " - "will be correctly detected only after Spyder " - "is restarted.")) - self.treewidget = DependenciesTreeWidget(self) - btn = QPushButton(_("Copy to clipboard"), ) - bbox = QDialogButtonBox(QDialogButtonBox.Ok) - - # Widget setup - self.setWindowTitle("Spyder %s: %s" % (__version__, - _("Dependencies"))) - self.setWindowIcon(ima.icon('tooloptions')) - self.setModal(False) - - # Layout - hlayout = QHBoxLayout() - hlayout.addWidget(btn) - hlayout.addStretch() - hlayout.addWidget(bbox) - - vlayout = QVBoxLayout() - vlayout.addWidget(self.treewidget) - vlayout.addWidget(self.label) - vlayout.addWidget(self.label2) - vlayout.addLayout(hlayout) - - self.setLayout(vlayout) - self.resize(860, 560) - - # Signals - btn.clicked.connect(self.copy_to_clipboard) - bbox.accepted.connect(self.accept) - - def set_data(self, dependencies): - self.treewidget.update_dependencies(dependencies) - self.treewidget.resize_columns_to_contents() - - def copy_to_clipboard(self): - from spyder.dependencies import status - QApplication.clipboard().setText(status()) - - -def test(): - """Run dependency widget test""" - from spyder import dependencies - - # Test sample - dependencies.add("IPython", "IPython", "Enhanced Python interpreter", - ">=20.0") - dependencies.add("matplotlib", "matplotlib", "Interactive data plotting", - ">=1.0") - dependencies.add("sympy", "sympy", "Symbolic Mathematics", ">=10.0", - kind=OPTIONAL) - dependencies.add("foo", "foo", "Non-existent module", ">=1.0") - dependencies.add("numpy", "numpy", "Edit arrays in Variable Explorer", - ">=0.10", kind=OPTIONAL) - - from spyder.utils.qthelpers import qapplication - app = qapplication() - dlg = DependenciesDialog(None) - dlg.set_data(dependencies.DEPENDENCIES) - dlg.show() - sys.exit(dlg.exec_()) - - -if __name__ == '__main__': - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Module checking Spyder runtime dependencies""" + +# Standard library imports +import sys + +# Third party imports +from qtpy.QtCore import Qt +from qtpy.QtGui import QColor +from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, + QHBoxLayout, QVBoxLayout, QLabel, QPushButton, + QTreeWidget, QTreeWidgetItem) + +# Local imports +from spyder import __version__ +from spyder.config.base import _ +from spyder.dependencies import MANDATORY, OPTIONAL, PLUGIN +from spyder.utils.icon_manager import ima +from spyder.utils.palette import SpyderPalette + + +class DependenciesTreeWidget(QTreeWidget): + + def update_dependencies(self, dependencies): + self.clear() + headers = (_("Module"), _("Package name"), _(" Required "), + _(" Installed "), _("Provided features")) + self.setHeaderLabels(headers) + + # Mandatory items + mandatory_item = QTreeWidgetItem([_("Mandatory")]) + font = mandatory_item.font(0) + font.setBold(True) + mandatory_item.setFont(0, font) + + # Optional items + optional_item = QTreeWidgetItem([_("Optional")]) + optional_item.setFont(0, font) + + # Spyder plugins + spyder_plugins = QTreeWidgetItem([_("Spyder plugins")]) + spyder_plugins.setFont(0, font) + + self.addTopLevelItems([mandatory_item, optional_item, spyder_plugins]) + + for dependency in sorted(dependencies, + key=lambda x: x.modname.lower()): + item = QTreeWidgetItem([dependency.modname, + dependency.package_name, + dependency.required_version, + dependency.installed_version, + dependency.features]) + # Format content + if dependency.check(): + item.setIcon(0, ima.icon('dependency_ok')) + elif dependency.kind == OPTIONAL: + item.setIcon(0, ima.icon('dependency_warning')) + item.setForeground(2, QColor(SpyderPalette.COLOR_WARN_1)) + else: + item.setIcon(0, ima.icon('dependency_error')) + item.setForeground(2, QColor(SpyderPalette.COLOR_ERROR_1)) + + # Add to tree + if dependency.kind == OPTIONAL: + optional_item.addChild(item) + elif dependency.kind == PLUGIN: + spyder_plugins.addChild(item) + else: + mandatory_item.addChild(item) + + self.expandAll() + + def resize_columns_to_contents(self): + for col in range(self.columnCount()): + self.resizeColumnToContents(col) + + +class DependenciesDialog(QDialog): + + def __init__(self, parent): + QDialog.__init__(self, parent) + + # Widgets + self.label = QLabel(_("Optional modules are not required to run " + "Spyder but enhance its functions.")) + self.label2 = QLabel(_("Note: New dependencies or changed ones " + "will be correctly detected only after Spyder " + "is restarted.")) + self.treewidget = DependenciesTreeWidget(self) + btn = QPushButton(_("Copy to clipboard"), ) + bbox = QDialogButtonBox(QDialogButtonBox.Ok) + + # Widget setup + self.setWindowTitle("Spyder %s: %s" % (__version__, + _("Dependencies"))) + self.setWindowIcon(ima.icon('tooloptions')) + self.setModal(False) + + # Layout + hlayout = QHBoxLayout() + hlayout.addWidget(btn) + hlayout.addStretch() + hlayout.addWidget(bbox) + + vlayout = QVBoxLayout() + vlayout.addWidget(self.treewidget) + vlayout.addWidget(self.label) + vlayout.addWidget(self.label2) + vlayout.addLayout(hlayout) + + self.setLayout(vlayout) + self.resize(860, 560) + + # Signals + btn.clicked.connect(self.copy_to_clipboard) + bbox.accepted.connect(self.accept) + + def set_data(self, dependencies): + self.treewidget.update_dependencies(dependencies) + self.treewidget.resize_columns_to_contents() + + def copy_to_clipboard(self): + from spyder.dependencies import status + QApplication.clipboard().setText(status()) + + +def test(): + """Run dependency widget test""" + from spyder import dependencies + + # Test sample + dependencies.add("IPython", "IPython", "Enhanced Python interpreter", + ">=20.0") + dependencies.add("matplotlib", "matplotlib", "Interactive data plotting", + ">=1.0") + dependencies.add("sympy", "sympy", "Symbolic Mathematics", ">=10.0", + kind=OPTIONAL) + dependencies.add("foo", "foo", "Non-existent module", ">=1.0") + dependencies.add("numpy", "numpy", "Edit arrays in Variable Explorer", + ">=0.10", kind=OPTIONAL) + + from spyder.utils.qthelpers import qapplication + app = qapplication() + dlg = DependenciesDialog(None) + dlg.set_data(dependencies.DEPENDENCIES) + dlg.show() + sys.exit(dlg.exec_()) + + +if __name__ == '__main__': + test() diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py index 5ca74010d6d..982119cb771 100644 --- a/spyder/widgets/findreplace.py +++ b/spyder/widgets/findreplace.py @@ -1,660 +1,660 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Find/Replace widget""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import re - -# Third party imports -from qtpy.QtCore import Qt, QTimer, Signal, Slot, QEvent -from qtpy.QtGui import QTextCursor -from qtpy.QtWidgets import (QGridLayout, QHBoxLayout, QLabel, - QSizePolicy, QWidget) - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.misc import regexp_error_msg -from spyder.plugins.editor.utils.editor import TextHelper -from spyder.utils.qthelpers import create_toolbutton -from spyder.utils.sourcecode import get_eol_chars -from spyder.widgets.comboboxes import PatternComboBox - - -def is_position_sup(pos1, pos2): - """Return True is pos1 > pos2""" - return pos1 > pos2 - -def is_position_inf(pos1, pos2): - """Return True is pos1 < pos2""" - return pos1 < pos2 - - -class FindReplace(QWidget): - """Find widget""" - STYLE = {False: "background-color:'#F37E12';", - True: "", - None: "", - 'regexp_error': "background-color:'#E74C3C';", - } - TOOLTIP = {False: _("No matches"), - True: _("Search string"), - None: _("Search string"), - 'regexp_error': _("Regular expression error") - } - visibility_changed = Signal(bool) - return_shift_pressed = Signal() - return_pressed = Signal() - - def __init__(self, parent, enable_replace=False): - QWidget.__init__(self, parent) - self.enable_replace = enable_replace - self.editor = None - self.is_code_editor = None - self.setStyleSheet( - "QComboBox {" - "padding-right: 0px;" - "padding-left: 0px;" - "}") - - glayout = QGridLayout() - glayout.setContentsMargins(0, 0, 0, 0) - self.setLayout(glayout) - - self.close_button = create_toolbutton(self, triggered=self.hide, - icon=ima.icon('DialogCloseButton')) - glayout.addWidget(self.close_button, 0, 0) - - # Find layout - self.search_text = PatternComboBox(self, tip=_("Search string"), - adjust_to_minimum=False) - - self.return_shift_pressed.connect( - lambda: - self.find(changed=False, forward=False, rehighlight=False, - multiline_replace_check = False)) - - self.return_pressed.connect( - lambda: - self.find(changed=False, forward=True, rehighlight=False, - multiline_replace_check = False)) - - self.search_text.lineEdit().textEdited.connect( - self.text_has_been_edited) - - self.number_matches_text = QLabel(self) - self.replace_on = False - self.replace_text_button = create_toolbutton( - self, - toggled=self.change_replace_state, - icon=ima.icon('replace'), - tip=_("Replace text") - ) - self.previous_button = create_toolbutton(self, - triggered=self.find_previous, - icon=ima.icon('findprevious'), - tip=_("Find previous")) - self.next_button = create_toolbutton(self, - triggered=self.find_next, - icon=ima.icon('findnext'), - tip=_("Find next")) - self.next_button.clicked.connect(self.update_search_combo) - self.previous_button.clicked.connect(self.update_search_combo) - - self.re_button = create_toolbutton(self, icon=ima.icon('regex'), - tip=_("Regular expression")) - self.re_button.setCheckable(True) - self.re_button.toggled.connect(lambda state: self.find()) - - self.case_button = create_toolbutton(self, - icon=ima.icon( - "format_letter_case"), - tip=_("Case Sensitive")) - self.case_button.setCheckable(True) - self.case_button.toggled.connect(lambda state: self.find()) - - self.words_button = create_toolbutton(self, - icon=ima.icon("whole_words"), - tip=_("Whole words")) - self.words_button.setCheckable(True) - self.words_button.toggled.connect(lambda state: self.find()) - - hlayout = QHBoxLayout() - self.widgets = [self.close_button, self.search_text, - self.number_matches_text, self.replace_text_button, - self.previous_button, self.next_button, - self.re_button, self.case_button, - self.words_button] - for widget in self.widgets[1:]: - hlayout.addWidget(widget) - glayout.addLayout(hlayout, 0, 1) - - # Replace layout - replace_with = QLabel(_("Replace with:")) - self.replace_text = PatternComboBox(self, adjust_to_minimum=False, - tip=_('Replace string')) - self.replace_text.valid.connect( - lambda _: self.replace_find(focus_replace_text=True)) - self.replace_button = create_toolbutton(self, - text=_('Find next'), - icon=ima.icon('DialogApplyButton'), - triggered=self.replace_find, - text_beside_icon=True) - self.replace_sel_button = create_toolbutton(self, - text=_('In selection'), - icon=ima.icon('DialogApplyButton'), - triggered=self.replace_find_selection, - text_beside_icon=True) - self.replace_sel_button.clicked.connect(self.update_replace_combo) - self.replace_sel_button.clicked.connect(self.update_search_combo) - - self.replace_all_button = create_toolbutton(self, - text=_('All'), - icon=ima.icon('DialogApplyButton'), - triggered=self.replace_find_all, - text_beside_icon=True) - self.replace_all_button.clicked.connect(self.update_replace_combo) - self.replace_all_button.clicked.connect(self.update_search_combo) - - self.replace_layout = QHBoxLayout() - widgets = [replace_with, self.replace_text, self.replace_button, - self.replace_sel_button, self.replace_all_button] - for widget in widgets: - self.replace_layout.addWidget(widget) - glayout.addLayout(self.replace_layout, 1, 1) - self.widgets.extend(widgets) - self.replace_widgets = widgets - self.hide_replace() - - self.search_text.setTabOrder(self.search_text, self.replace_text) - - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - self.shortcuts = self.create_shortcuts(parent) - - self.highlight_timer = QTimer(self) - self.highlight_timer.setSingleShot(True) - self.highlight_timer.setInterval(300) - self.highlight_timer.timeout.connect(self.highlight_matches) - self.search_text.installEventFilter(self) - - def eventFilter(self, widget, event): - """Event filter for search_text widget. - - Emits signals when presing Enter and Shift+Enter. - This signals are used for search forward and backward. - Also, a crude hack to get tab working in the Find/Replace boxes. - """ - - # Type check: Prevent error in PySide where 'event' may be of type - # QtGui.QPainter (for whatever reason). - if not isinstance(event, QEvent): - return True - - if event.type() == QEvent.KeyPress: - key = event.key() - shift = event.modifiers() & Qt.ShiftModifier - - if key == Qt.Key_Return: - if shift: - self.return_shift_pressed.emit() - else: - self.return_pressed.emit() - - if key == Qt.Key_Tab: - if self.search_text.hasFocus(): - self.replace_text.set_current_text( - self.search_text.currentText()) - self.focusNextChild() - - return super(FindReplace, self).eventFilter(widget, event) - - def create_shortcuts(self, parent): - """Create shortcuts for this widget""" - # Configurable - findnext = CONF.config_shortcut( - self.find_next, - context='find_replace', - name='Find next', - parent=parent) - - findprev = CONF.config_shortcut( - self.find_previous, - context='find_replace', - name='Find previous', - parent=parent) - - togglefind = CONF.config_shortcut( - self.show, - context='find_replace', - name='Find text', - parent=parent) - - togglereplace = CONF.config_shortcut( - self.show_replace, - context='find_replace', - name='Replace text', - parent=parent) - - hide = CONF.config_shortcut( - self.hide, - context='find_replace', - name='hide find and replace', - parent=self) - - return [findnext, findprev, togglefind, togglereplace, hide] - - def get_shortcut_data(self): - """ - Returns shortcut data, a list of tuples (shortcut, text, default) - shortcut (QShortcut or QAction instance) - text (string): action/shortcut description - default (string): default key sequence - """ - return [sc.data for sc in self.shortcuts] - - def update_search_combo(self): - self.search_text.lineEdit().returnPressed.emit() - - def update_replace_combo(self): - self.replace_text.lineEdit().returnPressed.emit() - - @Slot(bool) - def toggle_highlighting(self, state): - """Toggle the 'highlight all results' feature""" - if self.editor is not None: - if state: - self.highlight_matches() - else: - self.clear_matches() - - def show(self, hide_replace=True): - """Overrides Qt Method""" - QWidget.show(self) - self.visibility_changed.emit(True) - self.change_number_matches() - if self.editor is not None: - if hide_replace: - if self.replace_widgets[0].isVisible(): - self.hide_replace() - text = self.editor.get_selected_text() - # When selecting several lines, and replace box is activated the - # text won't be replaced for the selection - if hide_replace or len(text.splitlines()) <= 1: - highlighted = True - # If no text is highlighted for search, use whatever word is - # under the cursor - if not text: - highlighted = False - try: - cursor = self.editor.textCursor() - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - except AttributeError: - # We can't do this for all widgets, e.g. WebView's - pass - - # Now that text value is sorted out, use it for the search - if text and not self.search_text.currentText() or highlighted: - self.search_text.setEditText(text) - self.search_text.lineEdit().selectAll() - self.refresh() - else: - self.search_text.lineEdit().selectAll() - self.search_text.setFocus() - - @Slot() - def replace_widget(self, replace_on): - """Show and hide replace widget""" - if replace_on: - self.show_replace() - else: - self.hide_replace() - - def change_replace_state(self): - """Handle the change of the replace state widget.""" - self.replace_on = not self.replace_on - self.replace_text_button.setChecked(self.replace_on) - self.replace_widget(self.replace_on) - - def hide(self): - """Overrides Qt Method""" - for widget in self.replace_widgets: - widget.hide() - QWidget.hide(self) - self.visibility_changed.emit(False) - if self.editor is not None: - self.editor.setFocus() - self.clear_matches() - - def show_replace(self): - """Show replace widgets""" - if self.enable_replace: - self.show(hide_replace=False) - for widget in self.replace_widgets: - widget.show() - - def hide_replace(self): - """Hide replace widgets""" - for widget in self.replace_widgets: - widget.hide() - - def refresh(self): - """Refresh widget""" - if self.isHidden(): - if self.editor is not None: - self.clear_matches() - return - state = self.editor is not None - for widget in self.widgets: - widget.setEnabled(state) - if state: - self.find() - - def set_editor(self, editor, refresh=True): - """ - Set associated editor/web page: - codeeditor.base.TextEditBaseWidget - browser.WebView - """ - self.editor = editor - # Note: This is necessary to test widgets/editor.py - # in Qt builds that don't have web widgets - try: - from qtpy.QtWebEngineWidgets import QWebEngineView - except ImportError: - QWebEngineView = type(None) - self.words_button.setVisible(not isinstance(editor, QWebEngineView)) - self.re_button.setVisible(not isinstance(editor, QWebEngineView)) - from spyder.plugins.editor.widgets.codeeditor import CodeEditor - self.is_code_editor = isinstance(editor, CodeEditor) - if refresh: - self.refresh() - if self.isHidden() and editor is not None: - self.clear_matches() - - @Slot() - def find_next(self, set_focus=True): - """Find next occurrence""" - state = self.find(changed=False, forward=True, rehighlight=False, - multiline_replace_check=False) - if set_focus: - self.editor.setFocus() - self.search_text.add_current_text() - return state - - @Slot() - def find_previous(self, set_focus=True): - """Find previous occurrence""" - state = self.find(changed=False, forward=False, rehighlight=False, - multiline_replace_check=False) - if set_focus: - self.editor.setFocus() - return state - - def text_has_been_edited(self, text): - """Find text has been edited (this slot won't be triggered when - setting the search pattern combo box text programmatically)""" - self.find(changed=True, forward=True, start_highlight_timer=True) - - def highlight_matches(self): - """Highlight found results""" - if self.is_code_editor: - text = self.search_text.currentText() - case = self.case_button.isChecked() - word = self.words_button.isChecked() - regexp = self.re_button.isChecked() - self.editor.highlight_found_results(text, word=word, - regexp=regexp, case=case) - - def clear_matches(self): - """Clear all highlighted matches""" - if self.is_code_editor: - self.editor.clear_found_results() - - def find(self, changed=True, forward=True, rehighlight=True, - start_highlight_timer=False, multiline_replace_check=True): - """Call the find function""" - # When several lines are selected in the editor and replace box is - # activated, dynamic search is deactivated to prevent changing the - # selection. Otherwise we show matching items. - if multiline_replace_check and self.replace_widgets[0].isVisible(): - sel_text = self.editor.get_selected_text() - if len(to_text_string(sel_text).splitlines()) > 1: - return None - text = self.search_text.currentText() - if len(text) == 0: - self.search_text.lineEdit().setStyleSheet("") - if not self.is_code_editor: - # Clears the selection for WebEngine - self.editor.find_text('') - self.change_number_matches() - self.clear_matches() - return None - else: - case = self.case_button.isChecked() - word = self.words_button.isChecked() - regexp = self.re_button.isChecked() - found = self.editor.find_text(text, changed, forward, case=case, - word=word, regexp=regexp) - - stylesheet = self.STYLE[found] - tooltip = self.TOOLTIP[found] - if not found and regexp: - error_msg = regexp_error_msg(text) - if error_msg: # special styling for regexp errors - stylesheet = self.STYLE['regexp_error'] - tooltip = self.TOOLTIP['regexp_error'] + ': ' + error_msg - self.search_text.lineEdit().setStyleSheet(stylesheet) - self.search_text.setToolTip(tooltip) - - if self.is_code_editor and found: - cursor = QTextCursor(self.editor.textCursor()) - TextHelper(self.editor).unfold_if_colapsed(cursor) - - if rehighlight or not self.editor.found_results: - self.highlight_timer.stop() - if start_highlight_timer: - self.highlight_timer.start() - else: - self.highlight_matches() - else: - self.clear_matches() - - number_matches = self.editor.get_number_matches(text, case=case, - regexp=regexp, - word=word) - if hasattr(self.editor, 'get_match_number'): - match_number = self.editor.get_match_number(text, case=case, - regexp=regexp, - word=word) - else: - match_number = 0 - self.change_number_matches(current_match=match_number, - total_matches=number_matches) - return found - - @Slot() - def replace_find(self, focus_replace_text=False): - """Replace and find.""" - if self.editor is None: - return - replace_text = to_text_string(self.replace_text.currentText()) - search_text = to_text_string(self.search_text.currentText()) - re_pattern = None - case = self.case_button.isChecked() - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - - # Check regexp before proceeding - if self.re_button.isChecked(): - try: - re_pattern = re.compile(search_text, flags=re_flags) - # Check if replace_text can be substituted in re_pattern - # Fixes spyder-ide/spyder#7177. - re_pattern.sub(replace_text, '') - except re.error: - # Do nothing with an invalid regexp - return - - # First found - seltxt = to_text_string(self.editor.get_selected_text()) - cmptxt1 = search_text if case else search_text.lower() - cmptxt2 = seltxt if case else seltxt.lower() - do_replace = True - if re_pattern is None: - has_selected = self.editor.has_selected_text() - if not has_selected or cmptxt1 != cmptxt2: - if not self.find(changed=False, forward=True, - rehighlight=False): - do_replace = False - else: - if len(re_pattern.findall(cmptxt2)) <= 0: - if not self.find(changed=False, forward=True, - rehighlight=False): - do_replace = False - cursor = None - if do_replace: - cursor = self.editor.textCursor() - cursor.beginEditBlock() - - if re_pattern is None: - cursor.removeSelectedText() - cursor.insertText(replace_text) - else: - seltxt = to_text_string(cursor.selectedText()) - - # Note: If the selection obtained from an editor spans a line - # break, the text will contain a Unicode U+2029 paragraph - # separator character instead of a newline \n character. - # See: spyder-ide/spyder#2675 - eol_char = get_eol_chars(self.editor.toPlainText()) - seltxt = seltxt.replace(u'\u2029', eol_char) - - cursor.removeSelectedText() - cursor.insertText(re_pattern.sub(replace_text, seltxt)) - - if self.find_next(set_focus=False): - found_cursor = self.editor.textCursor() - cursor.setPosition(found_cursor.selectionStart(), - QTextCursor.MoveAnchor) - cursor.setPosition(found_cursor.selectionEnd(), - QTextCursor.KeepAnchor) - - - if cursor is not None: - cursor.endEditBlock() - - if focus_replace_text: - self.replace_text.setFocus() - else: - self.editor.setFocus() - - if getattr(self.editor, 'document_did_change', False): - self.editor.document_did_change() - - @Slot() - def replace_find_all(self): - """Replace and find all matching occurrences""" - if self.editor is None: - return - replace_text = to_text_string(self.replace_text.currentText()) - search_text = to_text_string(self.search_text.currentText()) - re_pattern = None - case = self.case_button.isChecked() - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - re_enabled = self.re_button.isChecked() - # Check regexp before proceeding - if re_enabled: - try: - re_pattern = re.compile(search_text, flags=re_flags) - # Check if replace_text can be substituted in re_pattern - # Fixes spyder-ide/spyder#7177. - re_pattern.sub(replace_text, '') - except re.error: - # Do nothing with an invalid regexp - return - else: - re_pattern = re.compile(re.escape(search_text), flags=re_flags) - - cursor = self.editor._select_text("sof", "eof") - text = self.editor.toPlainText() - cursor.beginEditBlock() - cursor.removeSelectedText() - cursor.insertText(re_pattern.sub(replace_text, text)) - cursor.endEditBlock() - - self.editor.setFocus() - - @Slot() - def replace_find_selection(self, focus_replace_text=False): - """Replace and find in the current selection""" - if self.editor is not None: - replace_text = to_text_string(self.replace_text.currentText()) - search_text = to_text_string(self.search_text.currentText()) - case = self.case_button.isChecked() - word = self.words_button.isChecked() - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - - re_pattern = None - if self.re_button.isChecked(): - pattern = search_text - else: - pattern = re.escape(search_text) - replace_text = replace_text.replace('\\', r'\\') - if word: # match whole words only - pattern = r'\b{pattern}\b'.format(pattern=pattern) - - # Check regexp before proceeding - try: - re_pattern = re.compile(pattern, flags=re_flags) - # Check if replace_text can be substituted in re_pattern - # Fixes spyder-ide/spyder#7177. - re_pattern.sub(replace_text, '') - except re.error as e: - # Do nothing with an invalid regexp - return - - selected_text = to_text_string(self.editor.get_selected_text()) - replacement = re_pattern.sub(replace_text, selected_text) - if replacement != selected_text: - cursor = self.editor.textCursor() - start_pos = cursor.selectionStart() - cursor.beginEditBlock() - cursor.removeSelectedText() - cursor.insertText(replacement) - # Restore selection - self.editor.set_cursor_position(start_pos) - for c in range(len(replacement)): - self.editor.extend_selection_to_next('character', 'right') - cursor.endEditBlock() - - if focus_replace_text: - self.replace_text.setFocus() - else: - self.editor.setFocus() - - if getattr(self.editor, 'document_did_change', False): - self.editor.document_did_change() - - def change_number_matches(self, current_match=0, total_matches=0): - """Change number of match and total matches.""" - if current_match and total_matches: - matches_string = u"{} {} {}".format(current_match, _(u"of"), - total_matches) - self.number_matches_text.setText(matches_string) - elif total_matches: - matches_string = u"{} {}".format(total_matches, _(u"matches")) - self.number_matches_text.setText(matches_string) - else: - self.number_matches_text.setText(_(u"no matches")) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Find/Replace widget""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import re + +# Third party imports +from qtpy.QtCore import Qt, QTimer, Signal, Slot, QEvent +from qtpy.QtGui import QTextCursor +from qtpy.QtWidgets import (QGridLayout, QHBoxLayout, QLabel, + QSizePolicy, QWidget) + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.misc import regexp_error_msg +from spyder.plugins.editor.utils.editor import TextHelper +from spyder.utils.qthelpers import create_toolbutton +from spyder.utils.sourcecode import get_eol_chars +from spyder.widgets.comboboxes import PatternComboBox + + +def is_position_sup(pos1, pos2): + """Return True is pos1 > pos2""" + return pos1 > pos2 + +def is_position_inf(pos1, pos2): + """Return True is pos1 < pos2""" + return pos1 < pos2 + + +class FindReplace(QWidget): + """Find widget""" + STYLE = {False: "background-color:'#F37E12';", + True: "", + None: "", + 'regexp_error': "background-color:'#E74C3C';", + } + TOOLTIP = {False: _("No matches"), + True: _("Search string"), + None: _("Search string"), + 'regexp_error': _("Regular expression error") + } + visibility_changed = Signal(bool) + return_shift_pressed = Signal() + return_pressed = Signal() + + def __init__(self, parent, enable_replace=False): + QWidget.__init__(self, parent) + self.enable_replace = enable_replace + self.editor = None + self.is_code_editor = None + self.setStyleSheet( + "QComboBox {" + "padding-right: 0px;" + "padding-left: 0px;" + "}") + + glayout = QGridLayout() + glayout.setContentsMargins(0, 0, 0, 0) + self.setLayout(glayout) + + self.close_button = create_toolbutton(self, triggered=self.hide, + icon=ima.icon('DialogCloseButton')) + glayout.addWidget(self.close_button, 0, 0) + + # Find layout + self.search_text = PatternComboBox(self, tip=_("Search string"), + adjust_to_minimum=False) + + self.return_shift_pressed.connect( + lambda: + self.find(changed=False, forward=False, rehighlight=False, + multiline_replace_check = False)) + + self.return_pressed.connect( + lambda: + self.find(changed=False, forward=True, rehighlight=False, + multiline_replace_check = False)) + + self.search_text.lineEdit().textEdited.connect( + self.text_has_been_edited) + + self.number_matches_text = QLabel(self) + self.replace_on = False + self.replace_text_button = create_toolbutton( + self, + toggled=self.change_replace_state, + icon=ima.icon('replace'), + tip=_("Replace text") + ) + self.previous_button = create_toolbutton(self, + triggered=self.find_previous, + icon=ima.icon('findprevious'), + tip=_("Find previous")) + self.next_button = create_toolbutton(self, + triggered=self.find_next, + icon=ima.icon('findnext'), + tip=_("Find next")) + self.next_button.clicked.connect(self.update_search_combo) + self.previous_button.clicked.connect(self.update_search_combo) + + self.re_button = create_toolbutton(self, icon=ima.icon('regex'), + tip=_("Regular expression")) + self.re_button.setCheckable(True) + self.re_button.toggled.connect(lambda state: self.find()) + + self.case_button = create_toolbutton(self, + icon=ima.icon( + "format_letter_case"), + tip=_("Case Sensitive")) + self.case_button.setCheckable(True) + self.case_button.toggled.connect(lambda state: self.find()) + + self.words_button = create_toolbutton(self, + icon=ima.icon("whole_words"), + tip=_("Whole words")) + self.words_button.setCheckable(True) + self.words_button.toggled.connect(lambda state: self.find()) + + hlayout = QHBoxLayout() + self.widgets = [self.close_button, self.search_text, + self.number_matches_text, self.replace_text_button, + self.previous_button, self.next_button, + self.re_button, self.case_button, + self.words_button] + for widget in self.widgets[1:]: + hlayout.addWidget(widget) + glayout.addLayout(hlayout, 0, 1) + + # Replace layout + replace_with = QLabel(_("Replace with:")) + self.replace_text = PatternComboBox(self, adjust_to_minimum=False, + tip=_('Replace string')) + self.replace_text.valid.connect( + lambda _: self.replace_find(focus_replace_text=True)) + self.replace_button = create_toolbutton(self, + text=_('Find next'), + icon=ima.icon('DialogApplyButton'), + triggered=self.replace_find, + text_beside_icon=True) + self.replace_sel_button = create_toolbutton(self, + text=_('In selection'), + icon=ima.icon('DialogApplyButton'), + triggered=self.replace_find_selection, + text_beside_icon=True) + self.replace_sel_button.clicked.connect(self.update_replace_combo) + self.replace_sel_button.clicked.connect(self.update_search_combo) + + self.replace_all_button = create_toolbutton(self, + text=_('All'), + icon=ima.icon('DialogApplyButton'), + triggered=self.replace_find_all, + text_beside_icon=True) + self.replace_all_button.clicked.connect(self.update_replace_combo) + self.replace_all_button.clicked.connect(self.update_search_combo) + + self.replace_layout = QHBoxLayout() + widgets = [replace_with, self.replace_text, self.replace_button, + self.replace_sel_button, self.replace_all_button] + for widget in widgets: + self.replace_layout.addWidget(widget) + glayout.addLayout(self.replace_layout, 1, 1) + self.widgets.extend(widgets) + self.replace_widgets = widgets + self.hide_replace() + + self.search_text.setTabOrder(self.search_text, self.replace_text) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + self.shortcuts = self.create_shortcuts(parent) + + self.highlight_timer = QTimer(self) + self.highlight_timer.setSingleShot(True) + self.highlight_timer.setInterval(300) + self.highlight_timer.timeout.connect(self.highlight_matches) + self.search_text.installEventFilter(self) + + def eventFilter(self, widget, event): + """Event filter for search_text widget. + + Emits signals when presing Enter and Shift+Enter. + This signals are used for search forward and backward. + Also, a crude hack to get tab working in the Find/Replace boxes. + """ + + # Type check: Prevent error in PySide where 'event' may be of type + # QtGui.QPainter (for whatever reason). + if not isinstance(event, QEvent): + return True + + if event.type() == QEvent.KeyPress: + key = event.key() + shift = event.modifiers() & Qt.ShiftModifier + + if key == Qt.Key_Return: + if shift: + self.return_shift_pressed.emit() + else: + self.return_pressed.emit() + + if key == Qt.Key_Tab: + if self.search_text.hasFocus(): + self.replace_text.set_current_text( + self.search_text.currentText()) + self.focusNextChild() + + return super(FindReplace, self).eventFilter(widget, event) + + def create_shortcuts(self, parent): + """Create shortcuts for this widget""" + # Configurable + findnext = CONF.config_shortcut( + self.find_next, + context='find_replace', + name='Find next', + parent=parent) + + findprev = CONF.config_shortcut( + self.find_previous, + context='find_replace', + name='Find previous', + parent=parent) + + togglefind = CONF.config_shortcut( + self.show, + context='find_replace', + name='Find text', + parent=parent) + + togglereplace = CONF.config_shortcut( + self.show_replace, + context='find_replace', + name='Replace text', + parent=parent) + + hide = CONF.config_shortcut( + self.hide, + context='find_replace', + name='hide find and replace', + parent=self) + + return [findnext, findprev, togglefind, togglereplace, hide] + + def get_shortcut_data(self): + """ + Returns shortcut data, a list of tuples (shortcut, text, default) + shortcut (QShortcut or QAction instance) + text (string): action/shortcut description + default (string): default key sequence + """ + return [sc.data for sc in self.shortcuts] + + def update_search_combo(self): + self.search_text.lineEdit().returnPressed.emit() + + def update_replace_combo(self): + self.replace_text.lineEdit().returnPressed.emit() + + @Slot(bool) + def toggle_highlighting(self, state): + """Toggle the 'highlight all results' feature""" + if self.editor is not None: + if state: + self.highlight_matches() + else: + self.clear_matches() + + def show(self, hide_replace=True): + """Overrides Qt Method""" + QWidget.show(self) + self.visibility_changed.emit(True) + self.change_number_matches() + if self.editor is not None: + if hide_replace: + if self.replace_widgets[0].isVisible(): + self.hide_replace() + text = self.editor.get_selected_text() + # When selecting several lines, and replace box is activated the + # text won't be replaced for the selection + if hide_replace or len(text.splitlines()) <= 1: + highlighted = True + # If no text is highlighted for search, use whatever word is + # under the cursor + if not text: + highlighted = False + try: + cursor = self.editor.textCursor() + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + except AttributeError: + # We can't do this for all widgets, e.g. WebView's + pass + + # Now that text value is sorted out, use it for the search + if text and not self.search_text.currentText() or highlighted: + self.search_text.setEditText(text) + self.search_text.lineEdit().selectAll() + self.refresh() + else: + self.search_text.lineEdit().selectAll() + self.search_text.setFocus() + + @Slot() + def replace_widget(self, replace_on): + """Show and hide replace widget""" + if replace_on: + self.show_replace() + else: + self.hide_replace() + + def change_replace_state(self): + """Handle the change of the replace state widget.""" + self.replace_on = not self.replace_on + self.replace_text_button.setChecked(self.replace_on) + self.replace_widget(self.replace_on) + + def hide(self): + """Overrides Qt Method""" + for widget in self.replace_widgets: + widget.hide() + QWidget.hide(self) + self.visibility_changed.emit(False) + if self.editor is not None: + self.editor.setFocus() + self.clear_matches() + + def show_replace(self): + """Show replace widgets""" + if self.enable_replace: + self.show(hide_replace=False) + for widget in self.replace_widgets: + widget.show() + + def hide_replace(self): + """Hide replace widgets""" + for widget in self.replace_widgets: + widget.hide() + + def refresh(self): + """Refresh widget""" + if self.isHidden(): + if self.editor is not None: + self.clear_matches() + return + state = self.editor is not None + for widget in self.widgets: + widget.setEnabled(state) + if state: + self.find() + + def set_editor(self, editor, refresh=True): + """ + Set associated editor/web page: + codeeditor.base.TextEditBaseWidget + browser.WebView + """ + self.editor = editor + # Note: This is necessary to test widgets/editor.py + # in Qt builds that don't have web widgets + try: + from qtpy.QtWebEngineWidgets import QWebEngineView + except ImportError: + QWebEngineView = type(None) + self.words_button.setVisible(not isinstance(editor, QWebEngineView)) + self.re_button.setVisible(not isinstance(editor, QWebEngineView)) + from spyder.plugins.editor.widgets.codeeditor import CodeEditor + self.is_code_editor = isinstance(editor, CodeEditor) + if refresh: + self.refresh() + if self.isHidden() and editor is not None: + self.clear_matches() + + @Slot() + def find_next(self, set_focus=True): + """Find next occurrence""" + state = self.find(changed=False, forward=True, rehighlight=False, + multiline_replace_check=False) + if set_focus: + self.editor.setFocus() + self.search_text.add_current_text() + return state + + @Slot() + def find_previous(self, set_focus=True): + """Find previous occurrence""" + state = self.find(changed=False, forward=False, rehighlight=False, + multiline_replace_check=False) + if set_focus: + self.editor.setFocus() + return state + + def text_has_been_edited(self, text): + """Find text has been edited (this slot won't be triggered when + setting the search pattern combo box text programmatically)""" + self.find(changed=True, forward=True, start_highlight_timer=True) + + def highlight_matches(self): + """Highlight found results""" + if self.is_code_editor: + text = self.search_text.currentText() + case = self.case_button.isChecked() + word = self.words_button.isChecked() + regexp = self.re_button.isChecked() + self.editor.highlight_found_results(text, word=word, + regexp=regexp, case=case) + + def clear_matches(self): + """Clear all highlighted matches""" + if self.is_code_editor: + self.editor.clear_found_results() + + def find(self, changed=True, forward=True, rehighlight=True, + start_highlight_timer=False, multiline_replace_check=True): + """Call the find function""" + # When several lines are selected in the editor and replace box is + # activated, dynamic search is deactivated to prevent changing the + # selection. Otherwise we show matching items. + if multiline_replace_check and self.replace_widgets[0].isVisible(): + sel_text = self.editor.get_selected_text() + if len(to_text_string(sel_text).splitlines()) > 1: + return None + text = self.search_text.currentText() + if len(text) == 0: + self.search_text.lineEdit().setStyleSheet("") + if not self.is_code_editor: + # Clears the selection for WebEngine + self.editor.find_text('') + self.change_number_matches() + self.clear_matches() + return None + else: + case = self.case_button.isChecked() + word = self.words_button.isChecked() + regexp = self.re_button.isChecked() + found = self.editor.find_text(text, changed, forward, case=case, + word=word, regexp=regexp) + + stylesheet = self.STYLE[found] + tooltip = self.TOOLTIP[found] + if not found and regexp: + error_msg = regexp_error_msg(text) + if error_msg: # special styling for regexp errors + stylesheet = self.STYLE['regexp_error'] + tooltip = self.TOOLTIP['regexp_error'] + ': ' + error_msg + self.search_text.lineEdit().setStyleSheet(stylesheet) + self.search_text.setToolTip(tooltip) + + if self.is_code_editor and found: + cursor = QTextCursor(self.editor.textCursor()) + TextHelper(self.editor).unfold_if_colapsed(cursor) + + if rehighlight or not self.editor.found_results: + self.highlight_timer.stop() + if start_highlight_timer: + self.highlight_timer.start() + else: + self.highlight_matches() + else: + self.clear_matches() + + number_matches = self.editor.get_number_matches(text, case=case, + regexp=regexp, + word=word) + if hasattr(self.editor, 'get_match_number'): + match_number = self.editor.get_match_number(text, case=case, + regexp=regexp, + word=word) + else: + match_number = 0 + self.change_number_matches(current_match=match_number, + total_matches=number_matches) + return found + + @Slot() + def replace_find(self, focus_replace_text=False): + """Replace and find.""" + if self.editor is None: + return + replace_text = to_text_string(self.replace_text.currentText()) + search_text = to_text_string(self.search_text.currentText()) + re_pattern = None + case = self.case_button.isChecked() + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + + # Check regexp before proceeding + if self.re_button.isChecked(): + try: + re_pattern = re.compile(search_text, flags=re_flags) + # Check if replace_text can be substituted in re_pattern + # Fixes spyder-ide/spyder#7177. + re_pattern.sub(replace_text, '') + except re.error: + # Do nothing with an invalid regexp + return + + # First found + seltxt = to_text_string(self.editor.get_selected_text()) + cmptxt1 = search_text if case else search_text.lower() + cmptxt2 = seltxt if case else seltxt.lower() + do_replace = True + if re_pattern is None: + has_selected = self.editor.has_selected_text() + if not has_selected or cmptxt1 != cmptxt2: + if not self.find(changed=False, forward=True, + rehighlight=False): + do_replace = False + else: + if len(re_pattern.findall(cmptxt2)) <= 0: + if not self.find(changed=False, forward=True, + rehighlight=False): + do_replace = False + cursor = None + if do_replace: + cursor = self.editor.textCursor() + cursor.beginEditBlock() + + if re_pattern is None: + cursor.removeSelectedText() + cursor.insertText(replace_text) + else: + seltxt = to_text_string(cursor.selectedText()) + + # Note: If the selection obtained from an editor spans a line + # break, the text will contain a Unicode U+2029 paragraph + # separator character instead of a newline \n character. + # See: spyder-ide/spyder#2675 + eol_char = get_eol_chars(self.editor.toPlainText()) + seltxt = seltxt.replace(u'\u2029', eol_char) + + cursor.removeSelectedText() + cursor.insertText(re_pattern.sub(replace_text, seltxt)) + + if self.find_next(set_focus=False): + found_cursor = self.editor.textCursor() + cursor.setPosition(found_cursor.selectionStart(), + QTextCursor.MoveAnchor) + cursor.setPosition(found_cursor.selectionEnd(), + QTextCursor.KeepAnchor) + + + if cursor is not None: + cursor.endEditBlock() + + if focus_replace_text: + self.replace_text.setFocus() + else: + self.editor.setFocus() + + if getattr(self.editor, 'document_did_change', False): + self.editor.document_did_change() + + @Slot() + def replace_find_all(self): + """Replace and find all matching occurrences""" + if self.editor is None: + return + replace_text = to_text_string(self.replace_text.currentText()) + search_text = to_text_string(self.search_text.currentText()) + re_pattern = None + case = self.case_button.isChecked() + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + re_enabled = self.re_button.isChecked() + # Check regexp before proceeding + if re_enabled: + try: + re_pattern = re.compile(search_text, flags=re_flags) + # Check if replace_text can be substituted in re_pattern + # Fixes spyder-ide/spyder#7177. + re_pattern.sub(replace_text, '') + except re.error: + # Do nothing with an invalid regexp + return + else: + re_pattern = re.compile(re.escape(search_text), flags=re_flags) + + cursor = self.editor._select_text("sof", "eof") + text = self.editor.toPlainText() + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.insertText(re_pattern.sub(replace_text, text)) + cursor.endEditBlock() + + self.editor.setFocus() + + @Slot() + def replace_find_selection(self, focus_replace_text=False): + """Replace and find in the current selection""" + if self.editor is not None: + replace_text = to_text_string(self.replace_text.currentText()) + search_text = to_text_string(self.search_text.currentText()) + case = self.case_button.isChecked() + word = self.words_button.isChecked() + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + + re_pattern = None + if self.re_button.isChecked(): + pattern = search_text + else: + pattern = re.escape(search_text) + replace_text = replace_text.replace('\\', r'\\') + if word: # match whole words only + pattern = r'\b{pattern}\b'.format(pattern=pattern) + + # Check regexp before proceeding + try: + re_pattern = re.compile(pattern, flags=re_flags) + # Check if replace_text can be substituted in re_pattern + # Fixes spyder-ide/spyder#7177. + re_pattern.sub(replace_text, '') + except re.error as e: + # Do nothing with an invalid regexp + return + + selected_text = to_text_string(self.editor.get_selected_text()) + replacement = re_pattern.sub(replace_text, selected_text) + if replacement != selected_text: + cursor = self.editor.textCursor() + start_pos = cursor.selectionStart() + cursor.beginEditBlock() + cursor.removeSelectedText() + cursor.insertText(replacement) + # Restore selection + self.editor.set_cursor_position(start_pos) + for c in range(len(replacement)): + self.editor.extend_selection_to_next('character', 'right') + cursor.endEditBlock() + + if focus_replace_text: + self.replace_text.setFocus() + else: + self.editor.setFocus() + + if getattr(self.editor, 'document_did_change', False): + self.editor.document_did_change() + + def change_number_matches(self, current_match=0, total_matches=0): + """Change number of match and total matches.""" + if current_match and total_matches: + matches_string = u"{} {} {}".format(current_match, _(u"of"), + total_matches) + self.number_matches_text.setText(matches_string) + elif total_matches: + matches_string = u"{} {}".format(total_matches, _(u"matches")) + self.number_matches_text.setText(matches_string) + else: + self.number_matches_text.setText(_(u"no matches")) diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py index d1013e2a4d5..783c71638d4 100644 --- a/spyder/widgets/mixins.py +++ b/spyder/widgets/mixins.py @@ -1,1646 +1,1646 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Mix-in classes - -These classes were created to be able to provide Spyder's regular text and -console widget features to an independent widget based on QTextEdit for the -IPython console plugin. -""" - -# Standard library imports -from __future__ import print_function -import os -import os.path as osp -import re -import sre_constants -import sys -import textwrap -from pkg_resources import parse_version - -# Third party imports -from qtpy import QT_VERSION -from qtpy.QtCore import QPoint, QRegularExpression, Qt -from qtpy.QtGui import QCursor, QTextCursor, QTextDocument -from qtpy.QtWidgets import QApplication -from spyder_kernels.utils.dochelpers import (getargspecfromtext, getobj, - getsignaturefromtext) - -# Local imports -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string -from spyder.utils import encoding, sourcecode -from spyder.utils import syntaxhighlighters as sh -from spyder.utils.misc import get_error_match -from spyder.utils.palette import QStylePalette -from spyder.widgets.arraybuilder import ArrayBuilderDialog - - -# List of possible EOL symbols -EOL_SYMBOLS = [ - # Put first as it correspond to a single line return - "\r\n", # Carriage Return + Line Feed - "\r", # Carriage Return - "\n", # Line Feed - "\v", # Line Tabulation - "\x0b", # Line Tabulation - "\f", # Form Feed - "\x0c", # Form Feed - "\x1c", # File Separator - "\x1d", # Group Separator - "\x1e", # Record Separator - "\x85", # Next Line (C1 Control Code) - "\u2028", # Line Separator - "\u2029", # Paragraph Separator -] - - -class BaseEditMixin(object): - - _PARAMETER_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 - _DEFAULT_TITLE_COLOR = QStylePalette.COLOR_ACCENT_4 - _CHAR_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 - _DEFAULT_TEXT_COLOR = QStylePalette.COLOR_TEXT_2 - _DEFAULT_LANGUAGE = 'python' - _DEFAULT_MAX_LINES = 10 - _DEFAULT_MAX_WIDTH = 60 - _DEFAULT_COMPLETION_HINT_MAX_WIDTH = 52 - _DEFAULT_MAX_HINT_LINES = 20 - _DEFAULT_MAX_HINT_WIDTH = 85 - - # The following signals are used to indicate text changes on the editor. - sig_will_insert_text = None - sig_will_remove_selection = None - sig_text_was_inserted = None - - _styled_widgets = set() - - def __init__(self): - self.eol_chars = None - self.calltip_size = 600 - - #------Line number area - def get_linenumberarea_width(self): - """Return line number area width""" - # Implemented in CodeEditor, but needed for calltip/completion widgets - return 0 - - def calculate_real_position(self, point): - """ - Add offset to a point, to take into account the Editor panels. - - This is reimplemented in CodeEditor, in other widgets it returns - the same point. - """ - return point - - # --- Tooltips and Calltips - def _calculate_position(self, at_line=None, at_point=None): - """ - Calculate a global point position `QPoint(x, y)`, for a given - line, local cursor position, or local point. - """ - font = self.font() - - if at_point is not None: - # Showing tooltip at point position - margin = (self.document().documentMargin() / 2) + 1 - cx = int(at_point.x() - margin) - cy = int(at_point.y() - margin) - elif at_line is not None: - # Showing tooltip at line - cx = 5 - line = at_line - 1 - cursor = QTextCursor(self.document().findBlockByNumber(line)) - cy = int(self.cursorRect(cursor).top()) - else: - # Showing tooltip at cursor position - cx, cy = self.get_coordinates('cursor') - cx = int(cx) - cy = int(cy - font.pointSize() / 2) - - # Calculate vertical delta - # The needed delta changes with font size, so we use a power law - if sys.platform == 'darwin': - delta = int((font.pointSize() * 1.20) ** 0.98 + 4.5) - elif os.name == 'nt': - delta = int((font.pointSize() * 1.20) ** 1.05) + 7 - else: - delta = int((font.pointSize() * 1.20) ** 0.98) + 7 - # delta = font.pointSize() + 5 - - # Map to global coordinates - point = self.mapToGlobal(QPoint(cx, cy)) - point = self.calculate_real_position(point) - point.setY(point.y() + delta) - - return point - - def _update_stylesheet(self, widget): - """Update the background stylesheet to make it lighter.""" - # Update the stylesheet for a given widget at most once - # because Qt is slow to repeatedly parse & apply CSS - if id(widget) in self._styled_widgets: - return - self._styled_widgets.add(id(widget)) - background = QStylePalette.COLOR_BACKGROUND_4 - border = QStylePalette.COLOR_TEXT_4 - name = widget.__class__.__name__ - widget.setObjectName(name) - css = ''' - {0}#{0} {{ - background-color:{1}; - border: 1px solid {2}; - }}'''.format(name, background, border) - widget.setStyleSheet(css) - - def _get_inspect_shortcut(self): - """ - Queries the editor's config to get the current "Inspect" shortcut. - """ - value = CONF.get('shortcuts', 'editor/inspect current object') - if value: - if sys.platform == "darwin": - value = value.replace('Ctrl', 'Cmd') - return value - - def _format_text(self, title=None, signature=None, text=None, - inspect_word=None, title_color=None, max_lines=None, - max_width=_DEFAULT_MAX_WIDTH, display_link=False, - text_new_line=False, with_html_format=False): - """ - Create HTML template for calltips and tooltips. - - This will display title and text as separate sections and add `...` - - ---------------------------------------- - | `title` (with `title_color`) | - ---------------------------------------- - | `signature` | - | | - | `text` (ellided to `max_lines`) | - | | - ---------------------------------------- - | Link or shortcut with `inspect_word` | - ---------------------------------------- - """ - BASE_TEMPLATE = u''' -
    - {main_text} -
    - ''' - # Get current font properties - font = self.font() - font_family = font.family() - title_size = font.pointSize() - text_size = title_size - 1 if title_size > 9 else title_size - text_color = self._DEFAULT_TEXT_COLOR - - template = '' - if title: - template += BASE_TEMPLATE.format( - font_family=font_family, - size=title_size, - color=title_color, - main_text=title, - ) - - if text or signature: - template += '
    ' - - if signature: - signature = signature.strip('\r\n') - template += BASE_TEMPLATE.format( - font_family=font_family, - size=text_size, - color=text_color, - main_text=signature, - ) - - # Documentation/text handling - if (text is None or not text.strip() or - text.strip() == ''): - text = 'No documentation available' - else: - text = text.strip() - - if not with_html_format: - # All these replacements are need to properly divide the - # text in actual paragraphs and wrap the text on each one - paragraphs = (text - .replace(u"\xa0", u" ") - .replace("\n\n", "") - .replace(".\n", ".") - .replace("\n-", "-") - .replace("-\n", "-") - .replace("\n=", "=") - .replace("=\n", "=") - .replace("\n*", "*") - .replace("*\n", "*") - .replace("\n ", " ") - .replace(" \n", " ") - .replace("\n", " ") - .replace("", "\n\n") - .replace("", "\n").splitlines()) - new_paragraphs = [] - for paragraph in paragraphs: - # Wrap text - new_paragraph = textwrap.wrap(paragraph, width=max_width) - - # Remove empty lines at the beginning - new_paragraph = [l for l in new_paragraph if l.strip()] - - # Merge paragraph text - new_paragraph = '\n'.join(new_paragraph) - - # Add new paragraph - new_paragraphs.append(new_paragraph) - - # Join paragraphs and split in lines for max_lines check - paragraphs = '\n'.join(new_paragraphs) - paragraphs = paragraphs.strip('\r\n') - lines = paragraphs.splitlines() - - # Check that the first line is not empty - if len(lines) > 0 and not lines[0].strip(): - lines = lines[1:] - else: - lines = [l for l in text.split('\n') if l.strip()] - - # Limit max number of text displayed - if max_lines: - if len(lines) > max_lines: - text = '\n'.join(lines[:max_lines]) + ' ...' - else: - text = '\n'.join(lines) - - text = text.replace('\n', '
    ') - if text_new_line and signature: - text = '
    ' + text - - template += BASE_TEMPLATE.format( - font_family=font_family, - size=text_size, - color=text_color, - main_text=text, - ) - - help_text = '' - if inspect_word: - if display_link: - help_text = ( - '' - 'Click anywhere in this tooltip for additional help' - ''.format( - font_size=text_size, - font_family=font_family, - ) - ) - else: - shortcut = self._get_inspect_shortcut() - if shortcut: - base_style = ( - f'background-color:{QStylePalette.COLOR_BACKGROUND_4};' - f'color:{QStylePalette.COLOR_TEXT_1};' - 'font-size:11px;' - ) - help_text = '' - # ( - # 'Press ' - # '[' - # '' - # '{0}] for aditional ' - # 'help'.format(shortcut, base_style) - # ) - - if help_text and inspect_word: - if display_link: - template += ( - '
    ' - '
    ' - f'' - ''.format(font_family=font_family, - size=text_size) - ) + help_text + '
    ' - else: - template += ( - '
    ' - '
    ' - '' - '' + help_text + '
    ' - ) - - return template - - def _format_signature(self, signatures, parameter=None, - max_width=_DEFAULT_MAX_WIDTH, - parameter_color=_PARAMETER_HIGHLIGHT_COLOR, - char_color=_CHAR_HIGHLIGHT_COLOR, - language=_DEFAULT_LANGUAGE): - """ - Create HTML template for signature. - - This template will include indent after the method name, a highlight - color for the active parameter and highlights for special chars. - - Special chars depend on the language. - """ - language = getattr(self, 'language', language).lower() - active_parameter_template = ( - '' - '{parameter}' - '' - ) - chars_template = ( - '{char}' - '' - ) - - def handle_sub(matchobj): - """ - Handle substitution of active parameter template. - - This ensures the correct highlight of the active parameter. - """ - match = matchobj.group(0) - new = match.replace(parameter, active_parameter_template) - return new - - if not isinstance(signatures, list): - signatures = [signatures] - - new_signatures = [] - for signature in signatures: - # Remove duplicate spaces - signature = ' '.join(signature.split()) - - # Replace initial spaces - signature = signature.replace('( ', '(') - - # Process signature template - if parameter and language == 'python': - # Escape all possible regex characters - # ( ) { } | [ ] . ^ $ * + - escape_regex_chars = ['|', '.', '^', '$', '*', '+'] - remove_regex_chars = ['(', ')', '{', '}', '[', ']'] - regex_parameter = parameter - for regex_char in escape_regex_chars + remove_regex_chars: - if regex_char in escape_regex_chars: - escape_char = r'\{char}'.format(char=regex_char) - regex_parameter = regex_parameter.replace(regex_char, - escape_char) - else: - regex_parameter = regex_parameter.replace(regex_char, - '') - parameter = parameter.replace(regex_char, '') - - pattern = (r'[\*|\(|\[|\s](' + regex_parameter + - r')[,|\)|\]|\s|=]') - - formatted_lines = [] - name = signature.split('(')[0] - indent = ' ' * (len(name) + 1) - rows = textwrap.wrap(signature, width=max_width, - subsequent_indent=indent) - for row in rows: - if parameter and language == 'python': - # Add template to highlight the active parameter - row = re.sub(pattern, handle_sub, row) - - row = row.replace(' ', ' ') - row = row.replace('span ', 'span ') - row = row.replace('{}', '{{}}') - - if language and language == 'python': - for char in ['(', ')', ',', '*', '**']: - new_char = chars_template.format(char=char) - row = row.replace(char, new_char) - - formatted_lines.append(row) - title_template = '
    '.join(formatted_lines) - - # Get current font properties - font = self.font() - font_size = font.pointSize() - font_family = font.family() - - # Format title to display active parameter - if parameter and language == 'python': - title = title_template.format( - font_size=font_size, - font_family=font_family, - color=parameter_color, - parameter=parameter, - ) - else: - title = title_template - new_signatures.append(title) - - return '
    '.join(new_signatures) - - def _check_signature_and_format(self, signature_or_text, parameter=None, - inspect_word=None, - max_width=_DEFAULT_MAX_WIDTH, - language=_DEFAULT_LANGUAGE): - """ - LSP hints might provide docstrings instead of signatures. - - This method will check for multiple signatures (dict, type etc...) and - format the text accordingly. - """ - open_func_char = '' - has_signature = False - has_multisignature = False - language = getattr(self, 'language', language).lower() - signature_or_text = signature_or_text.replace('\\*', '*') - - # Remove special symbols that could itefere with ''.format - signature_or_text = signature_or_text.replace('{', '{') - signature_or_text = signature_or_text.replace('}', '}') - - # Remove 'ufunc' signature if needed. See spyder-ide/spyder#11821 - lines = [line for line in signature_or_text.split('\n') - if 'ufunc' not in line] - signature_or_text = '\n'.join(lines) - - if language == 'python': - open_func_char = '(' - has_multisignature = False - - if inspect_word: - has_signature = signature_or_text.startswith(inspect_word) - else: - idx = signature_or_text.find(open_func_char) - inspect_word = signature_or_text[:idx] - has_signature = True - - if has_signature: - name_plus_char = inspect_word + open_func_char - - all_lines = [] - for line in lines: - if (line.startswith(name_plus_char) - and line.count(name_plus_char) > 1): - sublines = line.split(name_plus_char) - sublines = [name_plus_char + l for l in sublines] - sublines = [l.strip() for l in sublines] - else: - sublines = [line] - - all_lines = all_lines + sublines - - lines = all_lines - count = 0 - for line in lines: - if line.startswith(name_plus_char): - count += 1 - - # Signature type - has_signature = count == 1 - has_multisignature = count > 1 and len(lines) > 1 - - if has_signature and not has_multisignature: - for i, line in enumerate(lines): - if line.strip() == '': - break - - if i == 0: - signature = lines[0] - extra_text = None - else: - signature = '\n'.join(lines[:i]) - extra_text = '\n'.join(lines[i:]) - - if signature: - new_signature = self._format_signature( - signatures=signature, - parameter=parameter, - max_width=max_width - ) - elif has_multisignature: - signature = signature_or_text.replace(name_plus_char, - '
    ' + name_plus_char) - signature = signature[4:] # Remove the first line break - signature = signature.replace('\n', ' ') - signature = signature.replace(r'\\*', '*') - signature = signature.replace(r'\*', '*') - signature = signature.replace('
    ', '\n') - signatures = signature.split('\n') - signatures = [sig for sig in signatures if sig] # Remove empty - new_signature = self._format_signature( - signatures=signatures, - parameter=parameter, - max_width=max_width - ) - extra_text = None - else: - new_signature = None - extra_text = signature_or_text - - return new_signature, extra_text, inspect_word - - def show_calltip(self, signature, parameter=None, documentation=None, - language=_DEFAULT_LANGUAGE, max_lines=_DEFAULT_MAX_LINES, - max_width=_DEFAULT_MAX_WIDTH, text_new_line=True): - """ - Show calltip. - - Calltips look like tooltips but will not disappear if mouse hovers - them. They are useful for displaying signature information on methods - and functions. - """ - # Find position of calltip - point = self._calculate_position() - signature = signature.strip() - inspect_word = None - language = getattr(self, 'language', language).lower() - if language == 'python' and signature: - inspect_word = signature.split('(')[0] - # Check if documentation is better than signature, sometimes - # signature has \n stripped for functions like print, type etc - check_doc = ' ' - if documentation: - check_doc.join(documentation.split()).replace('\\*', '*') - check_sig = ' '.join(signature.split()) - if check_doc == check_sig: - signature = documentation - documentation = '' - - # Remove duplicate signature inside documentation - if documentation: - documentation = documentation.replace('\\*', '*') - if signature.strip(): - documentation = documentation.replace(signature + '\n', '') - - # Format - res = self._check_signature_and_format(signature, parameter, - inspect_word=inspect_word, - language=language, - max_width=max_width) - new_signature, text, inspect_word = res - text = self._format_text( - signature=new_signature, - inspect_word=inspect_word, - display_link=False, - text=documentation, - max_lines=max_lines, - max_width=max_width, - text_new_line=text_new_line - ) - - self._update_stylesheet(self.calltip_widget) - - # Show calltip - self.calltip_widget.show_tip(point, text, []) - self.calltip_widget.show() - - def show_tooltip(self, title=None, signature=None, text=None, - inspect_word=None, title_color=_DEFAULT_TITLE_COLOR, - at_line=None, at_point=None, display_link=False, - max_lines=_DEFAULT_MAX_LINES, - max_width=_DEFAULT_MAX_WIDTH, - cursor=None, - with_html_format=False, - text_new_line=True, - completion_doc=None): - """Show tooltip.""" - # Find position of calltip - point = self._calculate_position( - at_line=at_line, - at_point=at_point, - ) - # Format text - tiptext = self._format_text( - title=title, - signature=signature, - text=text, - title_color=title_color, - inspect_word=inspect_word, - display_link=display_link, - max_lines=max_lines, - max_width=max_width, - with_html_format=with_html_format, - text_new_line=text_new_line - ) - - self._update_stylesheet(self.tooltip_widget) - - # Display tooltip - self.tooltip_widget.show_tip(point, tiptext, cursor=cursor, - completion_doc=completion_doc) - - def show_hint(self, text, inspect_word, at_point, - max_lines=_DEFAULT_MAX_HINT_LINES, - max_width=_DEFAULT_MAX_HINT_WIDTH, - text_new_line=True, completion_doc=None): - """Show code hint and crop text as needed.""" - res = self._check_signature_and_format(text, max_width=max_width, - inspect_word=inspect_word) - html_signature, extra_text, _ = res - point = self.get_word_start_pos(at_point) - - # Only display hover hint if there is documentation - if extra_text is not None: - # This is needed to get hover hints - cursor = self.cursorForPosition(at_point) - cursor.movePosition(QTextCursor.StartOfWord, - QTextCursor.MoveAnchor) - self._last_hover_cursor = cursor - - self.show_tooltip(signature=html_signature, text=extra_text, - at_point=point, inspect_word=inspect_word, - display_link=True, max_lines=max_lines, - max_width=max_width, cursor=cursor, - text_new_line=text_new_line, - completion_doc=completion_doc) - - def hide_tooltip(self): - """ - Hide the tooltip widget. - - The tooltip widget is a special QLabel that looks like a tooltip, - this method is here so it can be hidden as necessary. For example, - when the user leaves the Linenumber area when hovering over lint - warnings and errors. - """ - self._last_hover_cursor = None - self._last_hover_word = None - self._last_point = None - self.tooltip_widget.hide() - - # ----- Required methods for the LSP - def document_did_change(self, text=None): - pass - - #------EOL characters - def set_eol_chars(self, text=None, eol_chars=None): - """ - Set widget end-of-line (EOL) characters. - - Parameters - ---------- - text: str - Text to detect EOL characters from. - eol_chars: str - EOL characters to set. - - Notes - ----- - If `text` is passed, then `eol_chars` has no effect. - """ - if text is not None: - detected_eol_chars = sourcecode.get_eol_chars(text) - is_document_modified = ( - detected_eol_chars is not None and self.eol_chars is not None - ) - self.eol_chars = detected_eol_chars - elif eol_chars is not None: - is_document_modified = eol_chars != self.eol_chars - self.eol_chars = eol_chars - - if is_document_modified: - self.document().setModified(True) - if self.sig_eol_chars_changed is not None: - self.sig_eol_chars_changed.emit(eol_chars) - - def get_line_separator(self): - """Return line separator based on current EOL mode""" - if self.eol_chars is not None: - return self.eol_chars - else: - return os.linesep - - def get_text_with_eol(self): - """ - Same as 'toPlainText', replacing '\n' by correct end-of-line - characters. - """ - text = self.toPlainText() - linesep = self.get_line_separator() - for symbol in EOL_SYMBOLS: - text = text.replace(symbol, linesep) - return text - - #------Positions, coordinates (cursor, EOF, ...) - def get_position(self, subject): - """Get offset in character for the given subject from the start of - text edit area""" - cursor = self.textCursor() - if subject == 'cursor': - pass - elif subject == 'sol': - cursor.movePosition(QTextCursor.StartOfBlock) - elif subject == 'eol': - cursor.movePosition(QTextCursor.EndOfBlock) - elif subject == 'eof': - cursor.movePosition(QTextCursor.End) - elif subject == 'sof': - cursor.movePosition(QTextCursor.Start) - else: - # Assuming that input argument was already a position - return subject - return cursor.position() - - def get_coordinates(self, position): - position = self.get_position(position) - cursor = self.textCursor() - cursor.setPosition(position) - point = self.cursorRect(cursor).center() - return point.x(), point.y() - - def _is_point_inside_word_rect(self, point): - """ - Check if the mouse is within the rect of the cursor current word. - """ - cursor = self.cursorForPosition(point) - cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) - start_rect = self.cursorRect(cursor) - cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.MoveAnchor) - end_rect = self.cursorRect(cursor) - bounding_rect = start_rect.united(end_rect) - return bounding_rect.contains(point) - - def get_word_start_pos(self, position): - """ - Find start position (lower bottom) of a word being hovered by mouse. - """ - cursor = self.cursorForPosition(position) - cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) - rect = self.cursorRect(cursor) - pos = QPoint(rect.left() + 4, rect.top()) - return pos - - def get_last_hover_word(self): - """Return the last (or active) hover word.""" - return self._last_hover_word - - def get_last_hover_cursor(self): - """Return the last (or active) hover cursor.""" - return self._last_hover_cursor - - def get_cursor_line_column(self, cursor=None): - """ - Return `cursor` (line, column) numbers. - - If no `cursor` is provided, use the current text cursor. - """ - if cursor is None: - cursor = self.textCursor() - - return cursor.blockNumber(), cursor.columnNumber() - - def get_cursor_line_number(self): - """Return cursor line number""" - return self.textCursor().blockNumber()+1 - - def get_position_line_number(self, line, col): - """Get position offset from (line, col) coordinates.""" - block = self.document().findBlockByNumber(line) - cursor = QTextCursor(block) - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, - n=col + 1) - return cursor.position() - - def set_cursor_position(self, position): - """Set cursor position""" - position = self.get_position(position) - cursor = self.textCursor() - cursor.setPosition(position) - self.setTextCursor(cursor) - self.ensureCursorVisible() - - def move_cursor(self, chars=0): - """Move cursor to left or right (unit: characters)""" - direction = QTextCursor.Right if chars > 0 else QTextCursor.Left - for _i in range(abs(chars)): - self.moveCursor(direction, QTextCursor.MoveAnchor) - - def is_cursor_on_first_line(self): - """Return True if cursor is on the first line""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.StartOfBlock) - return cursor.atStart() - - def is_cursor_on_last_line(self): - """Return True if cursor is on the last line""" - cursor = self.textCursor() - cursor.movePosition(QTextCursor.EndOfBlock) - return cursor.atEnd() - - def is_cursor_at_end(self): - """Return True if cursor is at the end of the text""" - return self.textCursor().atEnd() - - def is_cursor_before(self, position, char_offset=0): - """Return True if cursor is before *position*""" - position = self.get_position(position) + char_offset - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - if position < cursor.position(): - cursor.setPosition(position) - return self.textCursor() < cursor - - def __move_cursor_anchor(self, what, direction, move_mode): - assert what in ('character', 'word', 'line') - if what == 'character': - if direction == 'left': - self.moveCursor(QTextCursor.PreviousCharacter, move_mode) - elif direction == 'right': - self.moveCursor(QTextCursor.NextCharacter, move_mode) - elif what == 'word': - if direction == 'left': - self.moveCursor(QTextCursor.PreviousWord, move_mode) - elif direction == 'right': - self.moveCursor(QTextCursor.NextWord, move_mode) - elif what == 'line': - if direction == 'down': - self.moveCursor(QTextCursor.NextBlock, move_mode) - elif direction == 'up': - self.moveCursor(QTextCursor.PreviousBlock, move_mode) - - def move_cursor_to_next(self, what='word', direction='left'): - """ - Move cursor to next *what* ('word' or 'character') - toward *direction* ('left' or 'right') - """ - self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) - - #------Selection - def extend_selection_to_next(self, what='word', direction='left'): - """ - Extend selection to next *what* ('word' or 'character') - toward *direction* ('left' or 'right') - """ - self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) - - #------Text: get, set, ... - - def _select_text(self, position_from, position_to): - """Select text and return cursor.""" - position_from = self.get_position(position_from) - position_to = self.get_position(position_to) - cursor = self.textCursor() - cursor.setPosition(position_from) - cursor.setPosition(position_to, QTextCursor.KeepAnchor) - return cursor - - def get_text_line(self, line_nb): - """Return text line at line number *line_nb*""" - block = self.document().findBlockByNumber(line_nb) - cursor = QTextCursor(block) - cursor.movePosition(QTextCursor.StartOfBlock) - cursor.movePosition(QTextCursor.EndOfBlock, mode=QTextCursor.KeepAnchor) - return to_text_string(cursor.selectedText()) - - def get_text_region(self, start_line, end_line): - """Return text lines spanned from *start_line* to *end_line*.""" - start_block = self.document().findBlockByNumber(start_line) - end_block = self.document().findBlockByNumber(end_line) - - start_cursor = QTextCursor(start_block) - start_cursor.movePosition(QTextCursor.StartOfBlock) - end_cursor = QTextCursor(end_block) - end_cursor.movePosition(QTextCursor.EndOfBlock) - end_position = end_cursor.position() - start_cursor.setPosition(end_position, mode=QTextCursor.KeepAnchor) - return self.get_selected_text(start_cursor) - - def get_text(self, position_from, position_to, remove_newlines=True): - """Returns text between *position_from* and *position_to*. - - Positions may be integers or 'sol', 'eol', 'sof', 'eof' or 'cursor'. - - Unless position_from='sof' and position_to='eof' any trailing newlines - in the string are removed. This was added as a workaround for - spyder-ide/spyder#1546 and later caused spyder-ide/spyder#14374. - The behaviour can be overridden by setting the optional parameter - *remove_newlines* to False. - - TODO: Evaluate if this is still a problem and if the workaround can - be moved closer to where the problem occurs. - """ - cursor = self._select_text(position_from, position_to) - text = to_text_string(cursor.selectedText()) - if remove_newlines: - remove_newlines = position_from != 'sof' or position_to != 'eof' - if text and remove_newlines: - while text and text[-1] in EOL_SYMBOLS: - text = text[:-1] - return text - - def get_character(self, position, offset=0): - """Return character at *position* with the given offset.""" - position = self.get_position(position) + offset - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - if position < cursor.position(): - cursor.setPosition(position) - cursor.movePosition(QTextCursor.Right, - QTextCursor.KeepAnchor) - return to_text_string(cursor.selectedText()) - else: - return '' - - def insert_text(self, text, will_insert_text=True): - """Insert text at cursor position""" - if not self.isReadOnly(): - if will_insert_text and self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - self.textCursor().insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - - def replace_text(self, position_from, position_to, text): - cursor = self._select_text(position_from, position_to) - if self.sig_will_remove_selection is not None: - start, end = self.get_selection_start_end(cursor) - self.sig_will_remove_selection.emit(start, end) - cursor.removeSelectedText() - if self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - cursor.insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - - def remove_text(self, position_from, position_to): - cursor = self._select_text(position_from, position_to) - if self.sig_will_remove_selection is not None: - start, end = self.get_selection_start_end(cursor) - self.sig_will_remove_selection.emit(start, end) - cursor.removeSelectedText() - - def get_current_object(self): - """ - Return current object under cursor. - - Get the text of the current word plus all the characters - to the left until a space is found. Used to get text to inspect - for Help of elements following dot notation for example - np.linalg.norm - """ - cursor = self.textCursor() - cursor_pos = cursor.position() - current_word = self.get_current_word(help_req=True) - - # Get max position to the left of cursor until space or no more - # characters are left - cursor.movePosition(QTextCursor.PreviousCharacter) - while self.get_character(cursor.position()).strip(): - cursor.movePosition(QTextCursor.PreviousCharacter) - if cursor.atBlockStart(): - break - cursor_pos_left = cursor.position() - - # Get max position to the right of cursor until space or no more - # characters are left - cursor.setPosition(cursor_pos) - while self.get_character(cursor.position()).strip(): - cursor.movePosition(QTextCursor.NextCharacter) - if cursor.atBlockEnd(): - break - cursor_pos_right = cursor.position() - - # Get text of the object under the cursor - current_text = self.get_text( - cursor_pos_left, cursor_pos_right).strip() - current_object = current_word - - if current_text and current_word is not None: - if current_word != current_text and current_word in current_text: - current_object = ( - current_text.split(current_word)[0] + current_word) - - return current_object - - def get_current_word_and_position(self, completion=False, help_req=False, - valid_python_variable=True): - """ - Return current word, i.e. word at cursor position, and the start - position. - """ - cursor = self.textCursor() - cursor_pos = cursor.position() - - if cursor.hasSelection(): - # Removes the selection and moves the cursor to the left side - # of the selection: this is required to be able to properly - # select the whole word under cursor (otherwise, the same word is - # not selected when the cursor is at the right side of it): - cursor.setPosition(min([cursor.selectionStart(), - cursor.selectionEnd()])) - else: - # Checks if the first character to the right is a white space - # and if not, moves the cursor one word to the left (otherwise, - # if the character to the left do not match the "word regexp" - # (see below), the word to the left of the cursor won't be - # selected), but only if the first character to the left is not a - # white space too. - def is_space(move): - curs = self.textCursor() - curs.movePosition(move, QTextCursor.KeepAnchor) - return not to_text_string(curs.selectedText()).strip() - - def is_special_character(move): - """Check if a character is a non-letter including numbers.""" - curs = self.textCursor() - curs.movePosition(move, QTextCursor.KeepAnchor) - text_cursor = to_text_string(curs.selectedText()).strip() - return len( - re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0 - - if help_req: - if is_special_character(QTextCursor.PreviousCharacter): - cursor.movePosition(QTextCursor.NextCharacter) - elif is_special_character(QTextCursor.NextCharacter): - cursor.movePosition(QTextCursor.PreviousCharacter) - elif not completion: - if is_space(QTextCursor.NextCharacter): - if is_space(QTextCursor.PreviousCharacter): - return - cursor.movePosition(QTextCursor.WordLeft) - else: - if is_space(QTextCursor.PreviousCharacter): - return - if (is_special_character(QTextCursor.NextCharacter)): - cursor.movePosition(QTextCursor.WordLeft) - - cursor.select(QTextCursor.WordUnderCursor) - text = to_text_string(cursor.selectedText()) - startpos = cursor.selectionStart() - - # Find a valid Python variable name - if valid_python_variable: - match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE) - if not match: - # This is assumed in several places of our codebase, - # so please don't change this return! - return None - else: - text = match[0] - - if completion: - text = text[:cursor_pos - startpos] - - return text, startpos - - def get_current_word(self, completion=False, help_req=False, - valid_python_variable=True): - """Return current word, i.e. word at cursor position.""" - ret = self.get_current_word_and_position( - completion=completion, - help_req=help_req, - valid_python_variable=valid_python_variable - ) - - if ret is not None: - return ret[0] - - def get_hover_word(self): - """Return the last hover word that requested a hover hint.""" - return self._last_hover_word - - def get_current_line(self): - """Return current line's text.""" - cursor = self.textCursor() - cursor.select(QTextCursor.BlockUnderCursor) - return to_text_string(cursor.selectedText()) - - def get_current_line_to_cursor(self): - """Return text from prompt to cursor.""" - return self.get_text(self.current_prompt_pos, 'cursor') - - def get_line_number_at(self, coordinates): - """Return line number at *coordinates* (QPoint).""" - cursor = self.cursorForPosition(coordinates) - return cursor.blockNumber() + 1 - - def get_line_at(self, coordinates): - """Return line at *coordinates* (QPoint).""" - cursor = self.cursorForPosition(coordinates) - cursor.select(QTextCursor.BlockUnderCursor) - return to_text_string(cursor.selectedText()).replace(u'\u2029', '') - - def get_word_at(self, coordinates): - """Return word at *coordinates* (QPoint).""" - cursor = self.cursorForPosition(coordinates) - cursor.select(QTextCursor.WordUnderCursor) - if self._is_point_inside_word_rect(coordinates): - word = to_text_string(cursor.selectedText()) - else: - word = '' - - return word - - def get_line_indentation(self, text): - """Get indentation for given line.""" - text = text.replace("\t", " "*self.tab_stop_width_spaces) - return len(text)-len(text.lstrip()) - - def get_block_indentation(self, block_nb): - """Return line indentation (character number).""" - text = to_text_string(self.document().findBlockByNumber(block_nb).text()) - return self.get_line_indentation(text) - - def get_selection_bounds(self, cursor=None): - """Return selection bounds (block numbers).""" - if cursor is None: - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - block_start = self.document().findBlock(start) - block_end = self.document().findBlock(end) - return sorted([block_start.blockNumber(), block_end.blockNumber()]) - - def get_selection_start_end(self, cursor=None): - """Return selection start and end (line, column) positions.""" - if cursor is None: - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - start_cursor = QTextCursor(cursor) - start_cursor.setPosition(start) - start_position = self.get_cursor_line_column(start_cursor) - end_cursor = QTextCursor(cursor) - end_cursor.setPosition(end) - end_position = self.get_cursor_line_column(end_cursor) - return start_position, end_position - - #------Text selection - def has_selected_text(self): - """Returns True if some text is selected.""" - return bool(to_text_string(self.textCursor().selectedText())) - - def get_selected_text(self, cursor=None): - """ - Return text selected by current text cursor, converted in unicode. - - Replace the unicode line separator character \u2029 by - the line separator characters returned by get_line_separator - """ - if cursor is None: - cursor = self.textCursor() - return to_text_string(cursor.selectedText()).replace(u"\u2029", - self.get_line_separator()) - - def remove_selected_text(self): - """Delete selected text.""" - self.textCursor().removeSelectedText() - # The next three lines are a workaround for a quirk of - # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. - # See spyder-ide/spyder#12663 and - # https://bugreports.qt.io/browse/QTBUG-35861 - if (parse_version(QT_VERSION) < parse_version('5.15') - or os.name == 'nt' or sys.platform == 'darwin'): - cursor = self.textCursor() - cursor.setPosition(cursor.position()) - self.setTextCursor(cursor) - - def replace(self, text, pattern=None): - """Replace selected text by *text*. - - If *pattern* is not None, replacing selected text using regular - expression text substitution.""" - cursor = self.textCursor() - cursor.beginEditBlock() - if pattern is not None: - seltxt = to_text_string(cursor.selectedText()) - if self.sig_will_remove_selection is not None: - start, end = self.get_selection_start_end(cursor) - self.sig_will_remove_selection.emit(start, end) - cursor.removeSelectedText() - if pattern is not None: - text = re.sub(to_text_string(pattern), - to_text_string(text), to_text_string(seltxt)) - if self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - cursor.insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - cursor.endEditBlock() - - - #------Find/replace - def find_multiline_pattern(self, regexp, cursor, findflag): - """Reimplement QTextDocument's find method. - - Add support for *multiline* regular expressions.""" - pattern = to_text_string(regexp.pattern()) - text = to_text_string(self.toPlainText()) - try: - regobj = re.compile(pattern) - except sre_constants.error: - return - if findflag & QTextDocument.FindBackward: - # Find backward - offset = min([cursor.selectionEnd(), cursor.selectionStart()]) - text = text[:offset] - matches = [_m for _m in regobj.finditer(text, 0, offset)] - if matches: - match = matches[-1] - else: - return - else: - # Find forward - offset = max([cursor.selectionEnd(), cursor.selectionStart()]) - match = regobj.search(text, offset) - if match: - pos1, pos2 = sh.get_span(match) - fcursor = self.textCursor() - fcursor.setPosition(pos1) - fcursor.setPosition(pos2, QTextCursor.KeepAnchor) - return fcursor - - def find_text(self, text, changed=True, forward=True, case=False, - word=False, regexp=False): - """Find text.""" - cursor = self.textCursor() - findflag = QTextDocument.FindFlag() - - # Get visible region to center cursor in case it's necessary. - if getattr(self, 'get_visible_block_numbers', False): - current_visible_region = self.get_visible_block_numbers() - else: - current_visible_region = None - - if not forward: - findflag = findflag | QTextDocument.FindBackward - - if case: - findflag = findflag | QTextDocument.FindCaseSensitively - - moves = [QTextCursor.NoMove] - if forward: - moves += [QTextCursor.NextWord, QTextCursor.Start] - if changed: - if to_text_string(cursor.selectedText()): - new_position = min([cursor.selectionStart(), - cursor.selectionEnd()]) - cursor.setPosition(new_position) - else: - cursor.movePosition(QTextCursor.PreviousWord) - else: - moves += [QTextCursor.End] - - if regexp: - text = to_text_string(text) - else: - text = re.escape(to_text_string(text)) - - pattern = QRegularExpression(u"\\b{}\\b".format(text) if word else - text) - if case: - pattern.setPatternOptions(QRegularExpression.CaseInsensitiveOption) - - for move in moves: - cursor.movePosition(move) - if regexp and '\\n' in text: - # Multiline regular expression - found_cursor = self.find_multiline_pattern(pattern, cursor, - findflag) - else: - # Single line find: using the QTextDocument's find function, - # probably much more efficient than ours - found_cursor = self.document().find(pattern, cursor, findflag) - if found_cursor is not None and not found_cursor.isNull(): - self.setTextCursor(found_cursor) - - # Center cursor if we move out of the visible region. - if current_visible_region is not None: - found_visible_region = self.get_visible_block_numbers() - if current_visible_region != found_visible_region: - current_visible_region = found_visible_region - self.centerCursor() - - return True - - return False - - def is_editor(self): - """Needs to be overloaded in the codeeditor where it will be True""" - return False - - def get_number_matches(self, pattern, source_text='', case=False, - regexp=False, word=False): - """Get the number of matches for the searched text.""" - pattern = to_text_string(pattern) - if not pattern: - return 0 - - if not regexp: - pattern = re.escape(pattern) - - if not source_text: - source_text = to_text_string(self.toPlainText()) - - if word: # match whole words only - pattern = r'\b{pattern}\b'.format(pattern=pattern) - try: - re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE - regobj = re.compile(pattern, flags=re_flags) - except sre_constants.error: - return None - - number_matches = 0 - for match in regobj.finditer(source_text): - number_matches += 1 - - return number_matches - - def get_match_number(self, pattern, case=False, regexp=False, word=False): - """Get number of the match for the searched text.""" - position = self.textCursor().position() - source_text = self.get_text(position_from='sof', position_to=position) - match_number = self.get_number_matches(pattern, - source_text=source_text, - case=case, regexp=regexp, - word=word) - return match_number - - # --- Array builder helper / See 'spyder/widgets/arraybuilder.py' - def enter_array_inline(self): - """Enter array builder inline mode.""" - self._enter_array(True) - - def enter_array_table(self): - """Enter array builder table mode.""" - self._enter_array(False) - - def _enter_array(self, inline): - """Enter array builder mode.""" - offset = self.get_position('cursor') - self.get_position('sol') - rect = self.cursorRect() - dlg = ArrayBuilderDialog(self, inline, offset) - - # TODO: adapt to font size - x = rect.left() - x = int(x - 14) - y = rect.top() + (rect.bottom() - rect.top())/2 - y = int(y - dlg.height()/2 - 3) - - pos = QPoint(x, y) - pos = self.calculate_real_position(pos) - dlg.move(self.mapToGlobal(pos)) - - # called from editor - if self.is_editor(): - python_like_check = self.is_python_like() - suffix = '\n' - # called from a console - else: - python_like_check = True - suffix = '' - - if python_like_check and dlg.exec_(): - text = dlg.text() + suffix - if text != '': - cursor = self.textCursor() - cursor.beginEditBlock() - if self.sig_will_insert_text is not None: - self.sig_will_insert_text.emit(text) - cursor.insertText(text) - if self.sig_text_was_inserted is not None: - self.sig_text_was_inserted.emit() - cursor.endEditBlock() - - -class TracebackLinksMixin(object): - """ """ - QT_CLASS = None - - # This signal emits a parsed error traceback text so we can then - # request opening the file that traceback comes from in the Editor. - sig_go_to_error_requested = None - - def __init__(self): - self.__cursor_changed = False - self.setMouseTracking(True) - - #------Mouse events - def mouseReleaseEvent(self, event): - """Go to error""" - self.QT_CLASS.mouseReleaseEvent(self, event) - text = self.get_line_at(event.pos()) - if get_error_match(text) and not self.has_selected_text(): - if self.sig_go_to_error_requested is not None: - self.sig_go_to_error_requested.emit(text) - - def mouseMoveEvent(self, event): - """Show Pointing Hand Cursor on error messages""" - text = self.get_line_at(event.pos()) - if get_error_match(text): - if not self.__cursor_changed: - QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) - self.__cursor_changed = True - event.accept() - return - if self.__cursor_changed: - QApplication.restoreOverrideCursor() - self.__cursor_changed = False - self.QT_CLASS.mouseMoveEvent(self, event) - - def leaveEvent(self, event): - """If cursor has not been restored yet, do it now""" - if self.__cursor_changed: - QApplication.restoreOverrideCursor() - self.__cursor_changed = False - self.QT_CLASS.leaveEvent(self, event) - - -class GetHelpMixin(object): - - def __init__(self): - self.help_enabled = False - - def set_help_enabled(self, state): - self.help_enabled = state - - def inspect_current_object(self): - current_object = self.get_current_object() - if current_object is not None: - self.show_object_info(current_object, force=True) - - def show_object_info(self, text, call=False, force=False): - """Show signature calltip and/or docstring in the Help plugin""" - text = to_text_string(text) - - # Show docstring - help_enabled = self.help_enabled or force - if help_enabled: - doc = { - 'name': text, - 'ignore_unknown': False, - } - self.sig_help_requested.emit(doc) - - # Show calltip - if call and getattr(self, 'calltips', None): - # Display argument list if this is a function call - iscallable = self.iscallable(text) - if iscallable is not None: - if iscallable: - arglist = self.get_arglist(text) - name = text.split('.')[-1] - argspec = signature = '' - if isinstance(arglist, bool): - arglist = [] - if arglist: - argspec = '(' + ''.join(arglist) + ')' - else: - doc = self.get__doc__(text) - if doc is not None: - # This covers cases like np.abs, whose docstring is - # the same as np.absolute and because of that a - # proper signature can't be obtained correctly - argspec = getargspecfromtext(doc) - if not argspec: - signature = getsignaturefromtext(doc, name) - if argspec or signature: - if argspec: - tiptext = name + argspec - else: - tiptext = signature - # TODO: Select language and pass it to call - self.show_calltip(tiptext) - - def get_last_obj(self, last=False): - """ - Return the last valid object on the current line - """ - return getobj(self.get_current_line_to_cursor(), last=last) - - -class SaveHistoryMixin(object): - - INITHISTORY = None - SEPARATOR = None - HISTORY_FILENAMES = [] - - sig_append_to_history_requested = None - - def __init__(self, history_filename=''): - self.history_filename = history_filename - self.create_history_filename() - - def create_history_filename(self): - """Create history_filename with INITHISTORY if it doesn't exist.""" - if self.history_filename and not osp.isfile(self.history_filename): - try: - encoding.writelines(self.INITHISTORY, self.history_filename) - except EnvironmentError: - pass - - def add_to_history(self, command): - """Add command to history""" - command = to_text_string(command) - if command in ['', '\n'] or command.startswith('Traceback'): - return - if command.endswith('\n'): - command = command[:-1] - self.histidx = None - if len(self.history) > 0 and self.history[-1] == command: - return - self.history.append(command) - text = os.linesep + command - - # When the first entry will be written in history file, - # the separator will be append first: - if self.history_filename not in self.HISTORY_FILENAMES: - self.HISTORY_FILENAMES.append(self.history_filename) - text = self.SEPARATOR + text - # Needed to prevent errors when writing history to disk - # See spyder-ide/spyder#6431. - try: - encoding.write(text, self.history_filename, mode='ab') - except EnvironmentError: - pass - if self.sig_append_to_history_requested is not None: - self.sig_append_to_history_requested.emit( - self.history_filename, text) - - -class BrowseHistory(object): - - def __init__(self): - self.history = [] - self.histidx = None - self.hist_wholeline = False - - def browse_history(self, line, cursor_pos, backward): - """ - Browse history. - - Return the new text and wherever the cursor should move. - """ - if cursor_pos < len(line) and self.hist_wholeline: - self.hist_wholeline = False - tocursor = line[:cursor_pos] - text, self.histidx = self.find_in_history(tocursor, self.histidx, - backward) - if text is not None: - text = text.strip() - if self.hist_wholeline: - return text, True - else: - return tocursor + text, False - return None, False - - def find_in_history(self, tocursor, start_idx, backward): - """Find text 'tocursor' in history, from index 'start_idx'""" - if start_idx is None: - start_idx = len(self.history) - # Finding text in history - step = -1 if backward else 1 - idx = start_idx - if len(tocursor) == 0 or self.hist_wholeline: - idx += step - if idx >= len(self.history) or len(self.history) == 0: - return "", len(self.history) - elif idx < 0: - idx = 0 - self.hist_wholeline = True - return self.history[idx], idx - else: - for index in range(len(self.history)): - idx = (start_idx+step*(index+1)) % len(self.history) - entry = self.history[idx] - if entry.startswith(tocursor): - return entry[len(tocursor):], idx - else: - return None, start_idx - - def reset_search_pos(self): - """Reset the position from which to search the history""" - self.histidx = None - - -class BrowseHistoryMixin(BrowseHistory): - - def clear_line(self): - """Clear current line (without clearing console prompt)""" - self.remove_text(self.current_prompt_pos, 'eof') - - def browse_history(self, backward): - """Browse history""" - line = self.get_text(self.current_prompt_pos, 'eof') - old_pos = self.get_position('cursor') - cursor_pos = self.get_position('cursor') - self.current_prompt_pos - if cursor_pos < 0: - cursor_pos = 0 - self.set_cursor_position(self.current_prompt_pos) - text, move_cursor = super(BrowseHistoryMixin, self).browse_history( - line, cursor_pos, backward) - if text is not None: - self.clear_line() - self.insert_text(text) - if not move_cursor: - self.set_cursor_position(old_pos) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Mix-in classes + +These classes were created to be able to provide Spyder's regular text and +console widget features to an independent widget based on QTextEdit for the +IPython console plugin. +""" + +# Standard library imports +from __future__ import print_function +import os +import os.path as osp +import re +import sre_constants +import sys +import textwrap +from pkg_resources import parse_version + +# Third party imports +from qtpy import QT_VERSION +from qtpy.QtCore import QPoint, QRegularExpression, Qt +from qtpy.QtGui import QCursor, QTextCursor, QTextDocument +from qtpy.QtWidgets import QApplication +from spyder_kernels.utils.dochelpers import (getargspecfromtext, getobj, + getsignaturefromtext) + +# Local imports +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string +from spyder.utils import encoding, sourcecode +from spyder.utils import syntaxhighlighters as sh +from spyder.utils.misc import get_error_match +from spyder.utils.palette import QStylePalette +from spyder.widgets.arraybuilder import ArrayBuilderDialog + + +# List of possible EOL symbols +EOL_SYMBOLS = [ + # Put first as it correspond to a single line return + "\r\n", # Carriage Return + Line Feed + "\r", # Carriage Return + "\n", # Line Feed + "\v", # Line Tabulation + "\x0b", # Line Tabulation + "\f", # Form Feed + "\x0c", # Form Feed + "\x1c", # File Separator + "\x1d", # Group Separator + "\x1e", # Record Separator + "\x85", # Next Line (C1 Control Code) + "\u2028", # Line Separator + "\u2029", # Paragraph Separator +] + + +class BaseEditMixin(object): + + _PARAMETER_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 + _DEFAULT_TITLE_COLOR = QStylePalette.COLOR_ACCENT_4 + _CHAR_HIGHLIGHT_COLOR = QStylePalette.COLOR_ACCENT_4 + _DEFAULT_TEXT_COLOR = QStylePalette.COLOR_TEXT_2 + _DEFAULT_LANGUAGE = 'python' + _DEFAULT_MAX_LINES = 10 + _DEFAULT_MAX_WIDTH = 60 + _DEFAULT_COMPLETION_HINT_MAX_WIDTH = 52 + _DEFAULT_MAX_HINT_LINES = 20 + _DEFAULT_MAX_HINT_WIDTH = 85 + + # The following signals are used to indicate text changes on the editor. + sig_will_insert_text = None + sig_will_remove_selection = None + sig_text_was_inserted = None + + _styled_widgets = set() + + def __init__(self): + self.eol_chars = None + self.calltip_size = 600 + + #------Line number area + def get_linenumberarea_width(self): + """Return line number area width""" + # Implemented in CodeEditor, but needed for calltip/completion widgets + return 0 + + def calculate_real_position(self, point): + """ + Add offset to a point, to take into account the Editor panels. + + This is reimplemented in CodeEditor, in other widgets it returns + the same point. + """ + return point + + # --- Tooltips and Calltips + def _calculate_position(self, at_line=None, at_point=None): + """ + Calculate a global point position `QPoint(x, y)`, for a given + line, local cursor position, or local point. + """ + font = self.font() + + if at_point is not None: + # Showing tooltip at point position + margin = (self.document().documentMargin() / 2) + 1 + cx = int(at_point.x() - margin) + cy = int(at_point.y() - margin) + elif at_line is not None: + # Showing tooltip at line + cx = 5 + line = at_line - 1 + cursor = QTextCursor(self.document().findBlockByNumber(line)) + cy = int(self.cursorRect(cursor).top()) + else: + # Showing tooltip at cursor position + cx, cy = self.get_coordinates('cursor') + cx = int(cx) + cy = int(cy - font.pointSize() / 2) + + # Calculate vertical delta + # The needed delta changes with font size, so we use a power law + if sys.platform == 'darwin': + delta = int((font.pointSize() * 1.20) ** 0.98 + 4.5) + elif os.name == 'nt': + delta = int((font.pointSize() * 1.20) ** 1.05) + 7 + else: + delta = int((font.pointSize() * 1.20) ** 0.98) + 7 + # delta = font.pointSize() + 5 + + # Map to global coordinates + point = self.mapToGlobal(QPoint(cx, cy)) + point = self.calculate_real_position(point) + point.setY(point.y() + delta) + + return point + + def _update_stylesheet(self, widget): + """Update the background stylesheet to make it lighter.""" + # Update the stylesheet for a given widget at most once + # because Qt is slow to repeatedly parse & apply CSS + if id(widget) in self._styled_widgets: + return + self._styled_widgets.add(id(widget)) + background = QStylePalette.COLOR_BACKGROUND_4 + border = QStylePalette.COLOR_TEXT_4 + name = widget.__class__.__name__ + widget.setObjectName(name) + css = ''' + {0}#{0} {{ + background-color:{1}; + border: 1px solid {2}; + }}'''.format(name, background, border) + widget.setStyleSheet(css) + + def _get_inspect_shortcut(self): + """ + Queries the editor's config to get the current "Inspect" shortcut. + """ + value = CONF.get('shortcuts', 'editor/inspect current object') + if value: + if sys.platform == "darwin": + value = value.replace('Ctrl', 'Cmd') + return value + + def _format_text(self, title=None, signature=None, text=None, + inspect_word=None, title_color=None, max_lines=None, + max_width=_DEFAULT_MAX_WIDTH, display_link=False, + text_new_line=False, with_html_format=False): + """ + Create HTML template for calltips and tooltips. + + This will display title and text as separate sections and add `...` + + ---------------------------------------- + | `title` (with `title_color`) | + ---------------------------------------- + | `signature` | + | | + | `text` (ellided to `max_lines`) | + | | + ---------------------------------------- + | Link or shortcut with `inspect_word` | + ---------------------------------------- + """ + BASE_TEMPLATE = u''' +
    + {main_text} +
    + ''' + # Get current font properties + font = self.font() + font_family = font.family() + title_size = font.pointSize() + text_size = title_size - 1 if title_size > 9 else title_size + text_color = self._DEFAULT_TEXT_COLOR + + template = '' + if title: + template += BASE_TEMPLATE.format( + font_family=font_family, + size=title_size, + color=title_color, + main_text=title, + ) + + if text or signature: + template += '
    ' + + if signature: + signature = signature.strip('\r\n') + template += BASE_TEMPLATE.format( + font_family=font_family, + size=text_size, + color=text_color, + main_text=signature, + ) + + # Documentation/text handling + if (text is None or not text.strip() or + text.strip() == ''): + text = 'No documentation available' + else: + text = text.strip() + + if not with_html_format: + # All these replacements are need to properly divide the + # text in actual paragraphs and wrap the text on each one + paragraphs = (text + .replace(u"\xa0", u" ") + .replace("\n\n", "") + .replace(".\n", ".") + .replace("\n-", "-") + .replace("-\n", "-") + .replace("\n=", "=") + .replace("=\n", "=") + .replace("\n*", "*") + .replace("*\n", "*") + .replace("\n ", " ") + .replace(" \n", " ") + .replace("\n", " ") + .replace("", "\n\n") + .replace("", "\n").splitlines()) + new_paragraphs = [] + for paragraph in paragraphs: + # Wrap text + new_paragraph = textwrap.wrap(paragraph, width=max_width) + + # Remove empty lines at the beginning + new_paragraph = [l for l in new_paragraph if l.strip()] + + # Merge paragraph text + new_paragraph = '\n'.join(new_paragraph) + + # Add new paragraph + new_paragraphs.append(new_paragraph) + + # Join paragraphs and split in lines for max_lines check + paragraphs = '\n'.join(new_paragraphs) + paragraphs = paragraphs.strip('\r\n') + lines = paragraphs.splitlines() + + # Check that the first line is not empty + if len(lines) > 0 and not lines[0].strip(): + lines = lines[1:] + else: + lines = [l for l in text.split('\n') if l.strip()] + + # Limit max number of text displayed + if max_lines: + if len(lines) > max_lines: + text = '\n'.join(lines[:max_lines]) + ' ...' + else: + text = '\n'.join(lines) + + text = text.replace('\n', '
    ') + if text_new_line and signature: + text = '
    ' + text + + template += BASE_TEMPLATE.format( + font_family=font_family, + size=text_size, + color=text_color, + main_text=text, + ) + + help_text = '' + if inspect_word: + if display_link: + help_text = ( + '' + 'Click anywhere in this tooltip for additional help' + ''.format( + font_size=text_size, + font_family=font_family, + ) + ) + else: + shortcut = self._get_inspect_shortcut() + if shortcut: + base_style = ( + f'background-color:{QStylePalette.COLOR_BACKGROUND_4};' + f'color:{QStylePalette.COLOR_TEXT_1};' + 'font-size:11px;' + ) + help_text = '' + # ( + # 'Press ' + # '[' + # '' + # '{0}] for aditional ' + # 'help'.format(shortcut, base_style) + # ) + + if help_text and inspect_word: + if display_link: + template += ( + '
    ' + '
    ' + f'' + ''.format(font_family=font_family, + size=text_size) + ) + help_text + '
    ' + else: + template += ( + '
    ' + '
    ' + '' + '' + help_text + '
    ' + ) + + return template + + def _format_signature(self, signatures, parameter=None, + max_width=_DEFAULT_MAX_WIDTH, + parameter_color=_PARAMETER_HIGHLIGHT_COLOR, + char_color=_CHAR_HIGHLIGHT_COLOR, + language=_DEFAULT_LANGUAGE): + """ + Create HTML template for signature. + + This template will include indent after the method name, a highlight + color for the active parameter and highlights for special chars. + + Special chars depend on the language. + """ + language = getattr(self, 'language', language).lower() + active_parameter_template = ( + '' + '{parameter}' + '' + ) + chars_template = ( + '{char}' + '' + ) + + def handle_sub(matchobj): + """ + Handle substitution of active parameter template. + + This ensures the correct highlight of the active parameter. + """ + match = matchobj.group(0) + new = match.replace(parameter, active_parameter_template) + return new + + if not isinstance(signatures, list): + signatures = [signatures] + + new_signatures = [] + for signature in signatures: + # Remove duplicate spaces + signature = ' '.join(signature.split()) + + # Replace initial spaces + signature = signature.replace('( ', '(') + + # Process signature template + if parameter and language == 'python': + # Escape all possible regex characters + # ( ) { } | [ ] . ^ $ * + + escape_regex_chars = ['|', '.', '^', '$', '*', '+'] + remove_regex_chars = ['(', ')', '{', '}', '[', ']'] + regex_parameter = parameter + for regex_char in escape_regex_chars + remove_regex_chars: + if regex_char in escape_regex_chars: + escape_char = r'\{char}'.format(char=regex_char) + regex_parameter = regex_parameter.replace(regex_char, + escape_char) + else: + regex_parameter = regex_parameter.replace(regex_char, + '') + parameter = parameter.replace(regex_char, '') + + pattern = (r'[\*|\(|\[|\s](' + regex_parameter + + r')[,|\)|\]|\s|=]') + + formatted_lines = [] + name = signature.split('(')[0] + indent = ' ' * (len(name) + 1) + rows = textwrap.wrap(signature, width=max_width, + subsequent_indent=indent) + for row in rows: + if parameter and language == 'python': + # Add template to highlight the active parameter + row = re.sub(pattern, handle_sub, row) + + row = row.replace(' ', ' ') + row = row.replace('span ', 'span ') + row = row.replace('{}', '{{}}') + + if language and language == 'python': + for char in ['(', ')', ',', '*', '**']: + new_char = chars_template.format(char=char) + row = row.replace(char, new_char) + + formatted_lines.append(row) + title_template = '
    '.join(formatted_lines) + + # Get current font properties + font = self.font() + font_size = font.pointSize() + font_family = font.family() + + # Format title to display active parameter + if parameter and language == 'python': + title = title_template.format( + font_size=font_size, + font_family=font_family, + color=parameter_color, + parameter=parameter, + ) + else: + title = title_template + new_signatures.append(title) + + return '
    '.join(new_signatures) + + def _check_signature_and_format(self, signature_or_text, parameter=None, + inspect_word=None, + max_width=_DEFAULT_MAX_WIDTH, + language=_DEFAULT_LANGUAGE): + """ + LSP hints might provide docstrings instead of signatures. + + This method will check for multiple signatures (dict, type etc...) and + format the text accordingly. + """ + open_func_char = '' + has_signature = False + has_multisignature = False + language = getattr(self, 'language', language).lower() + signature_or_text = signature_or_text.replace('\\*', '*') + + # Remove special symbols that could itefere with ''.format + signature_or_text = signature_or_text.replace('{', '{') + signature_or_text = signature_or_text.replace('}', '}') + + # Remove 'ufunc' signature if needed. See spyder-ide/spyder#11821 + lines = [line for line in signature_or_text.split('\n') + if 'ufunc' not in line] + signature_or_text = '\n'.join(lines) + + if language == 'python': + open_func_char = '(' + has_multisignature = False + + if inspect_word: + has_signature = signature_or_text.startswith(inspect_word) + else: + idx = signature_or_text.find(open_func_char) + inspect_word = signature_or_text[:idx] + has_signature = True + + if has_signature: + name_plus_char = inspect_word + open_func_char + + all_lines = [] + for line in lines: + if (line.startswith(name_plus_char) + and line.count(name_plus_char) > 1): + sublines = line.split(name_plus_char) + sublines = [name_plus_char + l for l in sublines] + sublines = [l.strip() for l in sublines] + else: + sublines = [line] + + all_lines = all_lines + sublines + + lines = all_lines + count = 0 + for line in lines: + if line.startswith(name_plus_char): + count += 1 + + # Signature type + has_signature = count == 1 + has_multisignature = count > 1 and len(lines) > 1 + + if has_signature and not has_multisignature: + for i, line in enumerate(lines): + if line.strip() == '': + break + + if i == 0: + signature = lines[0] + extra_text = None + else: + signature = '\n'.join(lines[:i]) + extra_text = '\n'.join(lines[i:]) + + if signature: + new_signature = self._format_signature( + signatures=signature, + parameter=parameter, + max_width=max_width + ) + elif has_multisignature: + signature = signature_or_text.replace(name_plus_char, + '
    ' + name_plus_char) + signature = signature[4:] # Remove the first line break + signature = signature.replace('\n', ' ') + signature = signature.replace(r'\\*', '*') + signature = signature.replace(r'\*', '*') + signature = signature.replace('
    ', '\n') + signatures = signature.split('\n') + signatures = [sig for sig in signatures if sig] # Remove empty + new_signature = self._format_signature( + signatures=signatures, + parameter=parameter, + max_width=max_width + ) + extra_text = None + else: + new_signature = None + extra_text = signature_or_text + + return new_signature, extra_text, inspect_word + + def show_calltip(self, signature, parameter=None, documentation=None, + language=_DEFAULT_LANGUAGE, max_lines=_DEFAULT_MAX_LINES, + max_width=_DEFAULT_MAX_WIDTH, text_new_line=True): + """ + Show calltip. + + Calltips look like tooltips but will not disappear if mouse hovers + them. They are useful for displaying signature information on methods + and functions. + """ + # Find position of calltip + point = self._calculate_position() + signature = signature.strip() + inspect_word = None + language = getattr(self, 'language', language).lower() + if language == 'python' and signature: + inspect_word = signature.split('(')[0] + # Check if documentation is better than signature, sometimes + # signature has \n stripped for functions like print, type etc + check_doc = ' ' + if documentation: + check_doc.join(documentation.split()).replace('\\*', '*') + check_sig = ' '.join(signature.split()) + if check_doc == check_sig: + signature = documentation + documentation = '' + + # Remove duplicate signature inside documentation + if documentation: + documentation = documentation.replace('\\*', '*') + if signature.strip(): + documentation = documentation.replace(signature + '\n', '') + + # Format + res = self._check_signature_and_format(signature, parameter, + inspect_word=inspect_word, + language=language, + max_width=max_width) + new_signature, text, inspect_word = res + text = self._format_text( + signature=new_signature, + inspect_word=inspect_word, + display_link=False, + text=documentation, + max_lines=max_lines, + max_width=max_width, + text_new_line=text_new_line + ) + + self._update_stylesheet(self.calltip_widget) + + # Show calltip + self.calltip_widget.show_tip(point, text, []) + self.calltip_widget.show() + + def show_tooltip(self, title=None, signature=None, text=None, + inspect_word=None, title_color=_DEFAULT_TITLE_COLOR, + at_line=None, at_point=None, display_link=False, + max_lines=_DEFAULT_MAX_LINES, + max_width=_DEFAULT_MAX_WIDTH, + cursor=None, + with_html_format=False, + text_new_line=True, + completion_doc=None): + """Show tooltip.""" + # Find position of calltip + point = self._calculate_position( + at_line=at_line, + at_point=at_point, + ) + # Format text + tiptext = self._format_text( + title=title, + signature=signature, + text=text, + title_color=title_color, + inspect_word=inspect_word, + display_link=display_link, + max_lines=max_lines, + max_width=max_width, + with_html_format=with_html_format, + text_new_line=text_new_line + ) + + self._update_stylesheet(self.tooltip_widget) + + # Display tooltip + self.tooltip_widget.show_tip(point, tiptext, cursor=cursor, + completion_doc=completion_doc) + + def show_hint(self, text, inspect_word, at_point, + max_lines=_DEFAULT_MAX_HINT_LINES, + max_width=_DEFAULT_MAX_HINT_WIDTH, + text_new_line=True, completion_doc=None): + """Show code hint and crop text as needed.""" + res = self._check_signature_and_format(text, max_width=max_width, + inspect_word=inspect_word) + html_signature, extra_text, _ = res + point = self.get_word_start_pos(at_point) + + # Only display hover hint if there is documentation + if extra_text is not None: + # This is needed to get hover hints + cursor = self.cursorForPosition(at_point) + cursor.movePosition(QTextCursor.StartOfWord, + QTextCursor.MoveAnchor) + self._last_hover_cursor = cursor + + self.show_tooltip(signature=html_signature, text=extra_text, + at_point=point, inspect_word=inspect_word, + display_link=True, max_lines=max_lines, + max_width=max_width, cursor=cursor, + text_new_line=text_new_line, + completion_doc=completion_doc) + + def hide_tooltip(self): + """ + Hide the tooltip widget. + + The tooltip widget is a special QLabel that looks like a tooltip, + this method is here so it can be hidden as necessary. For example, + when the user leaves the Linenumber area when hovering over lint + warnings and errors. + """ + self._last_hover_cursor = None + self._last_hover_word = None + self._last_point = None + self.tooltip_widget.hide() + + # ----- Required methods for the LSP + def document_did_change(self, text=None): + pass + + #------EOL characters + def set_eol_chars(self, text=None, eol_chars=None): + """ + Set widget end-of-line (EOL) characters. + + Parameters + ---------- + text: str + Text to detect EOL characters from. + eol_chars: str + EOL characters to set. + + Notes + ----- + If `text` is passed, then `eol_chars` has no effect. + """ + if text is not None: + detected_eol_chars = sourcecode.get_eol_chars(text) + is_document_modified = ( + detected_eol_chars is not None and self.eol_chars is not None + ) + self.eol_chars = detected_eol_chars + elif eol_chars is not None: + is_document_modified = eol_chars != self.eol_chars + self.eol_chars = eol_chars + + if is_document_modified: + self.document().setModified(True) + if self.sig_eol_chars_changed is not None: + self.sig_eol_chars_changed.emit(eol_chars) + + def get_line_separator(self): + """Return line separator based on current EOL mode""" + if self.eol_chars is not None: + return self.eol_chars + else: + return os.linesep + + def get_text_with_eol(self): + """ + Same as 'toPlainText', replacing '\n' by correct end-of-line + characters. + """ + text = self.toPlainText() + linesep = self.get_line_separator() + for symbol in EOL_SYMBOLS: + text = text.replace(symbol, linesep) + return text + + #------Positions, coordinates (cursor, EOF, ...) + def get_position(self, subject): + """Get offset in character for the given subject from the start of + text edit area""" + cursor = self.textCursor() + if subject == 'cursor': + pass + elif subject == 'sol': + cursor.movePosition(QTextCursor.StartOfBlock) + elif subject == 'eol': + cursor.movePosition(QTextCursor.EndOfBlock) + elif subject == 'eof': + cursor.movePosition(QTextCursor.End) + elif subject == 'sof': + cursor.movePosition(QTextCursor.Start) + else: + # Assuming that input argument was already a position + return subject + return cursor.position() + + def get_coordinates(self, position): + position = self.get_position(position) + cursor = self.textCursor() + cursor.setPosition(position) + point = self.cursorRect(cursor).center() + return point.x(), point.y() + + def _is_point_inside_word_rect(self, point): + """ + Check if the mouse is within the rect of the cursor current word. + """ + cursor = self.cursorForPosition(point) + cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) + start_rect = self.cursorRect(cursor) + cursor.movePosition(QTextCursor.EndOfWord, QTextCursor.MoveAnchor) + end_rect = self.cursorRect(cursor) + bounding_rect = start_rect.united(end_rect) + return bounding_rect.contains(point) + + def get_word_start_pos(self, position): + """ + Find start position (lower bottom) of a word being hovered by mouse. + """ + cursor = self.cursorForPosition(position) + cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor) + rect = self.cursorRect(cursor) + pos = QPoint(rect.left() + 4, rect.top()) + return pos + + def get_last_hover_word(self): + """Return the last (or active) hover word.""" + return self._last_hover_word + + def get_last_hover_cursor(self): + """Return the last (or active) hover cursor.""" + return self._last_hover_cursor + + def get_cursor_line_column(self, cursor=None): + """ + Return `cursor` (line, column) numbers. + + If no `cursor` is provided, use the current text cursor. + """ + if cursor is None: + cursor = self.textCursor() + + return cursor.blockNumber(), cursor.columnNumber() + + def get_cursor_line_number(self): + """Return cursor line number""" + return self.textCursor().blockNumber()+1 + + def get_position_line_number(self, line, col): + """Get position offset from (line, col) coordinates.""" + block = self.document().findBlockByNumber(line) + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, + n=col + 1) + return cursor.position() + + def set_cursor_position(self, position): + """Set cursor position""" + position = self.get_position(position) + cursor = self.textCursor() + cursor.setPosition(position) + self.setTextCursor(cursor) + self.ensureCursorVisible() + + def move_cursor(self, chars=0): + """Move cursor to left or right (unit: characters)""" + direction = QTextCursor.Right if chars > 0 else QTextCursor.Left + for _i in range(abs(chars)): + self.moveCursor(direction, QTextCursor.MoveAnchor) + + def is_cursor_on_first_line(self): + """Return True if cursor is on the first line""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.StartOfBlock) + return cursor.atStart() + + def is_cursor_on_last_line(self): + """Return True if cursor is on the last line""" + cursor = self.textCursor() + cursor.movePosition(QTextCursor.EndOfBlock) + return cursor.atEnd() + + def is_cursor_at_end(self): + """Return True if cursor is at the end of the text""" + return self.textCursor().atEnd() + + def is_cursor_before(self, position, char_offset=0): + """Return True if cursor is before *position*""" + position = self.get_position(position) + char_offset + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + if position < cursor.position(): + cursor.setPosition(position) + return self.textCursor() < cursor + + def __move_cursor_anchor(self, what, direction, move_mode): + assert what in ('character', 'word', 'line') + if what == 'character': + if direction == 'left': + self.moveCursor(QTextCursor.PreviousCharacter, move_mode) + elif direction == 'right': + self.moveCursor(QTextCursor.NextCharacter, move_mode) + elif what == 'word': + if direction == 'left': + self.moveCursor(QTextCursor.PreviousWord, move_mode) + elif direction == 'right': + self.moveCursor(QTextCursor.NextWord, move_mode) + elif what == 'line': + if direction == 'down': + self.moveCursor(QTextCursor.NextBlock, move_mode) + elif direction == 'up': + self.moveCursor(QTextCursor.PreviousBlock, move_mode) + + def move_cursor_to_next(self, what='word', direction='left'): + """ + Move cursor to next *what* ('word' or 'character') + toward *direction* ('left' or 'right') + """ + self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) + + #------Selection + def extend_selection_to_next(self, what='word', direction='left'): + """ + Extend selection to next *what* ('word' or 'character') + toward *direction* ('left' or 'right') + """ + self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) + + #------Text: get, set, ... + + def _select_text(self, position_from, position_to): + """Select text and return cursor.""" + position_from = self.get_position(position_from) + position_to = self.get_position(position_to) + cursor = self.textCursor() + cursor.setPosition(position_from) + cursor.setPosition(position_to, QTextCursor.KeepAnchor) + return cursor + + def get_text_line(self, line_nb): + """Return text line at line number *line_nb*""" + block = self.document().findBlockByNumber(line_nb) + cursor = QTextCursor(block) + cursor.movePosition(QTextCursor.StartOfBlock) + cursor.movePosition(QTextCursor.EndOfBlock, mode=QTextCursor.KeepAnchor) + return to_text_string(cursor.selectedText()) + + def get_text_region(self, start_line, end_line): + """Return text lines spanned from *start_line* to *end_line*.""" + start_block = self.document().findBlockByNumber(start_line) + end_block = self.document().findBlockByNumber(end_line) + + start_cursor = QTextCursor(start_block) + start_cursor.movePosition(QTextCursor.StartOfBlock) + end_cursor = QTextCursor(end_block) + end_cursor.movePosition(QTextCursor.EndOfBlock) + end_position = end_cursor.position() + start_cursor.setPosition(end_position, mode=QTextCursor.KeepAnchor) + return self.get_selected_text(start_cursor) + + def get_text(self, position_from, position_to, remove_newlines=True): + """Returns text between *position_from* and *position_to*. + + Positions may be integers or 'sol', 'eol', 'sof', 'eof' or 'cursor'. + + Unless position_from='sof' and position_to='eof' any trailing newlines + in the string are removed. This was added as a workaround for + spyder-ide/spyder#1546 and later caused spyder-ide/spyder#14374. + The behaviour can be overridden by setting the optional parameter + *remove_newlines* to False. + + TODO: Evaluate if this is still a problem and if the workaround can + be moved closer to where the problem occurs. + """ + cursor = self._select_text(position_from, position_to) + text = to_text_string(cursor.selectedText()) + if remove_newlines: + remove_newlines = position_from != 'sof' or position_to != 'eof' + if text and remove_newlines: + while text and text[-1] in EOL_SYMBOLS: + text = text[:-1] + return text + + def get_character(self, position, offset=0): + """Return character at *position* with the given offset.""" + position = self.get_position(position) + offset + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + if position < cursor.position(): + cursor.setPosition(position) + cursor.movePosition(QTextCursor.Right, + QTextCursor.KeepAnchor) + return to_text_string(cursor.selectedText()) + else: + return '' + + def insert_text(self, text, will_insert_text=True): + """Insert text at cursor position""" + if not self.isReadOnly(): + if will_insert_text and self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + self.textCursor().insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + + def replace_text(self, position_from, position_to, text): + cursor = self._select_text(position_from, position_to) + if self.sig_will_remove_selection is not None: + start, end = self.get_selection_start_end(cursor) + self.sig_will_remove_selection.emit(start, end) + cursor.removeSelectedText() + if self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + cursor.insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + + def remove_text(self, position_from, position_to): + cursor = self._select_text(position_from, position_to) + if self.sig_will_remove_selection is not None: + start, end = self.get_selection_start_end(cursor) + self.sig_will_remove_selection.emit(start, end) + cursor.removeSelectedText() + + def get_current_object(self): + """ + Return current object under cursor. + + Get the text of the current word plus all the characters + to the left until a space is found. Used to get text to inspect + for Help of elements following dot notation for example + np.linalg.norm + """ + cursor = self.textCursor() + cursor_pos = cursor.position() + current_word = self.get_current_word(help_req=True) + + # Get max position to the left of cursor until space or no more + # characters are left + cursor.movePosition(QTextCursor.PreviousCharacter) + while self.get_character(cursor.position()).strip(): + cursor.movePosition(QTextCursor.PreviousCharacter) + if cursor.atBlockStart(): + break + cursor_pos_left = cursor.position() + + # Get max position to the right of cursor until space or no more + # characters are left + cursor.setPosition(cursor_pos) + while self.get_character(cursor.position()).strip(): + cursor.movePosition(QTextCursor.NextCharacter) + if cursor.atBlockEnd(): + break + cursor_pos_right = cursor.position() + + # Get text of the object under the cursor + current_text = self.get_text( + cursor_pos_left, cursor_pos_right).strip() + current_object = current_word + + if current_text and current_word is not None: + if current_word != current_text and current_word in current_text: + current_object = ( + current_text.split(current_word)[0] + current_word) + + return current_object + + def get_current_word_and_position(self, completion=False, help_req=False, + valid_python_variable=True): + """ + Return current word, i.e. word at cursor position, and the start + position. + """ + cursor = self.textCursor() + cursor_pos = cursor.position() + + if cursor.hasSelection(): + # Removes the selection and moves the cursor to the left side + # of the selection: this is required to be able to properly + # select the whole word under cursor (otherwise, the same word is + # not selected when the cursor is at the right side of it): + cursor.setPosition(min([cursor.selectionStart(), + cursor.selectionEnd()])) + else: + # Checks if the first character to the right is a white space + # and if not, moves the cursor one word to the left (otherwise, + # if the character to the left do not match the "word regexp" + # (see below), the word to the left of the cursor won't be + # selected), but only if the first character to the left is not a + # white space too. + def is_space(move): + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + return not to_text_string(curs.selectedText()).strip() + + def is_special_character(move): + """Check if a character is a non-letter including numbers.""" + curs = self.textCursor() + curs.movePosition(move, QTextCursor.KeepAnchor) + text_cursor = to_text_string(curs.selectedText()).strip() + return len( + re.findall(r'([^\d\W]\w*)', text_cursor, re.UNICODE)) == 0 + + if help_req: + if is_special_character(QTextCursor.PreviousCharacter): + cursor.movePosition(QTextCursor.NextCharacter) + elif is_special_character(QTextCursor.NextCharacter): + cursor.movePosition(QTextCursor.PreviousCharacter) + elif not completion: + if is_space(QTextCursor.NextCharacter): + if is_space(QTextCursor.PreviousCharacter): + return + cursor.movePosition(QTextCursor.WordLeft) + else: + if is_space(QTextCursor.PreviousCharacter): + return + if (is_special_character(QTextCursor.NextCharacter)): + cursor.movePosition(QTextCursor.WordLeft) + + cursor.select(QTextCursor.WordUnderCursor) + text = to_text_string(cursor.selectedText()) + startpos = cursor.selectionStart() + + # Find a valid Python variable name + if valid_python_variable: + match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE) + if not match: + # This is assumed in several places of our codebase, + # so please don't change this return! + return None + else: + text = match[0] + + if completion: + text = text[:cursor_pos - startpos] + + return text, startpos + + def get_current_word(self, completion=False, help_req=False, + valid_python_variable=True): + """Return current word, i.e. word at cursor position.""" + ret = self.get_current_word_and_position( + completion=completion, + help_req=help_req, + valid_python_variable=valid_python_variable + ) + + if ret is not None: + return ret[0] + + def get_hover_word(self): + """Return the last hover word that requested a hover hint.""" + return self._last_hover_word + + def get_current_line(self): + """Return current line's text.""" + cursor = self.textCursor() + cursor.select(QTextCursor.BlockUnderCursor) + return to_text_string(cursor.selectedText()) + + def get_current_line_to_cursor(self): + """Return text from prompt to cursor.""" + return self.get_text(self.current_prompt_pos, 'cursor') + + def get_line_number_at(self, coordinates): + """Return line number at *coordinates* (QPoint).""" + cursor = self.cursorForPosition(coordinates) + return cursor.blockNumber() + 1 + + def get_line_at(self, coordinates): + """Return line at *coordinates* (QPoint).""" + cursor = self.cursorForPosition(coordinates) + cursor.select(QTextCursor.BlockUnderCursor) + return to_text_string(cursor.selectedText()).replace(u'\u2029', '') + + def get_word_at(self, coordinates): + """Return word at *coordinates* (QPoint).""" + cursor = self.cursorForPosition(coordinates) + cursor.select(QTextCursor.WordUnderCursor) + if self._is_point_inside_word_rect(coordinates): + word = to_text_string(cursor.selectedText()) + else: + word = '' + + return word + + def get_line_indentation(self, text): + """Get indentation for given line.""" + text = text.replace("\t", " "*self.tab_stop_width_spaces) + return len(text)-len(text.lstrip()) + + def get_block_indentation(self, block_nb): + """Return line indentation (character number).""" + text = to_text_string(self.document().findBlockByNumber(block_nb).text()) + return self.get_line_indentation(text) + + def get_selection_bounds(self, cursor=None): + """Return selection bounds (block numbers).""" + if cursor is None: + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + block_start = self.document().findBlock(start) + block_end = self.document().findBlock(end) + return sorted([block_start.blockNumber(), block_end.blockNumber()]) + + def get_selection_start_end(self, cursor=None): + """Return selection start and end (line, column) positions.""" + if cursor is None: + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + start_cursor = QTextCursor(cursor) + start_cursor.setPosition(start) + start_position = self.get_cursor_line_column(start_cursor) + end_cursor = QTextCursor(cursor) + end_cursor.setPosition(end) + end_position = self.get_cursor_line_column(end_cursor) + return start_position, end_position + + #------Text selection + def has_selected_text(self): + """Returns True if some text is selected.""" + return bool(to_text_string(self.textCursor().selectedText())) + + def get_selected_text(self, cursor=None): + """ + Return text selected by current text cursor, converted in unicode. + + Replace the unicode line separator character \u2029 by + the line separator characters returned by get_line_separator + """ + if cursor is None: + cursor = self.textCursor() + return to_text_string(cursor.selectedText()).replace(u"\u2029", + self.get_line_separator()) + + def remove_selected_text(self): + """Delete selected text.""" + self.textCursor().removeSelectedText() + # The next three lines are a workaround for a quirk of + # QTextEdit on Linux with Qt < 5.15, MacOs and Windows. + # See spyder-ide/spyder#12663 and + # https://bugreports.qt.io/browse/QTBUG-35861 + if (parse_version(QT_VERSION) < parse_version('5.15') + or os.name == 'nt' or sys.platform == 'darwin'): + cursor = self.textCursor() + cursor.setPosition(cursor.position()) + self.setTextCursor(cursor) + + def replace(self, text, pattern=None): + """Replace selected text by *text*. + + If *pattern* is not None, replacing selected text using regular + expression text substitution.""" + cursor = self.textCursor() + cursor.beginEditBlock() + if pattern is not None: + seltxt = to_text_string(cursor.selectedText()) + if self.sig_will_remove_selection is not None: + start, end = self.get_selection_start_end(cursor) + self.sig_will_remove_selection.emit(start, end) + cursor.removeSelectedText() + if pattern is not None: + text = re.sub(to_text_string(pattern), + to_text_string(text), to_text_string(seltxt)) + if self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + cursor.insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + cursor.endEditBlock() + + + #------Find/replace + def find_multiline_pattern(self, regexp, cursor, findflag): + """Reimplement QTextDocument's find method. + + Add support for *multiline* regular expressions.""" + pattern = to_text_string(regexp.pattern()) + text = to_text_string(self.toPlainText()) + try: + regobj = re.compile(pattern) + except sre_constants.error: + return + if findflag & QTextDocument.FindBackward: + # Find backward + offset = min([cursor.selectionEnd(), cursor.selectionStart()]) + text = text[:offset] + matches = [_m for _m in regobj.finditer(text, 0, offset)] + if matches: + match = matches[-1] + else: + return + else: + # Find forward + offset = max([cursor.selectionEnd(), cursor.selectionStart()]) + match = regobj.search(text, offset) + if match: + pos1, pos2 = sh.get_span(match) + fcursor = self.textCursor() + fcursor.setPosition(pos1) + fcursor.setPosition(pos2, QTextCursor.KeepAnchor) + return fcursor + + def find_text(self, text, changed=True, forward=True, case=False, + word=False, regexp=False): + """Find text.""" + cursor = self.textCursor() + findflag = QTextDocument.FindFlag() + + # Get visible region to center cursor in case it's necessary. + if getattr(self, 'get_visible_block_numbers', False): + current_visible_region = self.get_visible_block_numbers() + else: + current_visible_region = None + + if not forward: + findflag = findflag | QTextDocument.FindBackward + + if case: + findflag = findflag | QTextDocument.FindCaseSensitively + + moves = [QTextCursor.NoMove] + if forward: + moves += [QTextCursor.NextWord, QTextCursor.Start] + if changed: + if to_text_string(cursor.selectedText()): + new_position = min([cursor.selectionStart(), + cursor.selectionEnd()]) + cursor.setPosition(new_position) + else: + cursor.movePosition(QTextCursor.PreviousWord) + else: + moves += [QTextCursor.End] + + if regexp: + text = to_text_string(text) + else: + text = re.escape(to_text_string(text)) + + pattern = QRegularExpression(u"\\b{}\\b".format(text) if word else + text) + if case: + pattern.setPatternOptions(QRegularExpression.CaseInsensitiveOption) + + for move in moves: + cursor.movePosition(move) + if regexp and '\\n' in text: + # Multiline regular expression + found_cursor = self.find_multiline_pattern(pattern, cursor, + findflag) + else: + # Single line find: using the QTextDocument's find function, + # probably much more efficient than ours + found_cursor = self.document().find(pattern, cursor, findflag) + if found_cursor is not None and not found_cursor.isNull(): + self.setTextCursor(found_cursor) + + # Center cursor if we move out of the visible region. + if current_visible_region is not None: + found_visible_region = self.get_visible_block_numbers() + if current_visible_region != found_visible_region: + current_visible_region = found_visible_region + self.centerCursor() + + return True + + return False + + def is_editor(self): + """Needs to be overloaded in the codeeditor where it will be True""" + return False + + def get_number_matches(self, pattern, source_text='', case=False, + regexp=False, word=False): + """Get the number of matches for the searched text.""" + pattern = to_text_string(pattern) + if not pattern: + return 0 + + if not regexp: + pattern = re.escape(pattern) + + if not source_text: + source_text = to_text_string(self.toPlainText()) + + if word: # match whole words only + pattern = r'\b{pattern}\b'.format(pattern=pattern) + try: + re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE + regobj = re.compile(pattern, flags=re_flags) + except sre_constants.error: + return None + + number_matches = 0 + for match in regobj.finditer(source_text): + number_matches += 1 + + return number_matches + + def get_match_number(self, pattern, case=False, regexp=False, word=False): + """Get number of the match for the searched text.""" + position = self.textCursor().position() + source_text = self.get_text(position_from='sof', position_to=position) + match_number = self.get_number_matches(pattern, + source_text=source_text, + case=case, regexp=regexp, + word=word) + return match_number + + # --- Array builder helper / See 'spyder/widgets/arraybuilder.py' + def enter_array_inline(self): + """Enter array builder inline mode.""" + self._enter_array(True) + + def enter_array_table(self): + """Enter array builder table mode.""" + self._enter_array(False) + + def _enter_array(self, inline): + """Enter array builder mode.""" + offset = self.get_position('cursor') - self.get_position('sol') + rect = self.cursorRect() + dlg = ArrayBuilderDialog(self, inline, offset) + + # TODO: adapt to font size + x = rect.left() + x = int(x - 14) + y = rect.top() + (rect.bottom() - rect.top())/2 + y = int(y - dlg.height()/2 - 3) + + pos = QPoint(x, y) + pos = self.calculate_real_position(pos) + dlg.move(self.mapToGlobal(pos)) + + # called from editor + if self.is_editor(): + python_like_check = self.is_python_like() + suffix = '\n' + # called from a console + else: + python_like_check = True + suffix = '' + + if python_like_check and dlg.exec_(): + text = dlg.text() + suffix + if text != '': + cursor = self.textCursor() + cursor.beginEditBlock() + if self.sig_will_insert_text is not None: + self.sig_will_insert_text.emit(text) + cursor.insertText(text) + if self.sig_text_was_inserted is not None: + self.sig_text_was_inserted.emit() + cursor.endEditBlock() + + +class TracebackLinksMixin(object): + """ """ + QT_CLASS = None + + # This signal emits a parsed error traceback text so we can then + # request opening the file that traceback comes from in the Editor. + sig_go_to_error_requested = None + + def __init__(self): + self.__cursor_changed = False + self.setMouseTracking(True) + + #------Mouse events + def mouseReleaseEvent(self, event): + """Go to error""" + self.QT_CLASS.mouseReleaseEvent(self, event) + text = self.get_line_at(event.pos()) + if get_error_match(text) and not self.has_selected_text(): + if self.sig_go_to_error_requested is not None: + self.sig_go_to_error_requested.emit(text) + + def mouseMoveEvent(self, event): + """Show Pointing Hand Cursor on error messages""" + text = self.get_line_at(event.pos()) + if get_error_match(text): + if not self.__cursor_changed: + QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) + self.__cursor_changed = True + event.accept() + return + if self.__cursor_changed: + QApplication.restoreOverrideCursor() + self.__cursor_changed = False + self.QT_CLASS.mouseMoveEvent(self, event) + + def leaveEvent(self, event): + """If cursor has not been restored yet, do it now""" + if self.__cursor_changed: + QApplication.restoreOverrideCursor() + self.__cursor_changed = False + self.QT_CLASS.leaveEvent(self, event) + + +class GetHelpMixin(object): + + def __init__(self): + self.help_enabled = False + + def set_help_enabled(self, state): + self.help_enabled = state + + def inspect_current_object(self): + current_object = self.get_current_object() + if current_object is not None: + self.show_object_info(current_object, force=True) + + def show_object_info(self, text, call=False, force=False): + """Show signature calltip and/or docstring in the Help plugin""" + text = to_text_string(text) + + # Show docstring + help_enabled = self.help_enabled or force + if help_enabled: + doc = { + 'name': text, + 'ignore_unknown': False, + } + self.sig_help_requested.emit(doc) + + # Show calltip + if call and getattr(self, 'calltips', None): + # Display argument list if this is a function call + iscallable = self.iscallable(text) + if iscallable is not None: + if iscallable: + arglist = self.get_arglist(text) + name = text.split('.')[-1] + argspec = signature = '' + if isinstance(arglist, bool): + arglist = [] + if arglist: + argspec = '(' + ''.join(arglist) + ')' + else: + doc = self.get__doc__(text) + if doc is not None: + # This covers cases like np.abs, whose docstring is + # the same as np.absolute and because of that a + # proper signature can't be obtained correctly + argspec = getargspecfromtext(doc) + if not argspec: + signature = getsignaturefromtext(doc, name) + if argspec or signature: + if argspec: + tiptext = name + argspec + else: + tiptext = signature + # TODO: Select language and pass it to call + self.show_calltip(tiptext) + + def get_last_obj(self, last=False): + """ + Return the last valid object on the current line + """ + return getobj(self.get_current_line_to_cursor(), last=last) + + +class SaveHistoryMixin(object): + + INITHISTORY = None + SEPARATOR = None + HISTORY_FILENAMES = [] + + sig_append_to_history_requested = None + + def __init__(self, history_filename=''): + self.history_filename = history_filename + self.create_history_filename() + + def create_history_filename(self): + """Create history_filename with INITHISTORY if it doesn't exist.""" + if self.history_filename and not osp.isfile(self.history_filename): + try: + encoding.writelines(self.INITHISTORY, self.history_filename) + except EnvironmentError: + pass + + def add_to_history(self, command): + """Add command to history""" + command = to_text_string(command) + if command in ['', '\n'] or command.startswith('Traceback'): + return + if command.endswith('\n'): + command = command[:-1] + self.histidx = None + if len(self.history) > 0 and self.history[-1] == command: + return + self.history.append(command) + text = os.linesep + command + + # When the first entry will be written in history file, + # the separator will be append first: + if self.history_filename not in self.HISTORY_FILENAMES: + self.HISTORY_FILENAMES.append(self.history_filename) + text = self.SEPARATOR + text + # Needed to prevent errors when writing history to disk + # See spyder-ide/spyder#6431. + try: + encoding.write(text, self.history_filename, mode='ab') + except EnvironmentError: + pass + if self.sig_append_to_history_requested is not None: + self.sig_append_to_history_requested.emit( + self.history_filename, text) + + +class BrowseHistory(object): + + def __init__(self): + self.history = [] + self.histidx = None + self.hist_wholeline = False + + def browse_history(self, line, cursor_pos, backward): + """ + Browse history. + + Return the new text and wherever the cursor should move. + """ + if cursor_pos < len(line) and self.hist_wholeline: + self.hist_wholeline = False + tocursor = line[:cursor_pos] + text, self.histidx = self.find_in_history(tocursor, self.histidx, + backward) + if text is not None: + text = text.strip() + if self.hist_wholeline: + return text, True + else: + return tocursor + text, False + return None, False + + def find_in_history(self, tocursor, start_idx, backward): + """Find text 'tocursor' in history, from index 'start_idx'""" + if start_idx is None: + start_idx = len(self.history) + # Finding text in history + step = -1 if backward else 1 + idx = start_idx + if len(tocursor) == 0 or self.hist_wholeline: + idx += step + if idx >= len(self.history) or len(self.history) == 0: + return "", len(self.history) + elif idx < 0: + idx = 0 + self.hist_wholeline = True + return self.history[idx], idx + else: + for index in range(len(self.history)): + idx = (start_idx+step*(index+1)) % len(self.history) + entry = self.history[idx] + if entry.startswith(tocursor): + return entry[len(tocursor):], idx + else: + return None, start_idx + + def reset_search_pos(self): + """Reset the position from which to search the history""" + self.histidx = None + + +class BrowseHistoryMixin(BrowseHistory): + + def clear_line(self): + """Clear current line (without clearing console prompt)""" + self.remove_text(self.current_prompt_pos, 'eof') + + def browse_history(self, backward): + """Browse history""" + line = self.get_text(self.current_prompt_pos, 'eof') + old_pos = self.get_position('cursor') + cursor_pos = self.get_position('cursor') - self.current_prompt_pos + if cursor_pos < 0: + cursor_pos = 0 + self.set_cursor_position(self.current_prompt_pos) + text, move_cursor = super(BrowseHistoryMixin, self).browse_history( + line, cursor_pos, backward) + if text is not None: + self.clear_line() + self.insert_text(text) + if not move_cursor: + self.set_cursor_position(old_pos) diff --git a/spyder/widgets/onecolumntree.py b/spyder/widgets/onecolumntree.py index 240a5133ed1..7804903bd7d 100644 --- a/spyder/widgets/onecolumntree.py +++ b/spyder/widgets/onecolumntree.py @@ -1,307 +1,307 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -# Third party imports -from qtpy import PYQT5 -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QTreeWidget - -# Local imports -from spyder.api.widgets.mixins import SpyderWidgetMixin -from spyder.config.base import _ -from spyder.utils.icon_manager import ima -from spyder.utils.qthelpers import get_item_user_text - - -class OneColumnTreeActions: - CollapseAllAction = "collapse_all_action" - ExpandAllAction = "expand_all_action" - RestoreAction = "restore_action" - CollapseSelectionAction = "collapse_selection_action" - ExpandSelectionAction = "expand_selection_action" - - -class OneColumnTreeContextMenuSections: - Global = "global_section" - Restore = "restore_section" - Section = "section_section" - History = "history_section" - - -class OneColumnTree(QTreeWidget, SpyderWidgetMixin): - """ - One-column tree widget with context menu. - """ - - def __init__(self, parent): - if PYQT5: - super().__init__(parent, class_parent=parent) - else: - QTreeWidget.__init__(self, parent) - SpyderWidgetMixin.__init__(self, class_parent=parent) - - self.__expanded_state = None - - # Widget setup - self.setItemsExpandable(True) - self.setColumnCount(1) - - # Setup context menu - self.collapse_all_action = None - self.collapse_selection_action = None - self.expand_all_action = None - self.expand_selection_action = None - self.setup() - self.common_actions = self.setup_common_actions() - - # Signals - self.itemActivated.connect(self.activated) - self.itemClicked.connect(self.clicked) - self.itemSelectionChanged.connect(self.item_selection_changed) - - # To use mouseMoveEvent - self.setMouseTracking(True) - - # Use horizontal scrollbar when needed - self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) - self.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) - self.header().setStretchLastSection(False) - - self.item_selection_changed() - - # ---- SpyderWidgetMixin API - # ------------------------------------------------------------------------- - def setup(self): - self.menu = self.create_menu("context_menu") - - self.collapse_all_action = self.create_action( - OneColumnTreeActions.CollapseAllAction, - text=_("Collapse all"), - icon=ima.icon("collapse"), - triggered=self.collapseAll, - register_shortcut=False, - ) - self.expand_all_action = self.create_action( - OneColumnTreeActions.ExpandAllAction, - text=_("Expand all"), - icon=ima.icon("expand"), - triggered=self.expandAll, - register_shortcut=False, - ) - self.restore_action = self.create_action( - OneColumnTreeActions.RestoreAction, - text=_("Restore"), - tip=_("Restore original tree layout"), - icon=ima.icon("restore"), - triggered=self.restore, - register_shortcut=False, - ) - self.collapse_selection_action = self.create_action( - OneColumnTreeActions.CollapseSelectionAction, - text=_("Collapse section"), - icon=ima.icon("collapse_selection"), - triggered=self.collapse_selection, - register_shortcut=False, - ) - self.expand_selection_action = self.create_action( - OneColumnTreeActions.ExpandSelectionAction, - text=_("Expand section"), - icon=ima.icon("expand_selection"), - triggered=self.expand_selection, - register_shortcut=False, - ) - - for item in [self.collapse_all_action, self.expand_all_action]: - self.add_item_to_menu( - item, - self.menu, - section=OneColumnTreeContextMenuSections.Global, - ) - - self.add_item_to_menu( - self.restore_action, - self.menu, - section=OneColumnTreeContextMenuSections.Restore, - ) - for item in [self.collapse_selection_action, - self.expand_selection_action]: - self.add_item_to_menu( - item, - self.menu, - section=OneColumnTreeContextMenuSections.Section, - ) - - def update_actions(self): - pass - - # ---- Public API - # ------------------------------------------------------------------------- - def activated(self, item): - """Double-click event""" - raise NotImplementedError - - def clicked(self, item): - pass - - def set_title(self, title): - self.setHeaderLabels([title]) - - def setup_common_actions(self): - """Setup context menu common actions""" - return [self.collapse_all_action, self.expand_all_action, - self.collapse_selection_action, self.expand_selection_action] - - def get_menu_actions(self): - """Returns a list of menu actions""" - items = self.selectedItems() - actions = self.get_actions_from_items(items) - if actions: - actions.append(None) - - actions += self.common_actions - return actions - - def get_actions_from_items(self, items): - # Right here: add other actions if necessary - # (reimplement this method) - return [] - - @Slot() - def restore(self): - self.collapseAll() - for item in self.get_top_level_items(): - self.expandItem(item) - - def is_item_expandable(self, item): - """To be reimplemented in child class - See example in project explorer widget""" - return True - - def __expand_item(self, item): - if self.is_item_expandable(item): - self.expandItem(item) - for index in range(item.childCount()): - child = item.child(index) - self.__expand_item(child) - - @Slot() - def expand_selection(self): - items = self.selectedItems() - if not items: - items = self.get_top_level_items() - for item in items: - self.__expand_item(item) - if items: - self.scrollToItem(items[0]) - - def __collapse_item(self, item): - self.collapseItem(item) - for index in range(item.childCount()): - child = item.child(index) - self.__collapse_item(child) - - @Slot() - def collapse_selection(self): - items = self.selectedItems() - if not items: - items = self.get_top_level_items() - for item in items: - self.__collapse_item(item) - if items: - self.scrollToItem(items[0]) - - def item_selection_changed(self): - """Item selection has changed""" - is_selection = len(self.selectedItems()) > 0 - self.expand_selection_action.setEnabled(is_selection) - self.collapse_selection_action.setEnabled(is_selection) - - def get_top_level_items(self): - """Iterate over top level items""" - return [self.topLevelItem(_i) for _i in range(self.topLevelItemCount())] - - def get_items(self): - """Return items (excluding top level items)""" - itemlist = [] - def add_to_itemlist(item): - for index in range(item.childCount()): - citem = item.child(index) - itemlist.append(citem) - add_to_itemlist(citem) - for tlitem in self.get_top_level_items(): - add_to_itemlist(tlitem) - return itemlist - - def get_scrollbar_position(self): - return (self.horizontalScrollBar().value(), - self.verticalScrollBar().value()) - - def set_scrollbar_position(self, position): - hor, ver = position - self.horizontalScrollBar().setValue(hor) - self.verticalScrollBar().setValue(ver) - - def get_expanded_state(self): - self.save_expanded_state() - return self.__expanded_state - - def set_expanded_state(self, state): - self.__expanded_state = state - self.restore_expanded_state() - - def save_expanded_state(self): - """Save all items expanded state""" - self.__expanded_state = {} - def add_to_state(item): - user_text = get_item_user_text(item) - self.__expanded_state[hash(user_text)] = item.isExpanded() - def browse_children(item): - add_to_state(item) - for index in range(item.childCount()): - citem = item.child(index) - user_text = get_item_user_text(citem) - self.__expanded_state[hash(user_text)] = citem.isExpanded() - browse_children(citem) - for tlitem in self.get_top_level_items(): - browse_children(tlitem) - - def restore_expanded_state(self): - """Restore all items expanded state""" - if self.__expanded_state is None: - return - for item in self.get_items()+self.get_top_level_items(): - user_text = get_item_user_text(item) - is_expanded = self.__expanded_state.get(hash(user_text)) - if is_expanded is not None: - item.setExpanded(is_expanded) - - def sort_top_level_items(self, key): - """Sorting tree wrt top level items""" - self.save_expanded_state() - items = sorted([self.takeTopLevelItem(0) - for index in range(self.topLevelItemCount())], key=key) - for index, item in enumerate(items): - self.insertTopLevelItem(index, item) - self.restore_expanded_state() - - # ---- Qt methods - # ------------------------------------------------------------------------- - def contextMenuEvent(self, event): - """Override Qt method""" - self.menu.popup(event.globalPos()) - - def mouseMoveEvent(self, event): - """Change cursor shape.""" - index = self.indexAt(event.pos()) - if index.isValid(): - vrect = self.visualRect(index) - item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() - if event.pos().x() > item_identation: - # When hovering over results - self.setCursor(Qt.PointingHandCursor) - else: - # On every other element - self.setCursor(Qt.ArrowCursor) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Third party imports +from qtpy import PYQT5 +from qtpy.QtCore import Qt, Slot +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QTreeWidget + +# Local imports +from spyder.api.widgets.mixins import SpyderWidgetMixin +from spyder.config.base import _ +from spyder.utils.icon_manager import ima +from spyder.utils.qthelpers import get_item_user_text + + +class OneColumnTreeActions: + CollapseAllAction = "collapse_all_action" + ExpandAllAction = "expand_all_action" + RestoreAction = "restore_action" + CollapseSelectionAction = "collapse_selection_action" + ExpandSelectionAction = "expand_selection_action" + + +class OneColumnTreeContextMenuSections: + Global = "global_section" + Restore = "restore_section" + Section = "section_section" + History = "history_section" + + +class OneColumnTree(QTreeWidget, SpyderWidgetMixin): + """ + One-column tree widget with context menu. + """ + + def __init__(self, parent): + if PYQT5: + super().__init__(parent, class_parent=parent) + else: + QTreeWidget.__init__(self, parent) + SpyderWidgetMixin.__init__(self, class_parent=parent) + + self.__expanded_state = None + + # Widget setup + self.setItemsExpandable(True) + self.setColumnCount(1) + + # Setup context menu + self.collapse_all_action = None + self.collapse_selection_action = None + self.expand_all_action = None + self.expand_selection_action = None + self.setup() + self.common_actions = self.setup_common_actions() + + # Signals + self.itemActivated.connect(self.activated) + self.itemClicked.connect(self.clicked) + self.itemSelectionChanged.connect(self.item_selection_changed) + + # To use mouseMoveEvent + self.setMouseTracking(True) + + # Use horizontal scrollbar when needed + self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.header().setStretchLastSection(False) + + self.item_selection_changed() + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------- + def setup(self): + self.menu = self.create_menu("context_menu") + + self.collapse_all_action = self.create_action( + OneColumnTreeActions.CollapseAllAction, + text=_("Collapse all"), + icon=ima.icon("collapse"), + triggered=self.collapseAll, + register_shortcut=False, + ) + self.expand_all_action = self.create_action( + OneColumnTreeActions.ExpandAllAction, + text=_("Expand all"), + icon=ima.icon("expand"), + triggered=self.expandAll, + register_shortcut=False, + ) + self.restore_action = self.create_action( + OneColumnTreeActions.RestoreAction, + text=_("Restore"), + tip=_("Restore original tree layout"), + icon=ima.icon("restore"), + triggered=self.restore, + register_shortcut=False, + ) + self.collapse_selection_action = self.create_action( + OneColumnTreeActions.CollapseSelectionAction, + text=_("Collapse section"), + icon=ima.icon("collapse_selection"), + triggered=self.collapse_selection, + register_shortcut=False, + ) + self.expand_selection_action = self.create_action( + OneColumnTreeActions.ExpandSelectionAction, + text=_("Expand section"), + icon=ima.icon("expand_selection"), + triggered=self.expand_selection, + register_shortcut=False, + ) + + for item in [self.collapse_all_action, self.expand_all_action]: + self.add_item_to_menu( + item, + self.menu, + section=OneColumnTreeContextMenuSections.Global, + ) + + self.add_item_to_menu( + self.restore_action, + self.menu, + section=OneColumnTreeContextMenuSections.Restore, + ) + for item in [self.collapse_selection_action, + self.expand_selection_action]: + self.add_item_to_menu( + item, + self.menu, + section=OneColumnTreeContextMenuSections.Section, + ) + + def update_actions(self): + pass + + # ---- Public API + # ------------------------------------------------------------------------- + def activated(self, item): + """Double-click event""" + raise NotImplementedError + + def clicked(self, item): + pass + + def set_title(self, title): + self.setHeaderLabels([title]) + + def setup_common_actions(self): + """Setup context menu common actions""" + return [self.collapse_all_action, self.expand_all_action, + self.collapse_selection_action, self.expand_selection_action] + + def get_menu_actions(self): + """Returns a list of menu actions""" + items = self.selectedItems() + actions = self.get_actions_from_items(items) + if actions: + actions.append(None) + + actions += self.common_actions + return actions + + def get_actions_from_items(self, items): + # Right here: add other actions if necessary + # (reimplement this method) + return [] + + @Slot() + def restore(self): + self.collapseAll() + for item in self.get_top_level_items(): + self.expandItem(item) + + def is_item_expandable(self, item): + """To be reimplemented in child class + See example in project explorer widget""" + return True + + def __expand_item(self, item): + if self.is_item_expandable(item): + self.expandItem(item) + for index in range(item.childCount()): + child = item.child(index) + self.__expand_item(child) + + @Slot() + def expand_selection(self): + items = self.selectedItems() + if not items: + items = self.get_top_level_items() + for item in items: + self.__expand_item(item) + if items: + self.scrollToItem(items[0]) + + def __collapse_item(self, item): + self.collapseItem(item) + for index in range(item.childCount()): + child = item.child(index) + self.__collapse_item(child) + + @Slot() + def collapse_selection(self): + items = self.selectedItems() + if not items: + items = self.get_top_level_items() + for item in items: + self.__collapse_item(item) + if items: + self.scrollToItem(items[0]) + + def item_selection_changed(self): + """Item selection has changed""" + is_selection = len(self.selectedItems()) > 0 + self.expand_selection_action.setEnabled(is_selection) + self.collapse_selection_action.setEnabled(is_selection) + + def get_top_level_items(self): + """Iterate over top level items""" + return [self.topLevelItem(_i) for _i in range(self.topLevelItemCount())] + + def get_items(self): + """Return items (excluding top level items)""" + itemlist = [] + def add_to_itemlist(item): + for index in range(item.childCount()): + citem = item.child(index) + itemlist.append(citem) + add_to_itemlist(citem) + for tlitem in self.get_top_level_items(): + add_to_itemlist(tlitem) + return itemlist + + def get_scrollbar_position(self): + return (self.horizontalScrollBar().value(), + self.verticalScrollBar().value()) + + def set_scrollbar_position(self, position): + hor, ver = position + self.horizontalScrollBar().setValue(hor) + self.verticalScrollBar().setValue(ver) + + def get_expanded_state(self): + self.save_expanded_state() + return self.__expanded_state + + def set_expanded_state(self, state): + self.__expanded_state = state + self.restore_expanded_state() + + def save_expanded_state(self): + """Save all items expanded state""" + self.__expanded_state = {} + def add_to_state(item): + user_text = get_item_user_text(item) + self.__expanded_state[hash(user_text)] = item.isExpanded() + def browse_children(item): + add_to_state(item) + for index in range(item.childCount()): + citem = item.child(index) + user_text = get_item_user_text(citem) + self.__expanded_state[hash(user_text)] = citem.isExpanded() + browse_children(citem) + for tlitem in self.get_top_level_items(): + browse_children(tlitem) + + def restore_expanded_state(self): + """Restore all items expanded state""" + if self.__expanded_state is None: + return + for item in self.get_items()+self.get_top_level_items(): + user_text = get_item_user_text(item) + is_expanded = self.__expanded_state.get(hash(user_text)) + if is_expanded is not None: + item.setExpanded(is_expanded) + + def sort_top_level_items(self, key): + """Sorting tree wrt top level items""" + self.save_expanded_state() + items = sorted([self.takeTopLevelItem(0) + for index in range(self.topLevelItemCount())], key=key) + for index, item in enumerate(items): + self.insertTopLevelItem(index, item) + self.restore_expanded_state() + + # ---- Qt methods + # ------------------------------------------------------------------------- + def contextMenuEvent(self, event): + """Override Qt method""" + self.menu.popup(event.globalPos()) + + def mouseMoveEvent(self, event): + """Change cursor shape.""" + index = self.indexAt(event.pos()) + if index.isValid(): + vrect = self.visualRect(index) + item_identation = vrect.x() - self.visualRect(self.rootIndex()).x() + if event.pos().x() > item_identation: + # When hovering over results + self.setCursor(Qt.PointingHandCursor) + else: + # On every other element + self.setCursor(Qt.ArrowCursor) diff --git a/spyder/widgets/pathmanager.py b/spyder/widgets/pathmanager.py index 4c1c1a2f508..f562282f772 100644 --- a/spyder/widgets/pathmanager.py +++ b/spyder/widgets/pathmanager.py @@ -1,506 +1,506 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Spyder path manager.""" - -# Standard library imports -from __future__ import print_function -from collections import OrderedDict -import os -import os.path as osp -import re -import sys - -# Third party imports -from qtpy.compat import getexistingdirectory -from qtpy.QtCore import Qt, Signal, Slot -from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QHBoxLayout, - QListWidget, QListWidgetItem, QMessageBox, - QVBoxLayout, QLabel) - -# Local imports -from spyder.config.base import _ -from spyder.utils.icon_manager import ima -from spyder.utils.misc import getcwd_or_home -from spyder.utils.qthelpers import create_toolbutton - - -class PathManager(QDialog): - """Path manager dialog.""" - redirect_stdio = Signal(bool) - sig_path_changed = Signal(object) - - def __init__(self, parent, path=None, read_only_path=None, - not_active_path=None, sync=True): - """Path manager dialog.""" - super(PathManager, self).__init__(parent) - assert isinstance(path, (tuple, type(None))) - - self.path = path or () - self.read_only_path = read_only_path or () - self.not_active_path = not_active_path or () - self.last_path = getcwd_or_home() - self.original_path_dict = None - - # Widgets - self.add_button = None - self.remove_button = None - self.movetop_button = None - self.moveup_button = None - self.movedown_button = None - self.movebottom_button = None - self.import_button = None - self.export_button = None - self.selection_widgets = [] - self.top_toolbar_widgets = self._setup_top_toolbar() - self.bottom_toolbar_widgets = self._setup_bottom_toolbar() - self.listwidget = QListWidget(self) - self.bbox = QDialogButtonBox(QDialogButtonBox.Ok - | QDialogButtonBox.Cancel) - self.button_ok = self.bbox.button(QDialogButtonBox.Ok) - - # Widget setup - # Destroying the C++ object right after closing the dialog box, - # otherwise it may be garbage-collected in another QThread - # (e.g. the editor's analysis thread in Spyder), thus leading to - # a segmentation fault on UNIX or an application crash on Windows - self.setAttribute(Qt.WA_DeleteOnClose) - self.setWindowTitle(_("PYTHONPATH manager")) - self.setWindowIcon(ima.icon('pythonpath')) - self.resize(500, 400) - self.import_button.setVisible(sync) - self.export_button.setVisible(os.name == 'nt' and sync) - - # Layouts - description = QLabel( - _("The paths listed below will be passed to IPython consoles and " - "the language server as additional locations to search for " - "Python modules.

    " - "Any paths in your system PYTHONPATH environment " - "variable can be imported here if you'd like to use them.") - ) - description.setWordWrap(True) - top_layout = QHBoxLayout() - self._add_widgets_to_layout(self.top_toolbar_widgets, top_layout) - - bottom_layout = QHBoxLayout() - self._add_widgets_to_layout(self.bottom_toolbar_widgets, - bottom_layout) - bottom_layout.addWidget(self.bbox) - - layout = QVBoxLayout() - layout.addWidget(description) - layout.addLayout(top_layout) - layout.addWidget(self.listwidget) - layout.addLayout(bottom_layout) - self.setLayout(layout) - - # Signals - self.listwidget.currentRowChanged.connect(lambda x: self.refresh()) - self.listwidget.itemChanged.connect(lambda x: self.refresh()) - self.bbox.accepted.connect(self.accept) - self.bbox.rejected.connect(self.reject) - - # Setup - self.setup() - - def _add_widgets_to_layout(self, widgets, layout): - """Helper to add toolbar widgets to top and bottom layout.""" - layout.setAlignment(Qt.AlignLeft) - for widget in widgets: - if widget is None: - layout.addStretch(1) - else: - layout.addWidget(widget) - - def _setup_top_toolbar(self): - """Create top toolbar and actions.""" - self.movetop_button = create_toolbutton( - self, - text=_("Move to top"), - icon=ima.icon('2uparrow'), - triggered=lambda: self.move_to(absolute=0), - text_beside_icon=True) - self.moveup_button = create_toolbutton( - self, - text=_("Move up"), - icon=ima.icon('1uparrow'), - triggered=lambda: self.move_to(relative=-1), - text_beside_icon=True) - self.movedown_button = create_toolbutton( - self, - text=_("Move down"), - icon=ima.icon('1downarrow'), - triggered=lambda: self.move_to(relative=1), - text_beside_icon=True) - self.movebottom_button = create_toolbutton( - self, - text=_("Move to bottom"), - icon=ima.icon('2downarrow'), - triggered=lambda: self.move_to(absolute=1), - text_beside_icon=True) - - toolbar = [self.movetop_button, self.moveup_button, - self.movedown_button, self.movebottom_button] - self.selection_widgets.extend(toolbar) - return toolbar - - def _setup_bottom_toolbar(self): - """Create bottom toolbar and actions.""" - self.add_button = create_toolbutton( - self, - text=_('Add path'), - icon=ima.icon('edit_add'), - triggered=lambda x: self.add_path(), - text_beside_icon=True) - self.remove_button = create_toolbutton( - self, - text=_('Remove path'), - icon=ima.icon('edit_remove'), - triggered=lambda x: self.remove_path(), - text_beside_icon=True) - self.import_button = create_toolbutton( - self, - text=_("Import"), - icon=ima.icon('fileimport'), - triggered=self.import_pythonpath, - tip=_("Import from PYTHONPATH environment variable"), - text_beside_icon=True) - self.export_button = create_toolbutton( - self, - text=_("Export"), - icon=ima.icon('fileexport'), - triggered=self.export_pythonpath, - tip=_("Export to PYTHONPATH environment variable"), - text_beside_icon=True) - - return [self.add_button, self.remove_button, self.import_button, - self.export_button] - - def _create_item(self, path): - """Helper to create a new list item.""" - item = QListWidgetItem(path) - item.setIcon(ima.icon('DirClosedIcon')) - - if path in self.read_only_path: - item.setFlags(Qt.NoItemFlags | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked) - elif path in self.not_active_path: - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) - else: - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked) - - return item - - @property - def editable_bottom_row(self): - """Maximum bottom row count that is editable.""" - read_only_count = len(self.read_only_path) - max_row = self.listwidget.count() - read_only_count - 1 - return max_row - - def setup(self): - """Populate list widget.""" - self.listwidget.clear() - for path in self.path + self.read_only_path: - item = self._create_item(path) - self.listwidget.addItem(item) - self.listwidget.setCurrentRow(0) - self.original_path_dict = self.get_path_dict() - self.refresh() - - @Slot() - def import_pythonpath(self): - """Import from PYTHONPATH environment variable""" - env_pypath = os.environ.get('PYTHONPATH', '') - - if env_pypath: - env_pypath = env_pypath.split(os.pathsep) - - dlg = QDialog(self) - dlg.setWindowTitle(_("PYTHONPATH")) - dlg.setWindowIcon(ima.icon('pythonpath')) - dlg.setAttribute(Qt.WA_DeleteOnClose) - dlg.setMinimumWidth(400) - - label = QLabel("The following paths from your PYTHONPATH " - "environment variable will be imported.") - listw = QListWidget(dlg) - listw.addItems(env_pypath) - - bbox = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - bbox.accepted.connect(dlg.accept) - bbox.rejected.connect(dlg.reject) - - layout = QVBoxLayout() - layout.addWidget(label) - layout.addWidget(listw) - layout.addWidget(bbox) - dlg.setLayout(layout) - - if dlg.exec(): - spy_pypath = self.get_path_dict() - n = len(spy_pypath) - - for path in reversed(env_pypath): - if (path in spy_pypath) or not self.check_path(path): - continue - item = self._create_item(path) - self.listwidget.insertItem(n, item) - - self.refresh() - else: - QMessageBox.information( - self, - _("PYTHONPATH"), - _("Your PYTHONPATH environment variable is empty, so " - "there is nothing to import."), - QMessageBox.Ok - ) - - @Slot() - def export_pythonpath(self): - """ - Export to PYTHONPATH environment variable - Only apply to: current user. - """ - answer = QMessageBox.question( - self, - _("Export"), - _("This will export Spyder's path list to the " - "PYTHONPATH environment variable for the current user, " - "allowing you to run your Python modules outside Spyder " - "without having to configure sys.path. " - "

    " - "Do you want to clear the contents of PYTHONPATH before " - "adding Spyder's path list?"), - QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel - ) - - if answer == QMessageBox.Cancel: - return - elif answer == QMessageBox.Yes: - remove = True - else: - remove = False - - from spyder.utils.environ import (get_user_env, listdict2envdict, - set_user_env) - env = get_user_env() - - # Includes read only paths - active_path = tuple(k for k, v in self.get_path_dict(True).items() - if v) - - if remove: - ppath = active_path - else: - ppath = env.get('PYTHONPATH', []) - if not isinstance(ppath, list): - ppath = [ppath] - - ppath = tuple(p for p in ppath if p not in active_path) - ppath = ppath + active_path - - env['PYTHONPATH'] = list(ppath) - set_user_env(listdict2envdict(env), parent=self) - - def get_path_dict(self, read_only=False): - """ - Return an ordered dict with the path entries as keys and the active - state as the value. - - If `read_only` is True, the read_only entries are also included. - `read_only` entry refers to the project path entry. - """ - odict = OrderedDict() - for row in range(self.listwidget.count()): - item = self.listwidget.item(row) - path = item.text() - if path in self.read_only_path and not read_only: - continue - odict[path] = item.checkState() == Qt.Checked - return odict - - def refresh(self): - """Refresh toolbar widgets.""" - enabled = self.listwidget.currentItem() is not None - for widget in self.selection_widgets: - widget.setEnabled(enabled) - - # Disable buttons based on row - row = self.listwidget.currentRow() - disable_widgets = [] - - # Move up/top disabled for top item - if row == 0: - disable_widgets.extend([self.movetop_button, self.moveup_button]) - - # Move down/bottom disabled for bottom item - if row == self.editable_bottom_row: - disable_widgets.extend([self.movebottom_button, - self.movedown_button]) - for widget in disable_widgets: - widget.setEnabled(False) - - self.remove_button.setEnabled(self.listwidget.count() - - len(self.read_only_path)) - self.export_button.setEnabled(self.listwidget.count() > 0) - - # Ok button only enabled if actual changes occur - self.button_ok.setEnabled( - self.original_path_dict != self.get_path_dict()) - - def check_path(self, path): - """Check that the path is not a [site|dist]-packages folder.""" - if os.name == 'nt': - pat = re.compile(r'.*lib/(?:site|dist)-packages.*') - else: - pat = re.compile(r'.*lib/python.../(?:site|dist)-packages.*') - - path_norm = path.replace('\\', '/') - return pat.match(path_norm) is None - - @Slot() - def add_path(self, directory=None): - """ - Add path to list widget. - - If `directory` is provided, the folder dialog is overridden. - """ - if directory is None: - self.redirect_stdio.emit(False) - directory = getexistingdirectory(self, _("Select directory"), - self.last_path) - self.redirect_stdio.emit(True) - if not directory: - return - - directory = osp.abspath(directory) - self.last_path = directory - - if directory in self.get_path_dict(): - item = self.listwidget.findItems(directory, Qt.MatchExactly)[0] - item.setCheckState(Qt.Checked) - answer = QMessageBox.question( - self, - _("Add path"), - _("This directory is already included in the list." - "
    " - "Do you want to move it to the top of it?"), - QMessageBox.Yes | QMessageBox.No) - - if answer == QMessageBox.Yes: - item = self.listwidget.takeItem(self.listwidget.row(item)) - self.listwidget.insertItem(0, item) - self.listwidget.setCurrentRow(0) - else: - if self.check_path(directory): - item = self._create_item(directory) - self.listwidget.insertItem(0, item) - self.listwidget.setCurrentRow(0) - else: - answer = QMessageBox.warning( - self, - _("Add path"), - _("This directory cannot be added to the path!" - "

    " - "If you want to set a different Python interpreter, " - "please go to Preferences > Main interpreter" - "."), - QMessageBox.Ok) - - self.refresh() - - @Slot() - def remove_path(self, force=False): - """ - Remove path from list widget. - - If `force` is True, the message box is overridden. - """ - if self.listwidget.currentItem(): - if not force: - answer = QMessageBox.warning( - self, - _("Remove path"), - _("Do you really want to remove the selected path?"), - QMessageBox.Yes | QMessageBox.No) - - if force or answer == QMessageBox.Yes: - self.listwidget.takeItem(self.listwidget.currentRow()) - self.refresh() - - def move_to(self, absolute=None, relative=None): - """Move items of list widget.""" - index = self.listwidget.currentRow() - if absolute is not None: - if absolute: - new_index = self.listwidget.count() - 1 - else: - new_index = 0 - else: - new_index = index + relative - - new_index = max(0, min(self.editable_bottom_row, new_index)) - item = self.listwidget.takeItem(index) - self.listwidget.insertItem(new_index, item) - self.listwidget.setCurrentRow(new_index) - self.refresh() - - def current_row(self): - """Returns the current row of the list.""" - return self.listwidget.currentRow() - - def set_current_row(self, row): - """Set the current row of the list.""" - self.listwidget.setCurrentRow(row) - - def row_check_state(self, row): - """Return the checked state for item in row.""" - item = self.listwidget.item(row) - return item.checkState() - - def set_row_check_state(self, row, value): - """Set the current checked state for item in row.""" - item = self.listwidget.item(row) - item.setCheckState(value) - - def count(self): - """Return the number of items.""" - return self.listwidget.count() - - def accept(self): - """Override Qt method.""" - path_dict = self.get_path_dict() - if self.original_path_dict != path_dict: - self.sig_path_changed.emit(path_dict) - super(PathManager, self).accept() - - -def test(): - """Run path manager test.""" - from spyder.utils.qthelpers import qapplication - - _ = qapplication() - dlg = PathManager( - None, - path=tuple(sys.path[4:-2]), - read_only_path=tuple(sys.path[-2:]), - ) - - def callback(path_dict): - sys.stdout.write(str(path_dict)) - - dlg.sig_path_changed.connect(callback) - sys.exit(dlg.exec_()) - - -if __name__ == "__main__": - test() +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Spyder path manager.""" + +# Standard library imports +from __future__ import print_function +from collections import OrderedDict +import os +import os.path as osp +import re +import sys + +# Third party imports +from qtpy.compat import getexistingdirectory +from qtpy.QtCore import Qt, Signal, Slot +from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QHBoxLayout, + QListWidget, QListWidgetItem, QMessageBox, + QVBoxLayout, QLabel) + +# Local imports +from spyder.config.base import _ +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.utils.qthelpers import create_toolbutton + + +class PathManager(QDialog): + """Path manager dialog.""" + redirect_stdio = Signal(bool) + sig_path_changed = Signal(object) + + def __init__(self, parent, path=None, read_only_path=None, + not_active_path=None, sync=True): + """Path manager dialog.""" + super(PathManager, self).__init__(parent) + assert isinstance(path, (tuple, type(None))) + + self.path = path or () + self.read_only_path = read_only_path or () + self.not_active_path = not_active_path or () + self.last_path = getcwd_or_home() + self.original_path_dict = None + + # Widgets + self.add_button = None + self.remove_button = None + self.movetop_button = None + self.moveup_button = None + self.movedown_button = None + self.movebottom_button = None + self.import_button = None + self.export_button = None + self.selection_widgets = [] + self.top_toolbar_widgets = self._setup_top_toolbar() + self.bottom_toolbar_widgets = self._setup_bottom_toolbar() + self.listwidget = QListWidget(self) + self.bbox = QDialogButtonBox(QDialogButtonBox.Ok + | QDialogButtonBox.Cancel) + self.button_ok = self.bbox.button(QDialogButtonBox.Ok) + + # Widget setup + # Destroying the C++ object right after closing the dialog box, + # otherwise it may be garbage-collected in another QThread + # (e.g. the editor's analysis thread in Spyder), thus leading to + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + self.setWindowTitle(_("PYTHONPATH manager")) + self.setWindowIcon(ima.icon('pythonpath')) + self.resize(500, 400) + self.import_button.setVisible(sync) + self.export_button.setVisible(os.name == 'nt' and sync) + + # Layouts + description = QLabel( + _("The paths listed below will be passed to IPython consoles and " + "the language server as additional locations to search for " + "Python modules.

    " + "Any paths in your system PYTHONPATH environment " + "variable can be imported here if you'd like to use them.") + ) + description.setWordWrap(True) + top_layout = QHBoxLayout() + self._add_widgets_to_layout(self.top_toolbar_widgets, top_layout) + + bottom_layout = QHBoxLayout() + self._add_widgets_to_layout(self.bottom_toolbar_widgets, + bottom_layout) + bottom_layout.addWidget(self.bbox) + + layout = QVBoxLayout() + layout.addWidget(description) + layout.addLayout(top_layout) + layout.addWidget(self.listwidget) + layout.addLayout(bottom_layout) + self.setLayout(layout) + + # Signals + self.listwidget.currentRowChanged.connect(lambda x: self.refresh()) + self.listwidget.itemChanged.connect(lambda x: self.refresh()) + self.bbox.accepted.connect(self.accept) + self.bbox.rejected.connect(self.reject) + + # Setup + self.setup() + + def _add_widgets_to_layout(self, widgets, layout): + """Helper to add toolbar widgets to top and bottom layout.""" + layout.setAlignment(Qt.AlignLeft) + for widget in widgets: + if widget is None: + layout.addStretch(1) + else: + layout.addWidget(widget) + + def _setup_top_toolbar(self): + """Create top toolbar and actions.""" + self.movetop_button = create_toolbutton( + self, + text=_("Move to top"), + icon=ima.icon('2uparrow'), + triggered=lambda: self.move_to(absolute=0), + text_beside_icon=True) + self.moveup_button = create_toolbutton( + self, + text=_("Move up"), + icon=ima.icon('1uparrow'), + triggered=lambda: self.move_to(relative=-1), + text_beside_icon=True) + self.movedown_button = create_toolbutton( + self, + text=_("Move down"), + icon=ima.icon('1downarrow'), + triggered=lambda: self.move_to(relative=1), + text_beside_icon=True) + self.movebottom_button = create_toolbutton( + self, + text=_("Move to bottom"), + icon=ima.icon('2downarrow'), + triggered=lambda: self.move_to(absolute=1), + text_beside_icon=True) + + toolbar = [self.movetop_button, self.moveup_button, + self.movedown_button, self.movebottom_button] + self.selection_widgets.extend(toolbar) + return toolbar + + def _setup_bottom_toolbar(self): + """Create bottom toolbar and actions.""" + self.add_button = create_toolbutton( + self, + text=_('Add path'), + icon=ima.icon('edit_add'), + triggered=lambda x: self.add_path(), + text_beside_icon=True) + self.remove_button = create_toolbutton( + self, + text=_('Remove path'), + icon=ima.icon('edit_remove'), + triggered=lambda x: self.remove_path(), + text_beside_icon=True) + self.import_button = create_toolbutton( + self, + text=_("Import"), + icon=ima.icon('fileimport'), + triggered=self.import_pythonpath, + tip=_("Import from PYTHONPATH environment variable"), + text_beside_icon=True) + self.export_button = create_toolbutton( + self, + text=_("Export"), + icon=ima.icon('fileexport'), + triggered=self.export_pythonpath, + tip=_("Export to PYTHONPATH environment variable"), + text_beside_icon=True) + + return [self.add_button, self.remove_button, self.import_button, + self.export_button] + + def _create_item(self, path): + """Helper to create a new list item.""" + item = QListWidgetItem(path) + item.setIcon(ima.icon('DirClosedIcon')) + + if path in self.read_only_path: + item.setFlags(Qt.NoItemFlags | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) + elif path in self.not_active_path: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + else: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) + + return item + + @property + def editable_bottom_row(self): + """Maximum bottom row count that is editable.""" + read_only_count = len(self.read_only_path) + max_row = self.listwidget.count() - read_only_count - 1 + return max_row + + def setup(self): + """Populate list widget.""" + self.listwidget.clear() + for path in self.path + self.read_only_path: + item = self._create_item(path) + self.listwidget.addItem(item) + self.listwidget.setCurrentRow(0) + self.original_path_dict = self.get_path_dict() + self.refresh() + + @Slot() + def import_pythonpath(self): + """Import from PYTHONPATH environment variable""" + env_pypath = os.environ.get('PYTHONPATH', '') + + if env_pypath: + env_pypath = env_pypath.split(os.pathsep) + + dlg = QDialog(self) + dlg.setWindowTitle(_("PYTHONPATH")) + dlg.setWindowIcon(ima.icon('pythonpath')) + dlg.setAttribute(Qt.WA_DeleteOnClose) + dlg.setMinimumWidth(400) + + label = QLabel("The following paths from your PYTHONPATH " + "environment variable will be imported.") + listw = QListWidget(dlg) + listw.addItems(env_pypath) + + bbox = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + bbox.accepted.connect(dlg.accept) + bbox.rejected.connect(dlg.reject) + + layout = QVBoxLayout() + layout.addWidget(label) + layout.addWidget(listw) + layout.addWidget(bbox) + dlg.setLayout(layout) + + if dlg.exec(): + spy_pypath = self.get_path_dict() + n = len(spy_pypath) + + for path in reversed(env_pypath): + if (path in spy_pypath) or not self.check_path(path): + continue + item = self._create_item(path) + self.listwidget.insertItem(n, item) + + self.refresh() + else: + QMessageBox.information( + self, + _("PYTHONPATH"), + _("Your PYTHONPATH environment variable is empty, so " + "there is nothing to import."), + QMessageBox.Ok + ) + + @Slot() + def export_pythonpath(self): + """ + Export to PYTHONPATH environment variable + Only apply to: current user. + """ + answer = QMessageBox.question( + self, + _("Export"), + _("This will export Spyder's path list to the " + "PYTHONPATH environment variable for the current user, " + "allowing you to run your Python modules outside Spyder " + "without having to configure sys.path. " + "

    " + "Do you want to clear the contents of PYTHONPATH before " + "adding Spyder's path list?"), + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel + ) + + if answer == QMessageBox.Cancel: + return + elif answer == QMessageBox.Yes: + remove = True + else: + remove = False + + from spyder.utils.environ import (get_user_env, listdict2envdict, + set_user_env) + env = get_user_env() + + # Includes read only paths + active_path = tuple(k for k, v in self.get_path_dict(True).items() + if v) + + if remove: + ppath = active_path + else: + ppath = env.get('PYTHONPATH', []) + if not isinstance(ppath, list): + ppath = [ppath] + + ppath = tuple(p for p in ppath if p not in active_path) + ppath = ppath + active_path + + env['PYTHONPATH'] = list(ppath) + set_user_env(listdict2envdict(env), parent=self) + + def get_path_dict(self, read_only=False): + """ + Return an ordered dict with the path entries as keys and the active + state as the value. + + If `read_only` is True, the read_only entries are also included. + `read_only` entry refers to the project path entry. + """ + odict = OrderedDict() + for row in range(self.listwidget.count()): + item = self.listwidget.item(row) + path = item.text() + if path in self.read_only_path and not read_only: + continue + odict[path] = item.checkState() == Qt.Checked + return odict + + def refresh(self): + """Refresh toolbar widgets.""" + enabled = self.listwidget.currentItem() is not None + for widget in self.selection_widgets: + widget.setEnabled(enabled) + + # Disable buttons based on row + row = self.listwidget.currentRow() + disable_widgets = [] + + # Move up/top disabled for top item + if row == 0: + disable_widgets.extend([self.movetop_button, self.moveup_button]) + + # Move down/bottom disabled for bottom item + if row == self.editable_bottom_row: + disable_widgets.extend([self.movebottom_button, + self.movedown_button]) + for widget in disable_widgets: + widget.setEnabled(False) + + self.remove_button.setEnabled(self.listwidget.count() + - len(self.read_only_path)) + self.export_button.setEnabled(self.listwidget.count() > 0) + + # Ok button only enabled if actual changes occur + self.button_ok.setEnabled( + self.original_path_dict != self.get_path_dict()) + + def check_path(self, path): + """Check that the path is not a [site|dist]-packages folder.""" + if os.name == 'nt': + pat = re.compile(r'.*lib/(?:site|dist)-packages.*') + else: + pat = re.compile(r'.*lib/python.../(?:site|dist)-packages.*') + + path_norm = path.replace('\\', '/') + return pat.match(path_norm) is None + + @Slot() + def add_path(self, directory=None): + """ + Add path to list widget. + + If `directory` is provided, the folder dialog is overridden. + """ + if directory is None: + self.redirect_stdio.emit(False) + directory = getexistingdirectory(self, _("Select directory"), + self.last_path) + self.redirect_stdio.emit(True) + if not directory: + return + + directory = osp.abspath(directory) + self.last_path = directory + + if directory in self.get_path_dict(): + item = self.listwidget.findItems(directory, Qt.MatchExactly)[0] + item.setCheckState(Qt.Checked) + answer = QMessageBox.question( + self, + _("Add path"), + _("This directory is already included in the list." + "
    " + "Do you want to move it to the top of it?"), + QMessageBox.Yes | QMessageBox.No) + + if answer == QMessageBox.Yes: + item = self.listwidget.takeItem(self.listwidget.row(item)) + self.listwidget.insertItem(0, item) + self.listwidget.setCurrentRow(0) + else: + if self.check_path(directory): + item = self._create_item(directory) + self.listwidget.insertItem(0, item) + self.listwidget.setCurrentRow(0) + else: + answer = QMessageBox.warning( + self, + _("Add path"), + _("This directory cannot be added to the path!" + "

    " + "If you want to set a different Python interpreter, " + "please go to Preferences > Main interpreter" + "."), + QMessageBox.Ok) + + self.refresh() + + @Slot() + def remove_path(self, force=False): + """ + Remove path from list widget. + + If `force` is True, the message box is overridden. + """ + if self.listwidget.currentItem(): + if not force: + answer = QMessageBox.warning( + self, + _("Remove path"), + _("Do you really want to remove the selected path?"), + QMessageBox.Yes | QMessageBox.No) + + if force or answer == QMessageBox.Yes: + self.listwidget.takeItem(self.listwidget.currentRow()) + self.refresh() + + def move_to(self, absolute=None, relative=None): + """Move items of list widget.""" + index = self.listwidget.currentRow() + if absolute is not None: + if absolute: + new_index = self.listwidget.count() - 1 + else: + new_index = 0 + else: + new_index = index + relative + + new_index = max(0, min(self.editable_bottom_row, new_index)) + item = self.listwidget.takeItem(index) + self.listwidget.insertItem(new_index, item) + self.listwidget.setCurrentRow(new_index) + self.refresh() + + def current_row(self): + """Returns the current row of the list.""" + return self.listwidget.currentRow() + + def set_current_row(self, row): + """Set the current row of the list.""" + self.listwidget.setCurrentRow(row) + + def row_check_state(self, row): + """Return the checked state for item in row.""" + item = self.listwidget.item(row) + return item.checkState() + + def set_row_check_state(self, row, value): + """Set the current checked state for item in row.""" + item = self.listwidget.item(row) + item.setCheckState(value) + + def count(self): + """Return the number of items.""" + return self.listwidget.count() + + def accept(self): + """Override Qt method.""" + path_dict = self.get_path_dict() + if self.original_path_dict != path_dict: + self.sig_path_changed.emit(path_dict) + super(PathManager, self).accept() + + +def test(): + """Run path manager test.""" + from spyder.utils.qthelpers import qapplication + + _ = qapplication() + dlg = PathManager( + None, + path=tuple(sys.path[4:-2]), + read_only_path=tuple(sys.path[-2:]), + ) + + def callback(path_dict): + sys.stdout.write(str(path_dict)) + + dlg.sig_path_changed.connect(callback) + sys.exit(dlg.exec_()) + + +if __name__ == "__main__": + test() diff --git a/spyder/widgets/simplecodeeditor.py b/spyder/widgets/simplecodeeditor.py index 67979b0380e..986b9c8cae7 100644 --- a/spyder/widgets/simplecodeeditor.py +++ b/spyder/widgets/simplecodeeditor.py @@ -1,578 +1,578 @@ -# -*- coding: utf-8 -*- -# ----------------------------------------------------------------------------- -# Copyright © Spyder Project Contributors -# -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) -# ---------------------------------------------------------------------------- - -""" -Simple code editor with syntax highlighting and line number area. - -Adapted from: -https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html -""" - -# Third party imports -from qtpy.QtCore import QPoint, QRect, QSize, Qt, Signal -from qtpy.QtGui import QColor, QPainter, QTextCursor, QTextFormat, QTextOption -from qtpy.QtWidgets import QPlainTextEdit, QTextEdit, QWidget - -# Local imports -import spyder.utils.syntaxhighlighters as sh -from spyder.widgets.mixins import BaseEditMixin - - -# Constants -LANGUAGE_EXTENSIONS = { - 'Python': ('py', 'pyw', 'python', 'ipy'), - 'Cython': ('pyx', 'pxi', 'pxd'), - 'Enaml': ('enaml',), - 'Fortran77': ('f', 'for', 'f77'), - 'Fortran': ('f90', 'f95', 'f2k', 'f03', 'f08'), - 'Idl': ('pro',), - 'Diff': ('diff', 'patch', 'rej'), - 'GetText': ('po', 'pot'), - 'Nsis': ('nsi', 'nsh'), - 'Html': ('htm', 'html'), - 'Cpp': ('c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'hxx'), - 'OpenCL': ('cl',), - 'Yaml': ('yaml', 'yml'), - 'Markdown': ('md', 'mdw'), - # Every other language - 'None': ('', ), -} - - -class LineNumberArea(QWidget): - """ - Adapted from: - https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html - """ - - def __init__(self, code_editor=None): - super().__init__(code_editor) - - self._editor = code_editor - self._left_padding = 6 # Pixels - self._right_padding = 3 # Pixels - - # --- Qt overrides - # ------------------------------------------------------------------------ - def sizeHint(self): - return QSize(self._editor.linenumberarea_width(), 0) - - def paintEvent(self, event): - self._editor.linenumberarea_paint_event(event) - - -class SimpleCodeEditor(QPlainTextEdit, BaseEditMixin): - """Simple editor with highlight features.""" - - LANGUAGE_HIGHLIGHTERS = { - 'Python': (sh.PythonSH, '#'), - 'Cython': (sh.CythonSH, '#'), - 'Fortran77': (sh.Fortran77SH, 'c'), - 'Fortran': (sh.FortranSH, '!'), - 'Idl': (sh.IdlSH, ';'), - 'Diff': (sh.DiffSH, ''), - 'GetText': (sh.GetTextSH, '#'), - 'Nsis': (sh.NsisSH, '#'), - 'Html': (sh.HtmlSH, ''), - 'Yaml': (sh.YamlSH, '#'), - 'Cpp': (sh.CppSH, '//'), - 'OpenCL': (sh.OpenCLSH, '//'), - 'Enaml': (sh.EnamlSH, '#'), - 'Markdown': (sh.MarkdownSH, '#'), - # Every other language - 'None': (sh.TextSH, ''), - } - - # --- Signals - # ------------------------------------------------------------------------ - sig_focus_changed = Signal() - """ - This signal when the focus of the editor changes, either by a - `focusInEvent` or `focusOutEvent` event. - """ - - def __init__(self, parent=None): - super().__init__(parent) - - # Variables - self._linenumber_enabled = None - self._color_scheme = "spyder/dark" - self._language = None - self._blanks_enabled = None - self._scrollpastend_enabled = None - self._wrap_mode = None - self._highlight_current_line = None - self.supported_language = False - - # Widgets - self._highlighter = None - self.linenumberarea = LineNumberArea(self) - - # Widget setup - self.setObjectName(self.__class__.__name__ + str(id(self))) - self.update_linenumberarea_width(0) - self._apply_current_line_highlight() - - # Signals - self.blockCountChanged.connect(self.update_linenumberarea_width) - self.updateRequest.connect(self.update_linenumberarea) - self.cursorPositionChanged.connect(self._apply_current_line_highlight) - - # --- Private API - # ------------------------------------------------------------------------ - def _apply_color_scheme(self): - hl = self._highlighter - if hl is not None: - hl.setup_formats(self.font()) - if self._color_scheme is not None: - hl.set_color_scheme(self._color_scheme) - - self._set_palette(background=hl.get_background_color(), - foreground=hl.get_foreground_color()) - - def _set_palette(self, background, foreground): - style = ("QPlainTextEdit#%s {background: %s; color: %s;}" % - (self.objectName(), background.name(), foreground.name())) - self.setStyleSheet(style) - self.rehighlight() - - def _apply_current_line_highlight(self): - if self._highlighter and self._highlight_current_line: - extra_selections = [] - selection = QTextEdit.ExtraSelection() - line_color = self._highlighter.get_currentline_color() - selection.format.setBackground(line_color) - selection.format.setProperty(QTextFormat.FullWidthSelection, True) - selection.cursor = self.textCursor() - selection.cursor.clearSelection() - extra_selections.append(selection) - - self.setExtraSelections(extra_selections) - else: - self.setExtraSelections([]) - - # --- Qt Overrides - # ------------------------------------------------------------------------ - def focusInEvent(self, event): - self.sig_focus_changed.emit() - super().focusInEvent(event) - - def focusOutEvent(self, event): - self.sig_focus_changed.emit() - super().focusInEvent(event) - - def resizeEvent(self, event): - super().resizeEvent(event) - if self._linenumber_enabled: - cr = self.contentsRect() - self.linenumberarea.setGeometry( - QRect( - cr.left(), - cr.top(), - self.linenumberarea_width(), - cr.height(), - ) - ) - - # --- Public API - # ------------------------------------------------------------------------ - def setup_editor(self, - linenumbers=True, - color_scheme="spyder/dark", - language="py", - font=None, - show_blanks=False, - wrap=False, - highlight_current_line=True, - scroll_past_end=False): - """ - Setup editor options. - - Parameters - ---------- - color_scheme: str, optional - Default is "spyder/dark". - language: str, optional - Default is "py". - font: QFont or None - Default is None. - show_blanks: bool, optional - Default is False/ - wrap: bool, optional - Default is False. - highlight_current_line: bool, optional - Default is True. - scroll_past_end: bool, optional - Default is False - """ - if font: - self.set_font(font) - - self.set_highlight_current_line(highlight_current_line) - self.set_blanks_enabled(show_blanks) - self.toggle_line_numbers(linenumbers) - self.set_scrollpastend_enabled(scroll_past_end) - self.set_language(language) - self.set_color_scheme(color_scheme) - self.toggle_wrap_mode(wrap) - - def set_font(self, font): - """ - Set the editor font. - - Parameters - ---------- - font: QFont - Font to use. - """ - if font: - self.setFont(font) - self._apply_color_scheme() - - def set_color_scheme(self, color_scheme): - """ - Set the editor color scheme. - - Parameters - ---------- - color_scheme: str - Color scheme to use. - """ - self._color_scheme = color_scheme - self._apply_color_scheme() - - def set_language(self, language): - """ - Set current syntax highlighting to use `language`. - - Parameters - ---------- - language: str or None - Language name or known extensions. - """ - sh_class = sh.TextSH - language = str(language).lower() - self.supported_language = False - for (key, value) in LANGUAGE_EXTENSIONS.items(): - if language in (key.lower(), ) + value: - sh_class, __ = self.LANGUAGE_HIGHLIGHTERS[key] - self._language = key - self.supported_language = True - - self._highlighter = sh_class( - self.document(), self.font(), self._color_scheme) - self._apply_color_scheme() - - def toggle_line_numbers(self, state): - """ - Set visibility of line number area - - Parameters - ---------- - state: bool - Visible state of the line number area. - """ - - self._linenumber_enabled = state - self.linenumberarea.setVisible(state) - self.update_linenumberarea_width(()) - - def set_scrollpastend_enabled(self, state): - """ - Set scroll past end state. - - Parameters - ---------- - state: bool - Scroll past end state. - """ - self._scrollpastend_enabled = state - self.setCenterOnScroll(state) - self.setDocument(self.document()) - - def toggle_wrap_mode(self, state): - """ - Set line wrap.. - - Parameters - ---------- - state: bool - Wrap state. - """ - self.set_wrap_mode('word' if state else None) - - def set_wrap_mode(self, mode=None): - """ - Set line wrap mode. - - Parameters - ---------- - mode: str or None, optional - "word", or "character". Default is None. - """ - if mode == 'word': - wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere - elif mode == 'character': - wrap_mode = QTextOption.WrapAnywhere - else: - wrap_mode = QTextOption.NoWrap - - self.setWordWrapMode(wrap_mode) - - def set_highlight_current_line(self, value): - """ - Set if the current line is highlighted. - - Parameters - ---------- - value: bool - The value of the current line highlight option. - """ - self._highlight_current_line = value - self._apply_current_line_highlight() - - def set_blanks_enabled(self, state): - """ - Show blank spaces. - - Parameters - ---------- - state: bool - Blank spaces visibility. - """ - self._blanks_enabled = state - option = self.document().defaultTextOption() - option.setFlags(option.flags() - | QTextOption.AddSpaceForLineAndParagraphSeparators) - - if self._blanks_enabled: - option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) - else: - option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) - - self.document().setDefaultTextOption(option) - - # Rehighlight to make the spaces less apparent. - self.rehighlight() - - # --- Line number area - # ------------------------------------------------------------------------ - def linenumberarea_paint_event(self, event): - """ - Paint the line number area. - """ - if self._linenumber_enabled: - painter = QPainter(self.linenumberarea) - painter.fillRect( - event.rect(), - self._highlighter.get_sideareas_color(), - ) - - block = self.firstVisibleBlock() - block_number = block.blockNumber() - top = round(self.blockBoundingGeometry(block).translated( - self.contentOffset()).top()) - bottom = top + round(self.blockBoundingRect(block).height()) - - font = self.font() - active_block = self.textCursor().block() - active_line_number = active_block.blockNumber() + 1 - - while block.isValid() and top <= event.rect().bottom(): - if block.isVisible() and bottom >= event.rect().top(): - number = block_number + 1 - - if number == active_line_number: - font.setWeight(font.Bold) - painter.setFont(font) - painter.setPen( - self._highlighter.get_foreground_color()) - else: - font.setWeight(font.Normal) - painter.setFont(font) - painter.setPen(QColor(Qt.darkGray)) - right_padding = self.linenumberarea._right_padding - painter.drawText( - 0, - top, - self.linenumberarea.width() - right_padding, - self.fontMetrics().height(), - Qt.AlignRight, str(number), - ) - - block = block.next() - top = bottom - bottom = top + round(self.blockBoundingRect(block).height()) - block_number += 1 - - def linenumberarea_width(self): - """ - Return the line number area width. - - Returns - ------- - int - Line number are width in pixels. - - Notes - ----- - If the line number area is disabled this will return zero. - """ - width = 0 - if self._linenumber_enabled: - digits = 1 - count = max(1, self.blockCount()) - while count >= 10: - count /= 10 - digits += 1 - - fm = self.fontMetrics() - width = (self.linenumberarea._left_padding - + self.linenumberarea._right_padding - + fm.width('9') * digits) - - return width - - def update_linenumberarea_width(self, new_block_count=None): - """ - Update the line number area width based on the number of blocks in - the document. - - Parameters - ---------- - new_block_count: int - The current number of blocks in the document. - """ - self.setViewportMargins(self.linenumberarea_width(), 0, 0, 0) - - def update_linenumberarea(self, rect, dy): - """ - Update scroll position of line number area. - """ - if self._linenumber_enabled: - if dy: - self.linenumberarea.scroll(0, dy) - else: - self.linenumberarea.update( - 0, rect.y(), self.linenumberarea.width(), rect.height()) - - if rect.contains(self.viewport().rect()): - self.update_linenumberarea_width(0) - - # --- Text and cursor handling - # ------------------------------------------------------------------------ - def set_selection(self, start, end): - """ - Set current text selection. - - Parameters - ---------- - start: int - Selection start position. - end: int - Selection end position. - """ - cursor = self.textCursor() - cursor.setPosition(start) - cursor.setPosition(end, QTextCursor.KeepAnchor) - self.setTextCursor(cursor) - - def stdkey_backspace(self): - if not self.has_selected_text(): - self.moveCursor(QTextCursor.PreviousCharacter, - QTextCursor.KeepAnchor) - self.remove_selected_text() - - def restrict_cursor_position(self, position_from, position_to): - """ - Restrict the cursor from being inside from and to positions. - - Parameters - ---------- - position_from: int - Selection start position. - position_to: int - Selection end position. - """ - position_from = self.get_position(position_from) - position_to = self.get_position(position_to) - cursor = self.textCursor() - cursor_position = cursor.position() - if cursor_position < position_from or cursor_position > position_to: - self.set_cursor_position(position_to) - - def truncate_selection(self, position_from): - """ - Restrict the cursor selection to start from the given position. - - Parameters - ---------- - position_from: int - Selection start position. - """ - position_from = self.get_position(position_from) - cursor = self.textCursor() - start, end = cursor.selectionStart(), cursor.selectionEnd() - if start < end: - start = max([position_from, start]) - else: - end = max([position_from, end]) - - self.set_selection(start, end) - - def set_text(self, text): - """ - Set `text` of the document. - - Parameters - ---------- - text: str - Text to set. - """ - self.setPlainText(text) - - def append(self, text): - """ - Add `text` to the end of the document. - - Parameters - ---------- - text: str - Text to append. - """ - cursor = self.textCursor() - cursor.movePosition(QTextCursor.End) - cursor.insertText(text) - - def get_visible_block_numbers(self): - """Get the first and last visible block numbers.""" - first = self.firstVisibleBlock().blockNumber() - bottom_right = QPoint(self.viewport().width() - 1, - self.viewport().height() - 1) - last = self.cursorForPosition(bottom_right).blockNumber() - return (first, last) - - # --- Syntax highlighter - # ------------------------------------------------------------------------ - def rehighlight(self): - """ - Reapply syntax highligthing to the document. - """ - if self._highlighter: - self._highlighter.rehighlight() - - -if __name__ == "__main__": - from spyder.utils.qthelpers import qapplication - - app = qapplication() - editor = SimpleCodeEditor() - editor.setup_editor(language="markdown") - editor.set_text("# Hello!") - editor.show() - app.exec_() +# -*- coding: utf-8 -*- +# ----------------------------------------------------------------------------- +# Copyright © Spyder Project Contributors +# +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) +# ---------------------------------------------------------------------------- + +""" +Simple code editor with syntax highlighting and line number area. + +Adapted from: +https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html +""" + +# Third party imports +from qtpy.QtCore import QPoint, QRect, QSize, Qt, Signal +from qtpy.QtGui import QColor, QPainter, QTextCursor, QTextFormat, QTextOption +from qtpy.QtWidgets import QPlainTextEdit, QTextEdit, QWidget + +# Local imports +import spyder.utils.syntaxhighlighters as sh +from spyder.widgets.mixins import BaseEditMixin + + +# Constants +LANGUAGE_EXTENSIONS = { + 'Python': ('py', 'pyw', 'python', 'ipy'), + 'Cython': ('pyx', 'pxi', 'pxd'), + 'Enaml': ('enaml',), + 'Fortran77': ('f', 'for', 'f77'), + 'Fortran': ('f90', 'f95', 'f2k', 'f03', 'f08'), + 'Idl': ('pro',), + 'Diff': ('diff', 'patch', 'rej'), + 'GetText': ('po', 'pot'), + 'Nsis': ('nsi', 'nsh'), + 'Html': ('htm', 'html'), + 'Cpp': ('c', 'cc', 'cpp', 'cxx', 'h', 'hh', 'hpp', 'hxx'), + 'OpenCL': ('cl',), + 'Yaml': ('yaml', 'yml'), + 'Markdown': ('md', 'mdw'), + # Every other language + 'None': ('', ), +} + + +class LineNumberArea(QWidget): + """ + Adapted from: + https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html + """ + + def __init__(self, code_editor=None): + super().__init__(code_editor) + + self._editor = code_editor + self._left_padding = 6 # Pixels + self._right_padding = 3 # Pixels + + # --- Qt overrides + # ------------------------------------------------------------------------ + def sizeHint(self): + return QSize(self._editor.linenumberarea_width(), 0) + + def paintEvent(self, event): + self._editor.linenumberarea_paint_event(event) + + +class SimpleCodeEditor(QPlainTextEdit, BaseEditMixin): + """Simple editor with highlight features.""" + + LANGUAGE_HIGHLIGHTERS = { + 'Python': (sh.PythonSH, '#'), + 'Cython': (sh.CythonSH, '#'), + 'Fortran77': (sh.Fortran77SH, 'c'), + 'Fortran': (sh.FortranSH, '!'), + 'Idl': (sh.IdlSH, ';'), + 'Diff': (sh.DiffSH, ''), + 'GetText': (sh.GetTextSH, '#'), + 'Nsis': (sh.NsisSH, '#'), + 'Html': (sh.HtmlSH, ''), + 'Yaml': (sh.YamlSH, '#'), + 'Cpp': (sh.CppSH, '//'), + 'OpenCL': (sh.OpenCLSH, '//'), + 'Enaml': (sh.EnamlSH, '#'), + 'Markdown': (sh.MarkdownSH, '#'), + # Every other language + 'None': (sh.TextSH, ''), + } + + # --- Signals + # ------------------------------------------------------------------------ + sig_focus_changed = Signal() + """ + This signal when the focus of the editor changes, either by a + `focusInEvent` or `focusOutEvent` event. + """ + + def __init__(self, parent=None): + super().__init__(parent) + + # Variables + self._linenumber_enabled = None + self._color_scheme = "spyder/dark" + self._language = None + self._blanks_enabled = None + self._scrollpastend_enabled = None + self._wrap_mode = None + self._highlight_current_line = None + self.supported_language = False + + # Widgets + self._highlighter = None + self.linenumberarea = LineNumberArea(self) + + # Widget setup + self.setObjectName(self.__class__.__name__ + str(id(self))) + self.update_linenumberarea_width(0) + self._apply_current_line_highlight() + + # Signals + self.blockCountChanged.connect(self.update_linenumberarea_width) + self.updateRequest.connect(self.update_linenumberarea) + self.cursorPositionChanged.connect(self._apply_current_line_highlight) + + # --- Private API + # ------------------------------------------------------------------------ + def _apply_color_scheme(self): + hl = self._highlighter + if hl is not None: + hl.setup_formats(self.font()) + if self._color_scheme is not None: + hl.set_color_scheme(self._color_scheme) + + self._set_palette(background=hl.get_background_color(), + foreground=hl.get_foreground_color()) + + def _set_palette(self, background, foreground): + style = ("QPlainTextEdit#%s {background: %s; color: %s;}" % + (self.objectName(), background.name(), foreground.name())) + self.setStyleSheet(style) + self.rehighlight() + + def _apply_current_line_highlight(self): + if self._highlighter and self._highlight_current_line: + extra_selections = [] + selection = QTextEdit.ExtraSelection() + line_color = self._highlighter.get_currentline_color() + selection.format.setBackground(line_color) + selection.format.setProperty(QTextFormat.FullWidthSelection, True) + selection.cursor = self.textCursor() + selection.cursor.clearSelection() + extra_selections.append(selection) + + self.setExtraSelections(extra_selections) + else: + self.setExtraSelections([]) + + # --- Qt Overrides + # ------------------------------------------------------------------------ + def focusInEvent(self, event): + self.sig_focus_changed.emit() + super().focusInEvent(event) + + def focusOutEvent(self, event): + self.sig_focus_changed.emit() + super().focusInEvent(event) + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._linenumber_enabled: + cr = self.contentsRect() + self.linenumberarea.setGeometry( + QRect( + cr.left(), + cr.top(), + self.linenumberarea_width(), + cr.height(), + ) + ) + + # --- Public API + # ------------------------------------------------------------------------ + def setup_editor(self, + linenumbers=True, + color_scheme="spyder/dark", + language="py", + font=None, + show_blanks=False, + wrap=False, + highlight_current_line=True, + scroll_past_end=False): + """ + Setup editor options. + + Parameters + ---------- + color_scheme: str, optional + Default is "spyder/dark". + language: str, optional + Default is "py". + font: QFont or None + Default is None. + show_blanks: bool, optional + Default is False/ + wrap: bool, optional + Default is False. + highlight_current_line: bool, optional + Default is True. + scroll_past_end: bool, optional + Default is False + """ + if font: + self.set_font(font) + + self.set_highlight_current_line(highlight_current_line) + self.set_blanks_enabled(show_blanks) + self.toggle_line_numbers(linenumbers) + self.set_scrollpastend_enabled(scroll_past_end) + self.set_language(language) + self.set_color_scheme(color_scheme) + self.toggle_wrap_mode(wrap) + + def set_font(self, font): + """ + Set the editor font. + + Parameters + ---------- + font: QFont + Font to use. + """ + if font: + self.setFont(font) + self._apply_color_scheme() + + def set_color_scheme(self, color_scheme): + """ + Set the editor color scheme. + + Parameters + ---------- + color_scheme: str + Color scheme to use. + """ + self._color_scheme = color_scheme + self._apply_color_scheme() + + def set_language(self, language): + """ + Set current syntax highlighting to use `language`. + + Parameters + ---------- + language: str or None + Language name or known extensions. + """ + sh_class = sh.TextSH + language = str(language).lower() + self.supported_language = False + for (key, value) in LANGUAGE_EXTENSIONS.items(): + if language in (key.lower(), ) + value: + sh_class, __ = self.LANGUAGE_HIGHLIGHTERS[key] + self._language = key + self.supported_language = True + + self._highlighter = sh_class( + self.document(), self.font(), self._color_scheme) + self._apply_color_scheme() + + def toggle_line_numbers(self, state): + """ + Set visibility of line number area + + Parameters + ---------- + state: bool + Visible state of the line number area. + """ + + self._linenumber_enabled = state + self.linenumberarea.setVisible(state) + self.update_linenumberarea_width(()) + + def set_scrollpastend_enabled(self, state): + """ + Set scroll past end state. + + Parameters + ---------- + state: bool + Scroll past end state. + """ + self._scrollpastend_enabled = state + self.setCenterOnScroll(state) + self.setDocument(self.document()) + + def toggle_wrap_mode(self, state): + """ + Set line wrap.. + + Parameters + ---------- + state: bool + Wrap state. + """ + self.set_wrap_mode('word' if state else None) + + def set_wrap_mode(self, mode=None): + """ + Set line wrap mode. + + Parameters + ---------- + mode: str or None, optional + "word", or "character". Default is None. + """ + if mode == 'word': + wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere + elif mode == 'character': + wrap_mode = QTextOption.WrapAnywhere + else: + wrap_mode = QTextOption.NoWrap + + self.setWordWrapMode(wrap_mode) + + def set_highlight_current_line(self, value): + """ + Set if the current line is highlighted. + + Parameters + ---------- + value: bool + The value of the current line highlight option. + """ + self._highlight_current_line = value + self._apply_current_line_highlight() + + def set_blanks_enabled(self, state): + """ + Show blank spaces. + + Parameters + ---------- + state: bool + Blank spaces visibility. + """ + self._blanks_enabled = state + option = self.document().defaultTextOption() + option.setFlags(option.flags() + | QTextOption.AddSpaceForLineAndParagraphSeparators) + + if self._blanks_enabled: + option.setFlags(option.flags() | QTextOption.ShowTabsAndSpaces) + else: + option.setFlags(option.flags() & ~QTextOption.ShowTabsAndSpaces) + + self.document().setDefaultTextOption(option) + + # Rehighlight to make the spaces less apparent. + self.rehighlight() + + # --- Line number area + # ------------------------------------------------------------------------ + def linenumberarea_paint_event(self, event): + """ + Paint the line number area. + """ + if self._linenumber_enabled: + painter = QPainter(self.linenumberarea) + painter.fillRect( + event.rect(), + self._highlighter.get_sideareas_color(), + ) + + block = self.firstVisibleBlock() + block_number = block.blockNumber() + top = round(self.blockBoundingGeometry(block).translated( + self.contentOffset()).top()) + bottom = top + round(self.blockBoundingRect(block).height()) + + font = self.font() + active_block = self.textCursor().block() + active_line_number = active_block.blockNumber() + 1 + + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + number = block_number + 1 + + if number == active_line_number: + font.setWeight(font.Bold) + painter.setFont(font) + painter.setPen( + self._highlighter.get_foreground_color()) + else: + font.setWeight(font.Normal) + painter.setFont(font) + painter.setPen(QColor(Qt.darkGray)) + right_padding = self.linenumberarea._right_padding + painter.drawText( + 0, + top, + self.linenumberarea.width() - right_padding, + self.fontMetrics().height(), + Qt.AlignRight, str(number), + ) + + block = block.next() + top = bottom + bottom = top + round(self.blockBoundingRect(block).height()) + block_number += 1 + + def linenumberarea_width(self): + """ + Return the line number area width. + + Returns + ------- + int + Line number are width in pixels. + + Notes + ----- + If the line number area is disabled this will return zero. + """ + width = 0 + if self._linenumber_enabled: + digits = 1 + count = max(1, self.blockCount()) + while count >= 10: + count /= 10 + digits += 1 + + fm = self.fontMetrics() + width = (self.linenumberarea._left_padding + + self.linenumberarea._right_padding + + fm.width('9') * digits) + + return width + + def update_linenumberarea_width(self, new_block_count=None): + """ + Update the line number area width based on the number of blocks in + the document. + + Parameters + ---------- + new_block_count: int + The current number of blocks in the document. + """ + self.setViewportMargins(self.linenumberarea_width(), 0, 0, 0) + + def update_linenumberarea(self, rect, dy): + """ + Update scroll position of line number area. + """ + if self._linenumber_enabled: + if dy: + self.linenumberarea.scroll(0, dy) + else: + self.linenumberarea.update( + 0, rect.y(), self.linenumberarea.width(), rect.height()) + + if rect.contains(self.viewport().rect()): + self.update_linenumberarea_width(0) + + # --- Text and cursor handling + # ------------------------------------------------------------------------ + def set_selection(self, start, end): + """ + Set current text selection. + + Parameters + ---------- + start: int + Selection start position. + end: int + Selection end position. + """ + cursor = self.textCursor() + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.KeepAnchor) + self.setTextCursor(cursor) + + def stdkey_backspace(self): + if not self.has_selected_text(): + self.moveCursor(QTextCursor.PreviousCharacter, + QTextCursor.KeepAnchor) + self.remove_selected_text() + + def restrict_cursor_position(self, position_from, position_to): + """ + Restrict the cursor from being inside from and to positions. + + Parameters + ---------- + position_from: int + Selection start position. + position_to: int + Selection end position. + """ + position_from = self.get_position(position_from) + position_to = self.get_position(position_to) + cursor = self.textCursor() + cursor_position = cursor.position() + if cursor_position < position_from or cursor_position > position_to: + self.set_cursor_position(position_to) + + def truncate_selection(self, position_from): + """ + Restrict the cursor selection to start from the given position. + + Parameters + ---------- + position_from: int + Selection start position. + """ + position_from = self.get_position(position_from) + cursor = self.textCursor() + start, end = cursor.selectionStart(), cursor.selectionEnd() + if start < end: + start = max([position_from, start]) + else: + end = max([position_from, end]) + + self.set_selection(start, end) + + def set_text(self, text): + """ + Set `text` of the document. + + Parameters + ---------- + text: str + Text to set. + """ + self.setPlainText(text) + + def append(self, text): + """ + Add `text` to the end of the document. + + Parameters + ---------- + text: str + Text to append. + """ + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + cursor.insertText(text) + + def get_visible_block_numbers(self): + """Get the first and last visible block numbers.""" + first = self.firstVisibleBlock().blockNumber() + bottom_right = QPoint(self.viewport().width() - 1, + self.viewport().height() - 1) + last = self.cursorForPosition(bottom_right).blockNumber() + return (first, last) + + # --- Syntax highlighter + # ------------------------------------------------------------------------ + def rehighlight(self): + """ + Reapply syntax highligthing to the document. + """ + if self._highlighter: + self._highlighter.rehighlight() + + +if __name__ == "__main__": + from spyder.utils.qthelpers import qapplication + + app = qapplication() + editor = SimpleCodeEditor() + editor.setup_editor(language="markdown") + editor.set_text("# Hello!") + editor.show() + app.exec_() diff --git a/spyder/widgets/tabs.py b/spyder/widgets/tabs.py index b3facaf0b1a..5a887038182 100644 --- a/spyder/widgets/tabs.py +++ b/spyder/widgets/tabs.py @@ -1,506 +1,506 @@ -# -*- coding: utf-8 -*- -# -# Copyright © Spyder Project Contributors -# Licensed under the terms of the MIT License -# (see spyder/__init__.py for details) - -"""Tabs widget""" - -# pylint: disable=C0103 -# pylint: disable=R0903 -# pylint: disable=R0911 -# pylint: disable=R0201 - -# Standard library imports -import os -import os.path as osp -import sys - -# Third party imports -from qtpy import PYQT5 -from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot -from qtpy.QtWidgets import (QHBoxLayout, QMenu, QTabBar, - QTabWidget, QWidget, QLineEdit) - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.misc import get_common_path -from spyder.utils.qthelpers import (add_actions, create_action, - create_toolbutton) -from spyder.utils.stylesheet import PANES_TABBAR_STYLESHEET - - -class EditTabNamePopup(QLineEdit): - """Popup on top of the tab to edit its name.""" - - def __init__(self, parent, split_char, split_index): - """Popup on top of the tab to edit its name.""" - - # Variables - # Parent (main) - self.main = parent if parent is not None else self.parent() - self.split_char = split_char - self.split_index = split_index - - # Track which tab is being edited - self.tab_index = None - - # Widget setup - QLineEdit.__init__(self, parent=parent) - - # Slot to handle tab name update - self.editingFinished.connect(self.edit_finished) - - # Even filter to catch clicks and ESC key - self.installEventFilter(self) - - # Clean borders and no shadow to blend with tab - if PYQT5: - self.setWindowFlags( - Qt.Popup | - Qt.FramelessWindowHint | - Qt.NoDropShadowWindowHint - ) - else: - self.setWindowFlags( - Qt.Popup | - Qt.FramelessWindowHint - ) - self.setFrame(False) - - # Align with tab name - self.setTextMargins(9, 0, 0, 0) - - def eventFilter(self, widget, event): - """Catch clicks outside the object and ESC key press.""" - if ((event.type() == QEvent.MouseButtonPress and - not self.geometry().contains(event.globalPos())) or - (event.type() == QEvent.KeyPress and - event.key() == Qt.Key_Escape)): - # Exits editing - self.hide() - return True - - # Event is not interessant, raise to parent - return QLineEdit.eventFilter(self, widget, event) - - def edit_tab(self, index): - """Activate the edit tab.""" - - # Sets focus, shows cursor - self.setFocus() - - # Updates tab index - self.tab_index = index - - # Gets tab size and shrinks to avoid overlapping tab borders - rect = self.main.tabRect(index) - rect.adjust(1, 1, -2, -1) - - # Sets size - self.setFixedSize(rect.size()) - - # Places on top of the tab - self.move(self.main.mapToGlobal(rect.topLeft())) - - # Copies tab name and selects all - text = self.main.tabText(index) - text = text.replace(u'&', u'') - if self.split_char: - text = text.split(self.split_char)[self.split_index] - - self.setText(text) - self.selectAll() - - if not self.isVisible(): - # Makes editor visible - self.show() - - def edit_finished(self): - """On clean exit, update tab name.""" - # Hides editor - self.hide() - - if isinstance(self.tab_index, int) and self.tab_index >= 0: - # We are editing a valid tab, update name - tab_text = to_text_string(self.text()) - self.main.setTabText(self.tab_index, tab_text) - self.main.sig_name_changed.emit(tab_text) - - -class TabBar(QTabBar): - """Tabs base class with drag and drop support""" - sig_move_tab = Signal((int, int), (str, int, int)) - sig_name_changed = Signal(str) - - def __init__(self, parent, ancestor, rename_tabs=False, split_char='', - split_index=0): - QTabBar.__init__(self, parent) - self.ancestor = ancestor - self.setObjectName('pane-tabbar') - - # Dragging tabs - self.__drag_start_pos = QPoint() - self.setAcceptDrops(True) - self.setUsesScrollButtons(True) - self.setMovable(True) - - # Tab name editor - self.rename_tabs = rename_tabs - if self.rename_tabs: - # Creates tab name editor - self.tab_name_editor = EditTabNamePopup(self, split_char, - split_index) - else: - self.tab_name_editor = None - - def mousePressEvent(self, event): - """Reimplement Qt method""" - if event.button() == Qt.LeftButton: - self.__drag_start_pos = QPoint(event.pos()) - QTabBar.mousePressEvent(self, event) - - def mouseMoveEvent(self, event): - """Override Qt method""" - # FIXME: This was added by Pierre presumably to move tabs - # between plugins, but righit now it's breaking the regular - # Qt drag behavior for tabs, so we're commenting it for - # now - #if event.buttons() == Qt.MouseButtons(Qt.LeftButton) and \ - # (event.pos() - self.__drag_start_pos).manhattanLength() > \ - # QApplication.startDragDistance(): - # drag = QDrag(self) - # mimeData = QMimeData()# - - # ancestor_id = to_text_string(id(self.ancestor)) - # parent_widget_id = to_text_string(id(self.parentWidget())) - # self_id = to_text_string(id(self)) - # source_index = to_text_string(self.tabAt(self.__drag_start_pos)) - - # mimeData.setData("parent-id", to_binary_string(ancestor_id)) - # mimeData.setData("tabwidget-id", - # to_binary_string(parent_widget_id)) - # mimeData.setData("tabbar-id", to_binary_string(self_id)) - # mimeData.setData("source-index", to_binary_string(source_index)) - - # drag.setMimeData(mimeData) - # drag.exec_() - QTabBar.mouseMoveEvent(self, event) - - def dragEnterEvent(self, event): - """Override Qt method""" - mimeData = event.mimeData() - formats = list(mimeData.formats()) - - if "parent-id" in formats and \ - int(mimeData.data("parent-id")) == id(self.ancestor): - event.acceptProposedAction() - - QTabBar.dragEnterEvent(self, event) - - def dropEvent(self, event): - """Override Qt method""" - mimeData = event.mimeData() - index_from = int(mimeData.data("source-index")) - index_to = self.tabAt(event.pos()) - if index_to == -1: - index_to = self.count() - if int(mimeData.data("tabbar-id")) != id(self): - tabwidget_from = to_text_string(mimeData.data("tabwidget-id")) - - # We pass self object ID as a QString, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.sig_move_tab[(str, int, int)].emit(tabwidget_from, index_from, - index_to) - event.acceptProposedAction() - elif index_from != index_to: - self.sig_move_tab.emit(index_from, index_to) - event.acceptProposedAction() - QTabBar.dropEvent(self, event) - - def mouseDoubleClickEvent(self, event): - """Override Qt method to trigger the tab name editor.""" - if self.rename_tabs is True and \ - event.buttons() == Qt.MouseButtons(Qt.LeftButton): - # Tab index - index = self.tabAt(event.pos()) - if index >= 0: - # Tab is valid, call tab name editor - self.tab_name_editor.edit_tab(index) - else: - # Event is not interesting, raise to parent - QTabBar.mouseDoubleClickEvent(self, event) - - -class BaseTabs(QTabWidget): - """TabWidget with context menu and corner widgets""" - sig_close_tab = Signal(int) - - def __init__(self, parent, actions=None, menu=None, - corner_widgets=None, menu_use_tooltips=False): - QTabWidget.__init__(self, parent) - self.setUsesScrollButtons(True) - self.tabBar().setObjectName('pane-tabbar') - - self.corner_widgets = {} - self.menu_use_tooltips = menu_use_tooltips - - if menu is None: - self.menu = QMenu(self) - if actions: - add_actions(self.menu, actions) - else: - self.menu = menu - - self.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) - - # Corner widgets - if corner_widgets is None: - corner_widgets = {} - corner_widgets.setdefault(Qt.TopLeftCorner, []) - corner_widgets.setdefault(Qt.TopRightCorner, []) - - self.browse_button = create_toolbutton( - self, icon=ima.icon('browse_tab'), tip=_("Browse tabs")) - self.browse_button.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) - - self.browse_tabs_menu = QMenu(self) - self.browse_tabs_menu.setObjectName('checkbox-padding') - self.browse_button.setMenu(self.browse_tabs_menu) - self.browse_button.setPopupMode(self.browse_button.InstantPopup) - self.browse_tabs_menu.aboutToShow.connect(self.update_browse_tabs_menu) - corner_widgets[Qt.TopLeftCorner] += [self.browse_button] - - self.set_corner_widgets(corner_widgets) - - def update_browse_tabs_menu(self): - """Update browse tabs menu""" - self.browse_tabs_menu.clear() - names = [] - dirnames = [] - for index in range(self.count()): - if self.menu_use_tooltips: - text = to_text_string(self.tabToolTip(index)) - else: - text = to_text_string(self.tabText(index)) - names.append(text) - if osp.isfile(text): - # Testing if tab names are filenames - dirnames.append(osp.dirname(text)) - offset = None - - # If tab names are all filenames, removing common path: - if len(names) == len(dirnames): - common = get_common_path(dirnames) - if common is None: - offset = None - else: - offset = len(common)+1 - if offset <= 3: - # Common path is not a path but a drive letter... - offset = None - - for index, text in enumerate(names): - tab_action = create_action(self, text[offset:], - icon=self.tabIcon(index), - toggled=lambda state, index=index: - self.setCurrentIndex(index), - tip=self.tabToolTip(index)) - tab_action.setChecked(index == self.currentIndex()) - self.browse_tabs_menu.addAction(tab_action) - - def set_corner_widgets(self, corner_widgets): - """ - Set tabs corner widgets - corner_widgets: dictionary of (corner, widgets) - corner: Qt.TopLeftCorner or Qt.TopRightCorner - widgets: list of widgets (may contains integers to add spacings) - """ - assert isinstance(corner_widgets, dict) - assert all(key in (Qt.TopLeftCorner, Qt.TopRightCorner) - for key in corner_widgets) - self.corner_widgets.update(corner_widgets) - for corner, widgets in list(self.corner_widgets.items()): - cwidget = QWidget() - cwidget.hide() - - # This removes some white dots in our tabs (not all but most). - # See spyder-ide/spyder#15081 - cwidget.setObjectName('corner-widget') - cwidget.setStyleSheet( - "QWidget#corner-widget {border-radius: '0px'}") - - prev_widget = self.cornerWidget(corner) - if prev_widget: - prev_widget.close() - self.setCornerWidget(cwidget, corner) - clayout = QHBoxLayout() - clayout.setContentsMargins(0, 0, 0, 0) - for widget in widgets: - if isinstance(widget, int): - clayout.addSpacing(widget) - else: - clayout.addWidget(widget) - cwidget.setLayout(clayout) - cwidget.show() - - def add_corner_widgets(self, widgets, corner=Qt.TopRightCorner): - self.set_corner_widgets({corner: - self.corner_widgets.get(corner, [])+widgets}) - - def get_offset_pos(self, event): - """ - Add offset to position event to capture the mouse cursor - inside a tab. - """ - # This is necessary because event.pos() is the position in this - # widget, not in the tabBar. see spyder-ide/spyder#12617 - tb = self.tabBar() - point = tb.mapFromGlobal(event.globalPos()) - return tb.tabAt(point) - - def contextMenuEvent(self, event): - """Override Qt method""" - index = self.get_offset_pos(event) - self.setCurrentIndex(index) - if self.menu: - self.menu.popup(event.globalPos()) - - def mousePressEvent(self, event): - """Override Qt method""" - if event.button() == Qt.MidButton: - index = self.get_offset_pos(event) - if index >= 0: - self.sig_close_tab.emit(index) - event.accept() - return - QTabWidget.mousePressEvent(self, event) - - def keyPressEvent(self, event): - """Override Qt method""" - ctrl = event.modifiers() & Qt.ControlModifier - key = event.key() - handled = False - if ctrl and self.count() > 0: - index = self.currentIndex() - if key == Qt.Key_PageUp: - if index > 0: - self.setCurrentIndex(index - 1) - else: - self.setCurrentIndex(self.count() - 1) - handled = True - elif key == Qt.Key_PageDown: - if index < self.count() - 1: - self.setCurrentIndex(index + 1) - else: - self.setCurrentIndex(0) - handled = True - if not handled: - QTabWidget.keyPressEvent(self, event) - - def tab_navigate(self, delta=1): - """Ctrl+Tab""" - if delta > 0 and self.currentIndex() == self.count()-1: - index = delta-1 - elif delta < 0 and self.currentIndex() == 0: - index = self.count()+delta - else: - index = self.currentIndex()+delta - self.setCurrentIndex(index) - - def set_close_function(self, func): - """Setting Tabs close function - None -> tabs are not closable""" - state = func is not None - if state: - self.sig_close_tab.connect(func) - try: - # Assuming Qt >= 4.5 - QTabWidget.setTabsClosable(self, state) - self.tabCloseRequested.connect(func) - except AttributeError: - # Workaround for Qt < 4.5 - close_button = create_toolbutton(self, triggered=func, - icon=ima.icon('fileclose'), - tip=_("Close current tab")) - self.setCornerWidget(close_button if state else None) - - -class Tabs(BaseTabs): - """BaseTabs widget with movable tabs and tab navigation shortcuts.""" - # Signals - move_data = Signal(int, int) - move_tab_finished = Signal() - sig_move_tab = Signal(str, str, int, int) - - def __init__(self, parent, actions=None, menu=None, - corner_widgets=None, menu_use_tooltips=False, - rename_tabs=False, split_char='', - split_index=0): - BaseTabs.__init__(self, parent, actions, menu, - corner_widgets, menu_use_tooltips) - tab_bar = TabBar(self, parent, - rename_tabs=rename_tabs, - split_char=split_char, - split_index=split_index) - tab_bar.sig_move_tab.connect(self.move_tab) - tab_bar.sig_move_tab[(str, int, int)].connect( - self.move_tab_from_another_tabwidget) - self.setTabBar(tab_bar) - - CONF.config_shortcut( - lambda: self.tab_navigate(1), - context='editor', - name='go to next file', - parent=parent) - - CONF.config_shortcut( - lambda: self.tab_navigate(-1), - context='editor', - name='go to previous file', - parent=parent) - - CONF.config_shortcut( - lambda: self.sig_close_tab.emit(self.currentIndex()), - context='editor', - name='close file 1', - parent=parent) - - CONF.config_shortcut( - lambda: self.sig_close_tab.emit(self.currentIndex()), - context='editor', - name='close file 2', - parent=parent) - - @Slot(int, int) - def move_tab(self, index_from, index_to): - """Move tab inside a tabwidget""" - self.move_data.emit(index_from, index_to) - - tip, text = self.tabToolTip(index_from), self.tabText(index_from) - icon, widget = self.tabIcon(index_from), self.widget(index_from) - current_widget = self.currentWidget() - - self.removeTab(index_from) - self.insertTab(index_to, widget, icon, text) - self.setTabToolTip(index_to, tip) - - self.setCurrentWidget(current_widget) - self.move_tab_finished.emit() - - @Slot(str, int, int) - def move_tab_from_another_tabwidget(self, tabwidget_from, - index_from, index_to): - """Move tab from a tabwidget to another""" - - # We pass self object IDs as QString objs, because otherwise it would - # depend on the platform: long for 64bit, int for 32bit. Replacing - # by long all the time is not working on some 32bit platforms. - # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. - self.sig_move_tab.emit(tabwidget_from, to_text_string(id(self)), - index_from, index_to) +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Tabs widget""" + +# pylint: disable=C0103 +# pylint: disable=R0903 +# pylint: disable=R0911 +# pylint: disable=R0201 + +# Standard library imports +import os +import os.path as osp +import sys + +# Third party imports +from qtpy import PYQT5 +from qtpy.QtCore import QEvent, QPoint, Qt, Signal, Slot +from qtpy.QtWidgets import (QHBoxLayout, QMenu, QTabBar, + QTabWidget, QWidget, QLineEdit) + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.misc import get_common_path +from spyder.utils.qthelpers import (add_actions, create_action, + create_toolbutton) +from spyder.utils.stylesheet import PANES_TABBAR_STYLESHEET + + +class EditTabNamePopup(QLineEdit): + """Popup on top of the tab to edit its name.""" + + def __init__(self, parent, split_char, split_index): + """Popup on top of the tab to edit its name.""" + + # Variables + # Parent (main) + self.main = parent if parent is not None else self.parent() + self.split_char = split_char + self.split_index = split_index + + # Track which tab is being edited + self.tab_index = None + + # Widget setup + QLineEdit.__init__(self, parent=parent) + + # Slot to handle tab name update + self.editingFinished.connect(self.edit_finished) + + # Even filter to catch clicks and ESC key + self.installEventFilter(self) + + # Clean borders and no shadow to blend with tab + if PYQT5: + self.setWindowFlags( + Qt.Popup | + Qt.FramelessWindowHint | + Qt.NoDropShadowWindowHint + ) + else: + self.setWindowFlags( + Qt.Popup | + Qt.FramelessWindowHint + ) + self.setFrame(False) + + # Align with tab name + self.setTextMargins(9, 0, 0, 0) + + def eventFilter(self, widget, event): + """Catch clicks outside the object and ESC key press.""" + if ((event.type() == QEvent.MouseButtonPress and + not self.geometry().contains(event.globalPos())) or + (event.type() == QEvent.KeyPress and + event.key() == Qt.Key_Escape)): + # Exits editing + self.hide() + return True + + # Event is not interessant, raise to parent + return QLineEdit.eventFilter(self, widget, event) + + def edit_tab(self, index): + """Activate the edit tab.""" + + # Sets focus, shows cursor + self.setFocus() + + # Updates tab index + self.tab_index = index + + # Gets tab size and shrinks to avoid overlapping tab borders + rect = self.main.tabRect(index) + rect.adjust(1, 1, -2, -1) + + # Sets size + self.setFixedSize(rect.size()) + + # Places on top of the tab + self.move(self.main.mapToGlobal(rect.topLeft())) + + # Copies tab name and selects all + text = self.main.tabText(index) + text = text.replace(u'&', u'') + if self.split_char: + text = text.split(self.split_char)[self.split_index] + + self.setText(text) + self.selectAll() + + if not self.isVisible(): + # Makes editor visible + self.show() + + def edit_finished(self): + """On clean exit, update tab name.""" + # Hides editor + self.hide() + + if isinstance(self.tab_index, int) and self.tab_index >= 0: + # We are editing a valid tab, update name + tab_text = to_text_string(self.text()) + self.main.setTabText(self.tab_index, tab_text) + self.main.sig_name_changed.emit(tab_text) + + +class TabBar(QTabBar): + """Tabs base class with drag and drop support""" + sig_move_tab = Signal((int, int), (str, int, int)) + sig_name_changed = Signal(str) + + def __init__(self, parent, ancestor, rename_tabs=False, split_char='', + split_index=0): + QTabBar.__init__(self, parent) + self.ancestor = ancestor + self.setObjectName('pane-tabbar') + + # Dragging tabs + self.__drag_start_pos = QPoint() + self.setAcceptDrops(True) + self.setUsesScrollButtons(True) + self.setMovable(True) + + # Tab name editor + self.rename_tabs = rename_tabs + if self.rename_tabs: + # Creates tab name editor + self.tab_name_editor = EditTabNamePopup(self, split_char, + split_index) + else: + self.tab_name_editor = None + + def mousePressEvent(self, event): + """Reimplement Qt method""" + if event.button() == Qt.LeftButton: + self.__drag_start_pos = QPoint(event.pos()) + QTabBar.mousePressEvent(self, event) + + def mouseMoveEvent(self, event): + """Override Qt method""" + # FIXME: This was added by Pierre presumably to move tabs + # between plugins, but righit now it's breaking the regular + # Qt drag behavior for tabs, so we're commenting it for + # now + #if event.buttons() == Qt.MouseButtons(Qt.LeftButton) and \ + # (event.pos() - self.__drag_start_pos).manhattanLength() > \ + # QApplication.startDragDistance(): + # drag = QDrag(self) + # mimeData = QMimeData()# + + # ancestor_id = to_text_string(id(self.ancestor)) + # parent_widget_id = to_text_string(id(self.parentWidget())) + # self_id = to_text_string(id(self)) + # source_index = to_text_string(self.tabAt(self.__drag_start_pos)) + + # mimeData.setData("parent-id", to_binary_string(ancestor_id)) + # mimeData.setData("tabwidget-id", + # to_binary_string(parent_widget_id)) + # mimeData.setData("tabbar-id", to_binary_string(self_id)) + # mimeData.setData("source-index", to_binary_string(source_index)) + + # drag.setMimeData(mimeData) + # drag.exec_() + QTabBar.mouseMoveEvent(self, event) + + def dragEnterEvent(self, event): + """Override Qt method""" + mimeData = event.mimeData() + formats = list(mimeData.formats()) + + if "parent-id" in formats and \ + int(mimeData.data("parent-id")) == id(self.ancestor): + event.acceptProposedAction() + + QTabBar.dragEnterEvent(self, event) + + def dropEvent(self, event): + """Override Qt method""" + mimeData = event.mimeData() + index_from = int(mimeData.data("source-index")) + index_to = self.tabAt(event.pos()) + if index_to == -1: + index_to = self.count() + if int(mimeData.data("tabbar-id")) != id(self): + tabwidget_from = to_text_string(mimeData.data("tabwidget-id")) + + # We pass self object ID as a QString, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.sig_move_tab[(str, int, int)].emit(tabwidget_from, index_from, + index_to) + event.acceptProposedAction() + elif index_from != index_to: + self.sig_move_tab.emit(index_from, index_to) + event.acceptProposedAction() + QTabBar.dropEvent(self, event) + + def mouseDoubleClickEvent(self, event): + """Override Qt method to trigger the tab name editor.""" + if self.rename_tabs is True and \ + event.buttons() == Qt.MouseButtons(Qt.LeftButton): + # Tab index + index = self.tabAt(event.pos()) + if index >= 0: + # Tab is valid, call tab name editor + self.tab_name_editor.edit_tab(index) + else: + # Event is not interesting, raise to parent + QTabBar.mouseDoubleClickEvent(self, event) + + +class BaseTabs(QTabWidget): + """TabWidget with context menu and corner widgets""" + sig_close_tab = Signal(int) + + def __init__(self, parent, actions=None, menu=None, + corner_widgets=None, menu_use_tooltips=False): + QTabWidget.__init__(self, parent) + self.setUsesScrollButtons(True) + self.tabBar().setObjectName('pane-tabbar') + + self.corner_widgets = {} + self.menu_use_tooltips = menu_use_tooltips + + if menu is None: + self.menu = QMenu(self) + if actions: + add_actions(self.menu, actions) + else: + self.menu = menu + + self.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) + + # Corner widgets + if corner_widgets is None: + corner_widgets = {} + corner_widgets.setdefault(Qt.TopLeftCorner, []) + corner_widgets.setdefault(Qt.TopRightCorner, []) + + self.browse_button = create_toolbutton( + self, icon=ima.icon('browse_tab'), tip=_("Browse tabs")) + self.browse_button.setStyleSheet(str(PANES_TABBAR_STYLESHEET)) + + self.browse_tabs_menu = QMenu(self) + self.browse_tabs_menu.setObjectName('checkbox-padding') + self.browse_button.setMenu(self.browse_tabs_menu) + self.browse_button.setPopupMode(self.browse_button.InstantPopup) + self.browse_tabs_menu.aboutToShow.connect(self.update_browse_tabs_menu) + corner_widgets[Qt.TopLeftCorner] += [self.browse_button] + + self.set_corner_widgets(corner_widgets) + + def update_browse_tabs_menu(self): + """Update browse tabs menu""" + self.browse_tabs_menu.clear() + names = [] + dirnames = [] + for index in range(self.count()): + if self.menu_use_tooltips: + text = to_text_string(self.tabToolTip(index)) + else: + text = to_text_string(self.tabText(index)) + names.append(text) + if osp.isfile(text): + # Testing if tab names are filenames + dirnames.append(osp.dirname(text)) + offset = None + + # If tab names are all filenames, removing common path: + if len(names) == len(dirnames): + common = get_common_path(dirnames) + if common is None: + offset = None + else: + offset = len(common)+1 + if offset <= 3: + # Common path is not a path but a drive letter... + offset = None + + for index, text in enumerate(names): + tab_action = create_action(self, text[offset:], + icon=self.tabIcon(index), + toggled=lambda state, index=index: + self.setCurrentIndex(index), + tip=self.tabToolTip(index)) + tab_action.setChecked(index == self.currentIndex()) + self.browse_tabs_menu.addAction(tab_action) + + def set_corner_widgets(self, corner_widgets): + """ + Set tabs corner widgets + corner_widgets: dictionary of (corner, widgets) + corner: Qt.TopLeftCorner or Qt.TopRightCorner + widgets: list of widgets (may contains integers to add spacings) + """ + assert isinstance(corner_widgets, dict) + assert all(key in (Qt.TopLeftCorner, Qt.TopRightCorner) + for key in corner_widgets) + self.corner_widgets.update(corner_widgets) + for corner, widgets in list(self.corner_widgets.items()): + cwidget = QWidget() + cwidget.hide() + + # This removes some white dots in our tabs (not all but most). + # See spyder-ide/spyder#15081 + cwidget.setObjectName('corner-widget') + cwidget.setStyleSheet( + "QWidget#corner-widget {border-radius: '0px'}") + + prev_widget = self.cornerWidget(corner) + if prev_widget: + prev_widget.close() + self.setCornerWidget(cwidget, corner) + clayout = QHBoxLayout() + clayout.setContentsMargins(0, 0, 0, 0) + for widget in widgets: + if isinstance(widget, int): + clayout.addSpacing(widget) + else: + clayout.addWidget(widget) + cwidget.setLayout(clayout) + cwidget.show() + + def add_corner_widgets(self, widgets, corner=Qt.TopRightCorner): + self.set_corner_widgets({corner: + self.corner_widgets.get(corner, [])+widgets}) + + def get_offset_pos(self, event): + """ + Add offset to position event to capture the mouse cursor + inside a tab. + """ + # This is necessary because event.pos() is the position in this + # widget, not in the tabBar. see spyder-ide/spyder#12617 + tb = self.tabBar() + point = tb.mapFromGlobal(event.globalPos()) + return tb.tabAt(point) + + def contextMenuEvent(self, event): + """Override Qt method""" + index = self.get_offset_pos(event) + self.setCurrentIndex(index) + if self.menu: + self.menu.popup(event.globalPos()) + + def mousePressEvent(self, event): + """Override Qt method""" + if event.button() == Qt.MidButton: + index = self.get_offset_pos(event) + if index >= 0: + self.sig_close_tab.emit(index) + event.accept() + return + QTabWidget.mousePressEvent(self, event) + + def keyPressEvent(self, event): + """Override Qt method""" + ctrl = event.modifiers() & Qt.ControlModifier + key = event.key() + handled = False + if ctrl and self.count() > 0: + index = self.currentIndex() + if key == Qt.Key_PageUp: + if index > 0: + self.setCurrentIndex(index - 1) + else: + self.setCurrentIndex(self.count() - 1) + handled = True + elif key == Qt.Key_PageDown: + if index < self.count() - 1: + self.setCurrentIndex(index + 1) + else: + self.setCurrentIndex(0) + handled = True + if not handled: + QTabWidget.keyPressEvent(self, event) + + def tab_navigate(self, delta=1): + """Ctrl+Tab""" + if delta > 0 and self.currentIndex() == self.count()-1: + index = delta-1 + elif delta < 0 and self.currentIndex() == 0: + index = self.count()+delta + else: + index = self.currentIndex()+delta + self.setCurrentIndex(index) + + def set_close_function(self, func): + """Setting Tabs close function + None -> tabs are not closable""" + state = func is not None + if state: + self.sig_close_tab.connect(func) + try: + # Assuming Qt >= 4.5 + QTabWidget.setTabsClosable(self, state) + self.tabCloseRequested.connect(func) + except AttributeError: + # Workaround for Qt < 4.5 + close_button = create_toolbutton(self, triggered=func, + icon=ima.icon('fileclose'), + tip=_("Close current tab")) + self.setCornerWidget(close_button if state else None) + + +class Tabs(BaseTabs): + """BaseTabs widget with movable tabs and tab navigation shortcuts.""" + # Signals + move_data = Signal(int, int) + move_tab_finished = Signal() + sig_move_tab = Signal(str, str, int, int) + + def __init__(self, parent, actions=None, menu=None, + corner_widgets=None, menu_use_tooltips=False, + rename_tabs=False, split_char='', + split_index=0): + BaseTabs.__init__(self, parent, actions, menu, + corner_widgets, menu_use_tooltips) + tab_bar = TabBar(self, parent, + rename_tabs=rename_tabs, + split_char=split_char, + split_index=split_index) + tab_bar.sig_move_tab.connect(self.move_tab) + tab_bar.sig_move_tab[(str, int, int)].connect( + self.move_tab_from_another_tabwidget) + self.setTabBar(tab_bar) + + CONF.config_shortcut( + lambda: self.tab_navigate(1), + context='editor', + name='go to next file', + parent=parent) + + CONF.config_shortcut( + lambda: self.tab_navigate(-1), + context='editor', + name='go to previous file', + parent=parent) + + CONF.config_shortcut( + lambda: self.sig_close_tab.emit(self.currentIndex()), + context='editor', + name='close file 1', + parent=parent) + + CONF.config_shortcut( + lambda: self.sig_close_tab.emit(self.currentIndex()), + context='editor', + name='close file 2', + parent=parent) + + @Slot(int, int) + def move_tab(self, index_from, index_to): + """Move tab inside a tabwidget""" + self.move_data.emit(index_from, index_to) + + tip, text = self.tabToolTip(index_from), self.tabText(index_from) + icon, widget = self.tabIcon(index_from), self.widget(index_from) + current_widget = self.currentWidget() + + self.removeTab(index_from) + self.insertTab(index_to, widget, icon, text) + self.setTabToolTip(index_to, tip) + + self.setCurrentWidget(current_widget) + self.move_tab_finished.emit() + + @Slot(str, int, int) + def move_tab_from_another_tabwidget(self, tabwidget_from, + index_from, index_to): + """Move tab from a tabwidget to another""" + + # We pass self object IDs as QString objs, because otherwise it would + # depend on the platform: long for 64bit, int for 32bit. Replacing + # by long all the time is not working on some 32bit platforms. + # See spyder-ide/spyder#1094 and spyder-ide/spyder#1098. + self.sig_move_tab.emit(tabwidget_from, to_text_string(id(self)), + index_from, index_to) From e7ab3365540164ef5f3d9ea243e67cc727e4a88b Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Wed, 27 Jul 2022 07:13:17 +0200 Subject: [PATCH 82/83] add .git-blame-ignore-revs --- .git-blame-ignore-revs | 2 ++ setup.cfg | 1 + 2 files changed, 3 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..09026fb0702 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Removed CRLF from the entire codebase +bdfe0b59821951584de3f72768693a151ca8350a diff --git a/setup.cfg b/setup.cfg index 3f8f97243dd..026bb4ef2be 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,7 @@ ignore = .codecov.yml .codecov.yml .coveragerc + .git-blame-ignore-revs .pep8speaks.yml Announcements.md CHANGELOG.md From 66eca23b628b00f07ef4e093499583f1449d22e5 Mon Sep 17 00:00:00 2001 From: Quentin Peter Date: Fri, 29 Jul 2022 00:02:29 +0200 Subject: [PATCH 83/83] add matplotlib status --- spyder/api/shellconnect/mixins.py | 7 ++ spyder/plugins/ipythonconsole/plugin.py | 19 +++- .../ipythonconsole/widgets/__init__.py | 1 + .../ipythonconsole/widgets/main_widget.py | 6 +- .../plugins/ipythonconsole/widgets/status.py | 99 +++++++++++++++++++ 5 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 spyder/plugins/ipythonconsole/widgets/status.py diff --git a/spyder/api/shellconnect/mixins.py b/spyder/api/shellconnect/mixins.py index d7e221663ed..69f068a61ce 100644 --- a/spyder/api/shellconnect/mixins.py +++ b/spyder/api/shellconnect/mixins.py @@ -28,7 +28,10 @@ class ShellConnectMixin: def on_ipython_console_available(self): """Connect to the IPython console.""" ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.register_ipythonconsole(ipyconsole) + def register_ipythonconsole(self, ipyconsole): + """Register the console.""" ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) ipyconsole.sig_shellwidget_created.connect(self.add_shellwidget) ipyconsole.sig_shellwidget_deleted.connect(self.remove_shellwidget) @@ -39,6 +42,10 @@ def on_ipython_console_available(self): def on_ipython_console_teardown(self): """Disconnect from the IPython console.""" ipyconsole = self.get_plugin(Plugins.IPythonConsole) + self.unregister_ipythonconsole(ipyconsole) + + def unregister_ipythonconsole(self, ipyconsole): + """Unregister the console.""" ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) ipyconsole.sig_shellwidget_created.disconnect(self.add_shellwidget) diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index 17df1a897a5..6d469c03ccf 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -42,7 +42,8 @@ class IPythonConsole(SpyderDockablePlugin): NAME = 'ipython_console' REQUIRES = [Plugins.Console, Plugins.Preferences] OPTIONAL = [Plugins.Editor, Plugins.History, Plugins.MainMenu, - Plugins.Projects, Plugins.WorkingDirectory] + Plugins.Projects, Plugins.WorkingDirectory, + Plugins.StatusBar] TABIFY = [Plugins.History] WIDGET_CLASS = IPythonConsoleWidget CONF_SECTION = NAME @@ -252,6 +253,22 @@ def on_initialize(self): self.sig_focus_changed.connect(self.main.plugin_focus_changed) self._remove_old_std_files() + @on_plugin_available(plugin=Plugins.StatusBar) + def on_statusbar_available(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + matplotlib_status = self.get_widget().matplotlib_status + statusbar.add_status_widget(matplotlib_status) + matplotlib_status.register_ipythonconsole(self) + + @on_plugin_teardown(plugin=Plugins.StatusBar) + def on_statusbar_teardown(self): + # Add status widget + statusbar = self.get_plugin(Plugins.StatusBar) + matplotlib_status = self.get_widget().matplotlib_status + matplotlib_status.unregister_ipythonconsole(self) + statusbar.remove_status_widget(matplotlib_status.ID) + @on_plugin_available(plugin=Plugins.Preferences) def on_preferences_available(self): # Register conf page diff --git a/spyder/plugins/ipythonconsole/widgets/__init__.py b/spyder/plugins/ipythonconsole/widgets/__init__.py index f6bc62a94fe..8742f373d3c 100644 --- a/spyder/plugins/ipythonconsole/widgets/__init__.py +++ b/spyder/plugins/ipythonconsole/widgets/__init__.py @@ -18,6 +18,7 @@ from .figurebrowser import FigureBrowserWidget from .kernelconnect import KernelConnectionDialog from .restartdialog import ConsoleRestartDialog +from .status import MatplotlibStatus # ShellWidget contains the other widgets and ClientWidget # contains it diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index 75ee9734314..2a760d8e571 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -40,7 +40,8 @@ from spyder.plugins.ipythonconsole.utils.style import create_qss_style from spyder.plugins.ipythonconsole.widgets import ( ClientWidget, ConsoleRestartDialog, COMPLETION_WIDGET_TYPE, - KernelConnectionDialog, PageControlWidget, ShellWidget) + KernelConnectionDialog, PageControlWidget, ShellWidget, + MatplotlibStatus) from spyder.py3compat import PY38_OR_MORE from spyder.utils import encoding, programs, sourcecode from spyder.utils.misc import get_error_match, remove_backslashes @@ -393,6 +394,9 @@ def __init__(self, name=None, plugin=None, parent=None): # See spyder-ide/spyder#11880 self._init_asyncio_patch() + # Create MatplotlibStatus + self.matplotlib_status = MatplotlibStatus(self) + # To cache kernel properties self._cached_kernel_properties = None diff --git a/spyder/plugins/ipythonconsole/widgets/status.py b/spyder/plugins/ipythonconsole/widgets/status.py new file mode 100644 index 00000000000..be8180bf990 --- /dev/null +++ b/spyder/plugins/ipythonconsole/widgets/status.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Status bar widgets.""" +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.api.widgets.status import StatusBarWidget +from spyder_kernels.utils.mpl import MPL_BACKENDS_FROM_SPYDER +from spyder.api.shellconnect.mixins import ShellConnectMixin + + +class MatplotlibStatus(StatusBarWidget, ShellConnectMixin): + """Status bar widget for current matplotlib mode.""" + + ID = "matplotlib_status" + + def __init__(self, parent): + super(MatplotlibStatus, self).__init__( + parent) + self._gui = None + self._shellwidget_dict = {} + self._current_id = None + # Signals + self.sig_clicked.connect(self.toggle_matplotlib) + + def get_tooltip(self): + """Return localized tool tip for widget.""" + return _("Matplotlib interactive.") + + def toggle_matplotlib(self): + """Toggle matplotlib ineractive.""" + if self._current_id is None: + return + backend = "inline" if self._gui != "inline" else "auto" + sw = self._shellwidget_dict[self._current_id]["widget"] + sw.execute("%matplotlib " + backend) + is_spyder_kernel = self._shellwidget_dict[self._current_id][ + "spyder_kernel"] + if not is_spyder_kernel: + self.update_matplotlib_gui(backend) + + def update_matplotlib_gui(self, gui, shellwidget_id=None): + """Update matplotlib interactive.""" + if shellwidget_id is None: + shellwidget_id = self._current_id + if shellwidget_id is None: + return + if shellwidget_id in self._shellwidget_dict: + self._shellwidget_dict[shellwidget_id]["gui"] = gui + if shellwidget_id == self._current_id: + self.update(gui) + + def update(self, gui): + """Update interactive state.""" + self._gui = gui + self.set_value(_("Matplotlib: {}").format(gui)) + + def add_shellwidget(self, shellwidget): + """Add shellwidget.""" + shellwidget.spyder_kernel_comm.register_call_handler( + "update_matplotlib_gui", + lambda gui, sid=id(shellwidget): + self.update_matplotlib_gui(gui, sid)) + backend = MPL_BACKENDS_FROM_SPYDER[ + str(CONF.get('ipython_console', 'pylab/backend'))] + swid = id(shellwidget) + self._shellwidget_dict[swid] = { + "gui": backend, + "widget": shellwidget, + "spyder_kernel": shellwidget.is_spyder_kernel + } + self.set_shellwidget(shellwidget) + + def on_connection_to_external_spyder_kernel(self, shellwidget): + """Shellwidget is spyder_kernels.""" + shellwidget_id = id(shellwidget) + if shellwidget_id in self._shellwidget_dict: + self._shellwidget_dict[shellwidget_id][ + "spyder_kernel"] = True + + def set_shellwidget(self, shellwidget): + """Set current shellwidget.""" + self._current_id = None + shellwidget_id = id(shellwidget) + if shellwidget_id in self._shellwidget_dict: + self.update(self._shellwidget_dict[shellwidget_id]["gui"]) + self._current_id = shellwidget_id + + def remove_shellwidget(self, shellwidget): + """Remove shellwidget.""" + shellwidget.spyder_kernel_comm.register_call_handler( + "update_matplotlib_gui", None) + shellwidget_id = id(shellwidget) + if shellwidget_id in self._shellwidget_dict: + del self._shellwidget_dict[shellwidget_id]