From ab8537483da6c87cb9a0b0f01706208c964f292d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 26 Apr 2024 17:57:54 +0200 Subject: [PATCH] fix(widgets/editor): qscintilla editor removed --- bec_widgets/widgets/__init__.py | 1 - bec_widgets/widgets/editor/__init__.py | 1 - bec_widgets/widgets/editor/editor.py | 407 ------------------------- setup.py | 3 - tests/unit_tests/test_editor.py | 170 ----------- 5 files changed, 582 deletions(-) delete mode 100644 bec_widgets/widgets/editor/__init__.py delete mode 100644 bec_widgets/widgets/editor/editor.py delete mode 100644 tests/unit_tests/test_editor.py diff --git a/bec_widgets/widgets/__init__.py b/bec_widgets/widgets/__init__.py index e66c1f8c..3d0e94eb 100644 --- a/bec_widgets/widgets/__init__.py +++ b/bec_widgets/widgets/__init__.py @@ -1,4 +1,3 @@ -from .editor import BECEditor from .figure import BECFigure, FigureConfig from .monitor import BECMonitor from .motor_control import ( diff --git a/bec_widgets/widgets/editor/__init__.py b/bec_widgets/widgets/editor/__init__.py deleted file mode 100644 index e3b91ff2..00000000 --- a/bec_widgets/widgets/editor/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .editor import BECEditor diff --git a/bec_widgets/widgets/editor/editor.py b/bec_widgets/widgets/editor/editor.py deleted file mode 100644 index bda1ee1d..00000000 --- a/bec_widgets/widgets/editor/editor.py +++ /dev/null @@ -1,407 +0,0 @@ -import subprocess - -import qdarktheme -from jedi import Script -from jedi.api import Completion -from qtconsole.manager import QtKernelManager -from qtconsole.rich_jupyter_widget import RichJupyterWidget - -# pylint: disable=no-name-in-module -from qtpy.Qsci import QsciAPIs, QsciLexerPython, QsciScintilla -from qtpy.QtCore import Qt, QThread, Signal -from qtpy.QtGui import QColor, QFont -from qtpy.QtWidgets import QApplication, QFileDialog, QSplitter, QTextEdit, QVBoxLayout, QWidget - -from bec_widgets.widgets.toolbar import ModularToolBar - - -class AutoCompleter(QThread): - """Initializes the AutoCompleter thread for handling autocompletion and signature help. - - Args: - file_path (str): The path to the file for which autocompletion is required. - api (QsciAPIs): The QScintilla API instance used for managing autocompletions. - enable_docstring (bool, optional): Flag to determine if docstrings should be included in the signatures. - """ - - def __init__(self, file_path: str, api: QsciAPIs, enable_docstring: bool = False): - super().__init__(None) - self.file_path = file_path - self.script: Script = None - self.api: QsciAPIs = api - self.completions: list[Completion] = None - self.line = 0 - self.index = 0 - self.text = "" - - # TODO so far disabled, quite buggy, docstring extraction has to be generalised - self.enable_docstring = enable_docstring - - def update_script(self, text: str): - """Updates the script for Jedi completion based on the current editor text. - - Args: - text (str): The current text of the editor. - """ - if self.script is None or self.script.path != text: - self.script = Script(text, path=self.file_path) - - def run(self): - """Runs the thread for generating autocompletions. Overrides QThread.run.""" - self.update_script(self.text) - try: - self.completions = self.script.complete(self.line, self.index) - self.load_autocomplete(self.completions) - except Exception as err: - print(err) - self.finished.emit() - - def get_function_signature(self, line: int, index: int, text: str) -> str: - """Fetches the function signature for a given position in the text. - - Args: - line (int): The line number in the editor. - index (int): The index (column number) in the line. - text (str): The current text of the editor. - - Returns: - str: A string containing the function signature or an empty string if not available. - """ - self.update_script(text) - try: - signatures = self.script.get_signatures(line, index) - if signatures and self.enable_docstring is True: - full_docstring = signatures[0].docstring(raw=True) - compact_docstring = self.get_compact_docstring(full_docstring) - return compact_docstring - if signatures and self.enable_docstring is False: - return signatures[0].to_string() - except Exception as err: - print(f"Signature Error:{err}") - return "" - - def load_autocomplete(self, completions: list): - """Loads the autocomplete suggestions into the QScintilla API. - - Args: - completions (list[Completion]): A list of Completion objects to be added to the API. - """ - self.api.clear() - for i in completions: - self.api.add(i.name) - self.api.prepare() - - def get_completions(self, line: int, index: int, text: str): - """Starts the autocompletion process for a given position in the text. - - Args: - line (int): The line number in the editor. - index (int): The index (column number) in the line. - text (str): The current text of the editor. - """ - self.line = line - self.index = index - self.text = text - self.start() - - def get_compact_docstring(self, full_docstring): - """Generates a compact version of a function's docstring. - - Args: - full_docstring (str): The full docstring of a function. - - Returns: - str: A compact version of the docstring. - """ - lines = full_docstring.split("\n") - # TODO make it also for different docstring styles, now it is only for numpy style - cutoff_indices = [ - i - for i, line in enumerate(lines) - if line.strip().lower() in ["parameters", "returns", "examples", "see also", "warnings"] - ] - - if cutoff_indices: - lines = lines[: cutoff_indices[0]] - - compact_docstring = "\n".join(lines).strip() - return compact_docstring - - -class ScriptRunnerThread(QThread): - """Initializes the thread for running a Python script. - - Args: - script (str): The script to be executed. - """ - - outputSignal = Signal(str) - - def __init__(self, script): - super().__init__() - self.script = script - - def run(self): - """Executes the script in a subprocess and emits output through a signal. Overrides QThread.run.""" - process = subprocess.Popen( - ["python", "-u", "-c", self.script], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=1, - universal_newlines=True, - text=True, - ) - - while True: - output = process.stdout.readline() - if output == "" and process.poll() is not None: - break - if output: - self.outputSignal.emit(output) - error = process.communicate()[1] - if error: - self.outputSignal.emit(error) - - -class BECEditor(QWidget): - """Initializes the BEC Editor widget. - - Args: - toolbar_enabled (bool, optional): Determines if the toolbar should be enabled. Defaults to True. - """ - - def __init__( - self, toolbar_enabled=True, jupyter_terminal_enabled=False, docstring_tooltip=False - ): - super().__init__() - - self.script_runner_thread = None - self.file_path = None - self.docstring_tooltip = docstring_tooltip - self.jupyter_terminal_enabled = jupyter_terminal_enabled - # TODO just temporary solution, could be extended to other languages - self.is_python_file = True - - # Initialize the editor and terminal - self.editor = QsciScintilla() - if self.jupyter_terminal_enabled: - self.terminal = self.make_jupyter_widget_with_kernel() - else: - self.terminal = QTextEdit() - self.terminal.setReadOnly(True) - - # Layout - self.layout = QVBoxLayout() - - # Initialize and add the toolbar if enabled - if toolbar_enabled: - self.toolbar = ModularToolBar(self) - self.layout.addWidget(self.toolbar) - - # Initialize the splitter - self.splitter = QSplitter(Qt.Orientation.Vertical, self) - self.splitter.addWidget(self.editor) - self.splitter.addWidget(self.terminal) - self.splitter.setSizes([400, 200]) - - # Add Splitter to layout - self.layout.addWidget(self.splitter) - self.setLayout(self.layout) - - self.setup_editor() - - def setup_editor(self): - """Sets up the editor with necessary configurations like lexer, auto indentation, and line numbers.""" - # Set the lexer for Python - self.lexer = QsciLexerPython() - self.editor.setLexer(self.lexer) - - # Enable auto indentation and competition within the editor - self.editor.setAutoIndent(True) - self.editor.setIndentationsUseTabs(False) - self.editor.setIndentationWidth(4) - self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAll) - self.editor.setAutoCompletionThreshold(1) - - # Autocomplete for python file - # Connect cursor position change signal for autocompletion - self.editor.cursorPositionChanged.connect(self.on_cursor_position_changed) - - # if self.is_python_file: #TODO can be changed depending on supported languages - self.__api = QsciAPIs(self.lexer) - self.auto_completer = AutoCompleter( - self.editor.text(), self.__api, enable_docstring=self.docstring_tooltip - ) - self.auto_completer.finished.connect(self.loaded_autocomplete) - - # Enable line numbers in the margin - self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin) - self.editor.setMarginWidth(0, "0000") # Adjust the width as needed - - # Additional UI elements like menu for load/save can be added here - self.set_editor_style() - - @staticmethod - def make_jupyter_widget_with_kernel() -> object: - """Start a kernel, connect to it, and create a RichJupyterWidget to use it""" - kernel_manager = QtKernelManager(kernel_name="python3") - kernel_manager.start_kernel() - - kernel_client = kernel_manager.client() - kernel_client.start_channels() - - jupyter_widget = RichJupyterWidget() - jupyter_widget.set_default_style("linux") - jupyter_widget.kernel_manager = kernel_manager - jupyter_widget.kernel_client = kernel_client - return jupyter_widget - - def show_call_tip(self, position): - """Shows a call tip at the given position in the editor. - - Args: - position (int): The position in the editor where the call tip should be shown. - """ - line, index = self.editor.lineIndexFromPosition(position) - signature = self.auto_completer.get_function_signature(line + 1, index, self.editor.text()) - if signature: - self.editor.showUserList(1, [signature]) - - def on_cursor_position_changed(self, line, index): - """Handles the event of cursor position change in the editor. - - Args: - line (int): The current line number where the cursor is. - index (int): The current column index where the cursor is. - """ - # if self.is_python_file: #TODO can be changed depending on supported languages - # Get completions - self.auto_completer.get_completions(line + 1, index, self.editor.text()) - self.editor.autoCompleteFromAPIs() - - # Show call tip - signature - position = self.editor.positionFromLineIndex(line, index) - self.show_call_tip(position) - - def loaded_autocomplete(self): - """Placeholder method for actions after autocompletion data is loaded.""" - - def set_editor_style(self): - """Sets the style and color scheme for the editor.""" - # Dracula Theme Colors - background_color = QColor("#282a36") - text_color = QColor("#f8f8f2") - keyword_color = QColor("#8be9fd") - string_color = QColor("#f1fa8c") - comment_color = QColor("#6272a4") - class_function_color = QColor("#50fa7b") - - # Set Font - font = QFont() - font.setFamily("Consolas") - font.setPointSize(10) - self.editor.setFont(font) - self.editor.setMarginsFont(font) - - # Set Editor Colors - self.editor.setMarginsBackgroundColor(background_color) - self.editor.setMarginsForegroundColor(text_color) - self.editor.setCaretForegroundColor(text_color) - self.editor.setCaretLineBackgroundColor(QColor("#44475a")) - self.editor.setPaper(background_color) # Set the background color for the entire paper - self.editor.setColor(text_color) - - # Set editor - # Syntax Highlighting Colors - lexer = self.editor.lexer() - if lexer: - lexer.setDefaultPaper(background_color) # Set the background color for the text area - lexer.setDefaultColor(text_color) - lexer.setColor(keyword_color, QsciLexerPython.Keyword) - lexer.setColor(string_color, QsciLexerPython.DoubleQuotedString) - lexer.setColor(string_color, QsciLexerPython.SingleQuotedString) - lexer.setColor(comment_color, QsciLexerPython.Comment) - lexer.setColor(class_function_color, QsciLexerPython.ClassName) - lexer.setColor(class_function_color, QsciLexerPython.FunctionMethodName) - - # Set the style for all text to have a transparent background - # TODO find better way how to do it! - for style in range( - 128 - ): # QsciScintilla supports 128 styles by default, this set all to transparent background - self.lexer.setPaper(background_color, style) - - def run_script(self): - """Runs the current script in the editor.""" - if self.jupyter_terminal_enabled: - script = self.editor.text() - self.terminal.execute(script) - - else: - script = self.editor.text() - self.script_runner_thread = ScriptRunnerThread(script) - self.script_runner_thread.outputSignal.connect(self.update_terminal) - self.script_runner_thread.start() - - def update_terminal(self, text): - """Updates the terminal with new text. - - Args: - text (str): The text to be appended to the terminal. - """ - self.terminal.append(text) - - def enable_docstring_tooltip(self): - """Enables the docstring tooltip.""" - self.docstring_tooltip = True - self.auto_completer.enable_docstring = True - - def open_file(self): - """Opens a file dialog for selecting and opening a Python file in the editor.""" - options = QFileDialog.Options() - options |= QFileDialog.DontUseNativeDialog - file_path, _ = QFileDialog.getOpenFileName( - self, "Open file", "", "Python files (*.py);;All Files (*)", options=options - ) - - if not file_path: - return - try: - with open(file_path, "r") as file: - text = file.read() - self.editor.setText(text) - except FileNotFoundError: - print(f"The file {file_path} was not found.") - except Exception as e: - print(f"An error occurred while opening the file {file_path}: {e}") - - def save_file(self): - """Opens a save file dialog for saving the current script in the editor.""" - options = QFileDialog.Options() - options |= QFileDialog.DontUseNativeDialog - file_path, _ = QFileDialog.getSaveFileName( - self, "Save file", "", "Python files (*.py);;All Files (*)", options=options - ) - - if not file_path: - return - try: - if not file_path.endswith(".py"): - file_path += ".py" - - with open(file_path, "w") as file: - text = self.editor.text() - file.write(text) - print(f"File saved to {file_path}") - except Exception as e: - print(f"An error occurred while saving the file to {file_path}: {e}") - - -if __name__ == "__main__": # pragma: no cover - app = QApplication([]) - qdarktheme.setup_theme("auto") - - mainWin = BECEditor(jupyter_terminal_enabled=True) - - mainWin.show() - app.exec() diff --git a/setup.py b/setup.py index 89082a57..16e4105d 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,6 @@ __version__ = "0.49.0" # Default to PyQt6 if no other Qt binding is installed QT_DEPENDENCY = "PyQt6<=6.6.3" -QSCINTILLA_DEPENDENCY = "PyQt6-QScintilla" # pylint: disable=unused-import try: @@ -14,7 +13,6 @@ except ImportError: pass else: QT_DEPENDENCY = "PyQt5>=5.9" - QSCINTILLA_DEPENDENCY = "QScintilla" if __name__ == "__main__": setup( @@ -23,7 +21,6 @@ if __name__ == "__main__": "qtconsole", "PyQt6-Qt6<=6.6.3", QT_DEPENDENCY, - QSCINTILLA_DEPENDENCY, "jedi", "qtpy", "pyqtgraph", diff --git a/tests/unit_tests/test_editor.py b/tests/unit_tests/test_editor.py deleted file mode 100644 index 99611d24..00000000 --- a/tests/unit_tests/test_editor.py +++ /dev/null @@ -1,170 +0,0 @@ -# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring - -import os -import tempfile -from unittest.mock import MagicMock, mock_open, patch - -import pytest -from qtpy.Qsci import QsciScintilla -from qtpy.QtWidgets import QTextEdit - -from bec_widgets.widgets.editor.editor import AutoCompleter, BECEditor - - -@pytest.fixture(scope="function") -def editor(qtbot, docstring_tooltip=False): - """Helper function to set up the BECEditor widget.""" - widget = BECEditor(toolbar_enabled=True, docstring_tooltip=docstring_tooltip) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -def find_action_by_text(toolbar, text): - """Helper function to find an action in the toolbar by its text.""" - for action in toolbar.actions(): - if action.text() == text: - return action - return None - - -def test_bec_editor_initialization(editor): - """Test if the BECEditor widget is initialized correctly.""" - assert isinstance(editor.editor, QsciScintilla) - assert isinstance(editor.terminal, QTextEdit) - assert isinstance(editor.auto_completer, AutoCompleter) - - -@patch("bec_widgets.widgets.editor.editor.Script") # Mock the Script class from jedi -def test_autocompleter_suggestions(mock_script, editor, qtbot): - """Test if the autocompleter provides correct suggestions based on input.""" - # Set up mock return values for the Script.complete method - mock_completion = MagicMock() - mock_completion.name = "mocked_method" - mock_script.return_value.complete.return_value = [mock_completion] - - # Simulate user input in the editor - test_code = "print(" - editor.editor.setText(test_code) - line, index = editor.editor.getCursorPosition() - - # Trigger autocomplete - editor.auto_completer.get_completions(line, index, test_code) - - # Use qtbot to wait for the completion thread - qtbot.waitUntil(lambda: editor.auto_completer.completions is not None, timeout=1000) - - # Check if the expected completion is in the autocompleter's suggestions - suggested_methods = [completion.name for completion in editor.auto_completer.completions] - assert "mocked_method" in suggested_methods - - -@patch("bec_widgets.widgets.editor.editor.Script") # Mock the Script class from jedi -@pytest.mark.parametrize( - "docstring_enabled, expected_signature", - [(True, "Mocked signature with docstring"), (False, "Mocked signature")], -) -def test_autocompleter_signature(mock_script, editor, docstring_enabled, expected_signature): - """Test if the autocompleter provides correct function signature based on docstring setting.""" - # Set docstring mode based on parameter - editor.docstring_tooltip = docstring_enabled - editor.auto_completer.enable_docstring = docstring_enabled - - # Set up mock return values for the Script.get_signatures method - mock_signature = MagicMock() - if docstring_enabled: - mock_signature.docstring.return_value = expected_signature - else: - mock_signature.to_string.return_value = expected_signature - mock_script.return_value.get_signatures.return_value = [mock_signature] - - # Simulate user input that would trigger a signature request - test_code = "print(" - editor.editor.setText(test_code) - line, index = editor.editor.getCursorPosition() - - # Trigger signature request - signature = editor.auto_completer.get_function_signature(line, index, test_code) - - # Check if the expected signature is returned - assert signature == expected_signature - - -def test_open_file(editor): - """Test open_file method of BECEditor.""" - # Create a temporary file with some content - with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as temp_file: - temp_file.write(b"test file content") - - # Mock user selecting the file in the dialog - with patch("qtpy.QtWidgets.QFileDialog.getOpenFileName", return_value=(temp_file.name, "")): - with patch("builtins.open", new_callable=mock_open, read_data="test file content"): - editor.open_file() - - # Verify if the editor's text is set to the file content - assert editor.editor.text() == "test file content" - - # Clean up by removing the temporary file - os.remove(temp_file.name) - - -def test_save_file(editor): - """Test save_file method of BECEditor.""" - # Set some text in the editor - editor.editor.setText("test save content") - - # Mock user selecting the file in the dialog - with patch( - "qtpy.QtWidgets.QFileDialog.getSaveFileName", return_value=("/path/to/save/file.py", "") - ): - with patch("builtins.open", new_callable=mock_open) as mock_file: - editor.save_file() - - # Verify if the file was opened correctly for writing - mock_file.assert_called_with("/path/to/save/file.py", "w") - - # Verify if the editor's text was written to the file - mock_file().write.assert_called_with("test save content") - - -def test_open_file_through_toolbar(editor): - """Test the open_file method through the ModularToolBar.""" - # Create a temporary file - with tempfile.NamedTemporaryFile(delete=False, suffix=".py") as temp_file: - temp_file.write(b"test file content") - - # Find the open file action in the toolbar - open_action = find_action_by_text(editor.toolbar, "Open File") - assert open_action is not None, "Open File action should be found" - - # Mock the file dialog and built-in open function - with patch("qtpy.QtWidgets.QFileDialog.getOpenFileName", return_value=(temp_file.name, "")): - with patch("builtins.open", new_callable=mock_open, read_data="test file content"): - open_action.trigger() - # Verify if the editor's text is set to the file content - assert editor.editor.text() == "test file content" - - # Clean up - os.remove(temp_file.name) - - -def test_save_file_through_toolbar(editor): - """Test the save_file method through the ModularToolBar.""" - # Set some text in the editor - editor.editor.setText("test save content") - - # Find the save file action in the toolbar - save_action = find_action_by_text(editor.toolbar, "Save File") - assert save_action is not None, "Save File action should be found" - - # Mock the file dialog and built-in open function - with patch( - "qtpy.QtWidgets.QFileDialog.getSaveFileName", return_value=("/path/to/save/file.py", "") - ): - with patch("builtins.open", new_callable=mock_open) as mock_file: - save_action.trigger() - # Verify if the file was opened correctly for writing - mock_file.assert_called_with("/path/to/save/file.py", "w") - - # Verify if the editor's text was written to the file - mock_file().write.assert_called_with("test save content")