diff --git a/docs/source/release/v4.2.0/mantidworkbench.rst b/docs/source/release/v4.2.0/mantidworkbench.rst index 805c4a3d6c48f29b7aed07e710473c0a459548ab..4469da4c126872d07545cb1bd89837d5b190fe64 100644 --- a/docs/source/release/v4.2.0/mantidworkbench.rst +++ b/docs/source/release/v4.2.0/mantidworkbench.rst @@ -37,6 +37,7 @@ Improvements - It is now possible to fit table workspaces in the fit browser and in a script. - It is now possible to input axis limits in the figure options using scientific notation. - The sub-tabs in the Curves tab in plot options now contain an "Apply to All" button which copies the properties of the current curve to all other curves in the plot. +- The auto-complete in Workbench's script editor has been improved. Bugfixes ######## diff --git a/qt/python/CMakeLists.txt b/qt/python/CMakeLists.txt index 8aabf85e81974b1f882a997ad0fc40b20edcc4cb..51db6aea2d29e2a4c6e9de519fbd1ded76ed05a4 100644 --- a/qt/python/CMakeLists.txt +++ b/qt/python/CMakeLists.txt @@ -92,6 +92,7 @@ if(ENABLE_WORKBENCH OR ENABLE_WORKBENCH) mantidqt/utils/test/test_qt_utils.py mantidqt/utils/test/test_writetosignal.py mantidqt/widgets/codeeditor/test/test_codeeditor.py + mantidqt/widgets/codeeditor/test/test_completion.py mantidqt/widgets/codeeditor/test/test_errorformatter.py mantidqt/widgets/codeeditor/test/test_execution.py mantidqt/widgets/codeeditor/test/test_interpreter.py diff --git a/qt/python/mantidqt/widgets/codeeditor/completion.py b/qt/python/mantidqt/widgets/codeeditor/completion.py new file mode 100644 index 0000000000000000000000000000000000000000..85f4041f72198f7571bb3039c59d1471141ab57a --- /dev/null +++ b/qt/python/mantidqt/widgets/codeeditor/completion.py @@ -0,0 +1,252 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2019 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +""" +There was some time spent looking into using jedi for the completions. +jedi performs static analysis on code, which aims to provide completions +from modules and objects without needing to run any code. Because of +this, jedi needed to be updated almost every time the cursor position +was changed. This reduced the script editors performance especially when +analyzing large modules like numpy (not to an unusable level, but it was +noticeable). + +Further, jedi could not statically pick-up many of the simpleapi +algorithms, which are defined dynamically, so we had to provide these +ourselves anyway. jedi also caused workbench to crash when launching +through PyCharm's debugger using Python 2 (it ran fine when launched +normally - I think the cause was something to do with the debugging +process interfering with jedi's subprocess). + +For these reasons it was agreed jedi would be dropped, possibly +revisiting when we move to Python 3. +""" + +from __future__ import (absolute_import, unicode_literals) + +import ast +import inspect +import re +import sys +from keyword import kwlist as python_keywords +from collections import namedtuple +from six import PY2 +if PY2: # noqa + from inspect import getargspec as getfullargspec +else: # noqa + from inspect import getfullargspec + +from mantidqt.widgets.codeeditor.editor import CodeEditor + +ArgSpec = namedtuple("ArgSpec", "args varargs keywords defaults") + + +def get_builtin_argspec(builtin): + """ + Get the call tips for a builtin function from its docstring + :param builtin builtin: The builtin to generate call tips for + :return inspect.ArgSpec: The ArgSpec of the builtin. If no function + description is available in the docstring return None + """ + doc_string = builtin.__doc__ + if not doc_string: + return None + func_descriptor = doc_string.split('\n')[0].strip() + if re.search(builtin.__name__ + "\([\[\*a-zA-Z_].*\)", func_descriptor): + args_string = func_descriptor[func_descriptor.find('(') + 1:func_descriptor.rfind(')')] + all_args_list = args_string.split(', ') + args = [] + defaults = [] + for arg in all_args_list: + if '=' in arg: + args.append(arg.split('=')[0].strip()) + defaults.append(arg.split('=')[1].strip()) + else: + args.append(arg) + arg_spec = ArgSpec(args, None, None, defaults) + return arg_spec + + +def get_function_spec(func): + """ + Get the python function signature for the given function object. First + the args are inspected followed by varargs, which are set by some modules, + e.g. mantid.simpleapi algorithm functions + + :param func: A Python function object + :returns: A string containing the function specification + """ + try: + argspec = getfullargspec(func) + except TypeError: + try: + args_obj = inspect.getargs(func.__code__) + argspec = ArgSpec(args_obj.args, args_obj.varargs, args_obj.varkw, defaults=None) + except (TypeError, AttributeError, ValueError): + if inspect.isbuiltin(func): + argspec = get_builtin_argspec(func) + if not argspec: + return '' + else: + return '' + # mantid algorithm functions have varargs set not args + args = argspec[0] + if args: + # For methods strip the self argument + if hasattr(func, 'im_func'): + args = args[1:] + defs = argspec[3] + elif argspec[1] is not None: + # Get from varargs/keywords + arg_str = argspec[1].strip().lstrip('\b').replace(',', ', ') + defs = [] + # Keyword args + kwargs = argspec[2] + if kwargs is not None: + kwargs = kwargs.strip().lstrip('\b\b') + if kwargs == 'kwargs': + kwargs = '**' + kwargs + '=None' + arg_str += ', %s' % kwargs + # Any default argument appears in the string + # on the rhs of an equal + for arg in arg_str.split(', '): + arg = arg.strip() + if '=' in arg: + arg_token = arg.split('=') + args.append(arg_token[0]) + defs.append(arg_token[1]) + else: + args.append(arg) + if len(defs) == 0: + defs = None + else: + return '' + + if defs is None: + call_tip = "({})".format(', '.join(args)) + else: + # The defaults list contains the default values for the last n arguments + diff = len(args) - len(defs) + call_tip = '' + for index in range(len(args) - 1, -1, -1): + def_index = index - diff + if def_index >= 0: + call_tip = '[' + args[index] + '], ' + call_tip + else: + call_tip = args[index] + ", " + call_tip + call_tip = '(' + call_tip.rstrip(', ') + ')' + return call_tip + + +def generate_call_tips(definitions, prepend_module_name=None): + """ + Generate call tips for a dictionary of object definitions (eg. globals()). + The call tips generated are of the form: + Load(InputWorkspace, [OutputWorkspace], ...) + where squared braces denote key-word arguments. + + :param dict definitions: Dictionary with names of python objects as keys and + the objects themselves as values + :param prepend_module_name: bool, None, or str. Prepend the name of the module to the call tips + default is None, if string given that string will act as the module name + :returns list: A list of call tips + """ + if not isinstance(definitions, dict): + return [] + call_tips = [] + for name, py_object in definitions.items(): + if name.startswith('_'): + continue + if prepend_module_name is True: + prepend_module_name = py_object.__module__ + if inspect.isfunction(py_object) or inspect.isbuiltin(py_object): + if not prepend_module_name: + call_tips.append(name + get_function_spec(py_object)) + else: + call_tips.append(prepend_module_name + '.' + name + get_function_spec(py_object)) + continue + # Ignore modules or we get duplicates of methods/classes that are imported + # in outer scopes, e.g. numpy.array and numpy.core.array + if inspect.ismodule(py_object): + continue + for attr in dir(py_object): + try: + f_attr = getattr(py_object, attr) + except Exception: + continue + if attr.startswith('_'): + continue + if hasattr(f_attr, 'im_func') or inspect.isfunction(f_attr) or inspect.ismethod(f_attr): + call_tip = name + '.' + attr + get_function_spec(f_attr) + else: + call_tip = name + '.' + attr + if prepend_module_name: + call_tips.append(prepend_module_name + '.' + call_tip) + return call_tips + + +def get_line_number_from_index(string, index): + return string[:index].count('\n') + + +def get_module_import_alias(import_name, text): + for node in ast.walk(ast.parse(text)): + if isinstance(node, ast.alias) and node.name == import_name: + return node.asname + return import_name + + +class CodeCompleter(object): + """ + This class generates autocompletions for Workbench's script editor. + It generates autocompletions from environment globals. These completions + are updated on every successful script execution. + """ + def __init__(self, editor, env_globals=None): + self.editor = editor + self.env_globals = env_globals + + # A dict gives O(1) lookups and ensures we have no duplicates + self._completions_dict = dict() + if "from mantid.simpleapi import *" in self.editor.text(): + self._add_to_completions(self._get_module_call_tips('mantid.simpleapi')) + if re.search("^#{0}import .*numpy( |,|$)", self.editor.text(), re.MULTILINE): + self._add_to_completions(self._get_module_call_tips('numpy')) + if re.search("^#{0}import .*pyplot( |,|$)", self.editor.text(), re.MULTILINE): + self._add_to_completions(self._get_module_call_tips('matplotlib.pyplot')) + self._add_to_completions(python_keywords) + + self.editor.enableAutoCompletion(CodeEditor.AcsAPIs) + self.editor.updateCompletionAPI(self.completions) + + @property + def completions(self): + return list(self._completions_dict.keys()) + + def _get_completions_from_globals(self): + return generate_call_tips(self.env_globals, prepend_module_name=True) + + def _add_to_completions(self, completions): + for completion in completions: + self._completions_dict[completion] = True + + def update_completion_api(self): + self._add_to_completions(self._get_completions_from_globals()) + self.editor.updateCompletionAPI(self.completions) + + def _get_module_call_tips(self, module): + """ + Get the call tips for a given module. If the module cannot be + found in sys.modules return an empty list + :param str module: The name of the module + :return list: A list of call tips for the module + """ + try: + module = sys.modules[module] + except KeyError: + return [] + module_name = get_module_import_alias(module.__name__, self.editor.text()) + return generate_call_tips(module.__dict__, module_name) diff --git a/qt/python/mantidqt/widgets/codeeditor/execution.py b/qt/python/mantidqt/widgets/codeeditor/execution.py index f8b8c733da11c70a46ff4b69f8e3986e57c95dd7..2b02b55a5c87b26100cf33e59d1c7559ba18a377 100644 --- a/qt/python/mantidqt/widgets/codeeditor/execution.py +++ b/qt/python/mantidqt/widgets/codeeditor/execution.py @@ -9,90 +9,20 @@ # from __future__ import absolute_import -import inspect import os from qtpy.QtCore import QObject, Signal from qtpy.QtWidgets import QApplication -from six import PY2, iteritems from mantidqt.utils import AddedToSysPath from mantidqt.utils.asynchronous import AsyncTask, BlockingAsyncTaskWithCallback from mantidqt.widgets.codeeditor.inputsplitter import InputSplitter -if PY2: - from inspect import getargspec as getfullargspec -else: - from inspect import getfullargspec - EMPTY_FILENAME_ID = '<string>' FILE_ATTR = '__file__' COMPILE_MODE = 'exec' -def get_function_spec(func): - """Get the python function signature for the given function object. First - the args are inspected followed by varargs, which are set by some modules, - e.g. mantid.simpleapi algorithm functions - - :param func: A Python function object - :returns: A string containing the function specification - : - """ - try: - argspec = getfullargspec(func) - except TypeError: - return '' - # mantid algorithm functions have varargs set not args - args = argspec[0] - if args: - # For methods strip the self argument - if hasattr(func, 'im_func'): - args = args[1:] - defs = argspec[3] - elif argspec[1] is not None: - # Get from varargs/keywords - arg_str = argspec[1].strip().lstrip('\b') - defs = [] - # Keyword args - kwargs = argspec[2] - if kwargs is not None: - kwargs = kwargs.strip().lstrip('\b\b') - if kwargs == 'kwargs': - kwargs = '**' + kwargs + '=None' - arg_str += ',%s' % kwargs - # Any default argument appears in the string - # on the rhs of an equal - for arg in arg_str.split(','): - arg = arg.strip() - if '=' in arg: - arg_token = arg.split('=') - args.append(arg_token[0]) - defs.append(arg_token[1]) - else: - args.append(arg) - if len(defs) == 0: - defs = None - else: - return '' - - if defs is None: - calltip = ','.join(args) - calltip = '(' + calltip + ')' - else: - # The defaults list contains the default values for the last n arguments - diff = len(args) - len(defs) - calltip = '' - for index in range(len(args) - 1, -1, -1): - def_index = index - diff - if def_index >= 0: - calltip = '[' + args[index] + '],' + calltip - else: - calltip = args[index] + "," + calltip - calltip = '(' + calltip.rstrip(',') + ')' - return calltip - - class PythonCodeExecution(QObject): """Provides the ability to execute arbitrary strings of Python code. It supports @@ -171,20 +101,6 @@ class PythonCodeExecution(QObject): dont_inherit=True) exec (code_obj, self.globals_ns, self.globals_ns) - def generate_calltips(self): - """ - Return a list of calltips for the current global scope. This is currently - very basic and only inspects the available functions and builtins at the current scope. - - :return: A list of strings giving calltips for each global callable - """ - calltips = [] - for name, attr in iteritems(self._globals_ns): - if inspect.isfunction(attr) or inspect.isbuiltin(attr): - calltips.append(name + get_function_spec(attr)) - - return calltips - def reset_context(self): # create new context for execution self._globals_ns, self._namespace = {}, {} diff --git a/qt/python/mantidqt/widgets/codeeditor/interpreter.py b/qt/python/mantidqt/widgets/codeeditor/interpreter.py index 9c6e32d87b03dd018bc720bcf40d2884387eea88..5ea63ad74c3ecbd584dfca2d87f52f6f01f3e8a8 100644 --- a/qt/python/mantidqt/widgets/codeeditor/interpreter.py +++ b/qt/python/mantidqt/widgets/codeeditor/interpreter.py @@ -17,6 +17,7 @@ from qtpy.QtWidgets import QFileDialog, QMessageBox, QStatusBar, QVBoxLayout, QW from mantidqt.io import open_a_file_dialog from mantidqt.widgets.codeeditor.codecommenter import CodeCommenter +from mantidqt.widgets.codeeditor.completion import CodeCompleter from mantidqt.widgets.codeeditor.editor import CodeEditor from mantidqt.widgets.codeeditor.errorformatter import ErrorFormatter from mantidqt.widgets.codeeditor.execution import PythonCodeExecution @@ -136,6 +137,7 @@ class PythonFileInterpreter(QWidget): self._presenter = PythonFileInterpreterPresenter(self, PythonCodeExecution(content)) self.code_commenter = CodeCommenter(self.editor) + self.code_completer = CodeCompleter(self.editor, self._presenter.model.globals_ns) self.editor.modificationChanged.connect(self.sig_editor_modified) self.editor.fileNameChanged.connect(self.sig_filename_modified) @@ -147,6 +149,9 @@ class PythonFileInterpreter(QWidget): self._presenter.model.sig_exec_error.connect(self.sig_exec_error) self._presenter.model.sig_exec_success.connect(self.sig_exec_success) + # Re-populate the completion API after execution success + self._presenter.model.sig_exec_success.connect(self.code_completer.update_completion_api) + def closeEvent(self, event): self.deleteLater() if self.find_replace_dialog: @@ -255,8 +260,6 @@ class PythonFileInterpreter(QWidget): # Default content does not count as a modification editor.setModified(False) - editor.enableAutoCompletion(CodeEditor.AcsAll) - def clear_key_binding(self, key_str): """Clear a keyboard shortcut bound to a Scintilla command""" self.editor.clearKeyBinding(key_str) @@ -276,9 +279,6 @@ class PythonFileInterpreterPresenter(QObject): self._is_executing = False self._error_formatter = ErrorFormatter() - # If startup code was executed then populate autocomplete - self.view.editor.updateCompletionAPI(self.model.generate_calltips()) - # connect signals self.model.sig_exec_success.connect(self._on_exec_success) self.model.sig_exec_error.connect(self._on_exec_error) @@ -329,7 +329,6 @@ class PythonFileInterpreterPresenter(QObject): return code_str, line_from def _on_exec_success(self, task_result): - self.view.editor.updateCompletionAPI(self.model.generate_calltips()) self._finish(success=True, task_result=task_result) def _on_exec_error(self, task_error): diff --git a/qt/python/mantidqt/widgets/codeeditor/test/test_completion.py b/qt/python/mantidqt/widgets/codeeditor/test/test_completion.py new file mode 100644 index 0000000000000000000000000000000000000000..0d02677ee05f8b9b711f4de2b366ac05ca0cde61 --- /dev/null +++ b/qt/python/mantidqt/widgets/codeeditor/test/test_completion.py @@ -0,0 +1,106 @@ +# Mantid Repository : https://github.com/mantidproject/mantid +# +# Copyright © 2019 ISIS Rutherford Appleton Laboratory UKRI, +# NScD Oak Ridge National Laboratory, European Spallation Source +# & Institut Laue - Langevin +# SPDX - License - Identifier: GPL - 3.0 + +# This file is part of the mantid workbench. +from __future__ import (absolute_import, unicode_literals) + +import re +import unittest + +import matplotlib.pyplot as plt # noqa +import numpy as np # noqa + +from mantid.simpleapi import Rebin # noqa # needed so sys.modules can pick up Rebin +from mantid.py3compat.mock import Mock +from mantidqt.widgets.codeeditor.completion import (CodeCompleter, get_function_spec, + get_builtin_argspec, get_module_import_alias) +from testhelpers import assertRaisesNothing + + +class CodeCompletionTest(unittest.TestCase): + + def _get_completer(self, text, env_globals=None): + return CodeCompleter(Mock(text=lambda: text, fileName=lambda: ""), env_globals) + + def _run_check_call_tip_generated(self, script_text, call_tip_regex): + completer = self._get_completer(script_text) + update_completion_api_mock = completer.editor.updateCompletionAPI + call_tips = update_completion_api_mock.call_args_list[0][0][0] + self.assertEqual(1, update_completion_api_mock.call_count) + self.assertGreater(len(call_tips), 1) + self.assertTrue(re.search(call_tip_regex, ' '.join(call_tips))) + + def _run_check_call_tip_not_generated(self, script_text, call_tip_regex): + completer = self._get_completer(script_text) + update_completion_api_mock = completer.editor.updateCompletionAPI + call_tips = update_completion_api_mock.call_args_list[0][0][0] + self.assertEqual(1, update_completion_api_mock.call_count) + self.assertFalse(bool(re.search(call_tip_regex, ' '.join(call_tips)))) + + def test_Rebin_call_tips_generated_on_construction_when_api_import_in_script(self): + self._run_check_call_tip_generated("from mantid.simpleapi import *\n# My code", + "Rebin\(InputWorkspace, .*\)") + + def test_numpy_call_tips_generated_if_numpy_imported_in_script(self): + self._run_check_call_tip_generated("import numpy as np\n# My code", + "np\.asarray\(a, \[dtype\], .*\)") + + def test_pyplot_call_tips_generated_if_imported_in_script(self): + self._run_check_call_tip_generated("import matplotlib.pyplot as plt\n# My code", + "plt\.figure\(\[num\], .*\)") + + def test_simple_api_call_tips_not_generated_on_construction_if_api_import_not_in_script(self): + self._run_check_call_tip_not_generated("import numpy as np\n# My code", "Rebin") + + def test_numpy_call_tips_not_generated_if_its_not_imported(self): + self._run_check_call_tip_not_generated("# My code", "numpy") + + def test_pyplot_call_tips_not_generated_if_its_not_imported(self): + self._run_check_call_tip_not_generated("# My code", "pyplot") + + def test_nothing_raised_when_getting_completions_from_a_not_imported_module(self): + completer = self._get_completer("# My code") + assertRaisesNothing(self, completer._get_module_call_tips, 'this.doesnt.exist') + + def test_get_function_spec_returns_expected_string_for_explicit_args(self): + def my_new_function(arg1, arg2, kwarg1=None, kwarg2=0): + pass + + self.assertEqual("(arg1, arg2, [kwarg1], [kwarg2])", get_function_spec(my_new_function)) + + def test_get_function_spec_returns_expected_string_for_implicit_args(self): + def my_new_function(*args, **kwargs): + pass + + self.assertEqual("(args, [**kwargs])", get_function_spec(my_new_function)) + + def test_get_builtin_argspec_generates_argspec_for_numpy_builtin(self): + argspec = get_builtin_argspec(np.zeros) + self.assertIn("shape, dtype, order", ', '.join(argspec.args)) + self.assertIn("float, 'C'", ', '.join(argspec.defaults)) + + def test_get_module_import_alias_finds_import_aliases(self): + script = ("import numpy as np\n" + "from keyword import kwlist as key_word_list\n" + "import matplotlib.pyplot as plt\n" + "import mymodule.some_func as func, something as _smthing\n" + "# import commented.module as not_imported\n" + "import thing as _thing # import kwlist2 as kew_word_list2") + aliases = { + 'numpy': 'np', + 'kwlist': 'key_word_list', + 'matplotlib.pyplot': 'plt', + 'mymodule.some_func': 'func', + 'something': '_smthing', + 'commented.module': 'commented.module', + 'kwlist2': 'kwlist2' # alias is commented out so expect alias to not be assigned + } + for import_name, alias in aliases.items(): + self.assertEqual(alias, get_module_import_alias(import_name, script)) + + +if __name__ == '__main__': + unittest.main()