Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Python console interactive help #58962

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions images/images.qrc
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,7 @@
<file>themes/default/propertyicons/notes.svg</file>
<file>themes/default/stacked-diagram.svg</file>
<file>themes/default/mIconStac.svg</file>
<file>themes/default/mIconQt.svg</file>
</qresource>
<qresource prefix="/images/tips">
<file alias="symbol_levels.png">qgis_tips/symbol_levels.png</file>
Expand Down
1 change: 1 addition & 0 deletions images/themes/default/mIconQt.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion python/PyQt6/gui/auto_additions/qgscodeeditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@
QgsCodeEditor.Flags.baseClass = QgsCodeEditor
Flags = QgsCodeEditor # dirty hack since SIP seems to introduce the flags in module
try:
QgsCodeEditor.__attribute_docs__ = {'SEARCH_RESULT_INDICATOR': 'Indicator index for search results', 'sessionHistoryCleared': 'Emitted when the history of commands run in the current session is cleared.\n\n.. versionadded:: 3.30\n', 'persistentHistoryCleared': 'Emitted when the persistent history of commands run in the editor is cleared.\n\n.. versionadded:: 3.30\n'}
QgsCodeEditor.__attribute_docs__ = {'SEARCH_RESULT_INDICATOR': 'Indicator index for search results', 'sessionHistoryCleared': 'Emitted when the history of commands run in the current session is cleared.\n\n.. versionadded:: 3.30\n', 'persistentHistoryCleared': 'Emitted when the persistent history of commands run in the editor is cleared.\n\n.. versionadded:: 3.30\n', 'helpRequested': 'Emitted whent the F1 key is pressed while hovering over a word\n\n.. versionadded:: 3.42\n'}
QgsCodeEditor.languageToString = staticmethod(QgsCodeEditor.languageToString)
QgsCodeEditor.defaultColor = staticmethod(QgsCodeEditor.defaultColor)
QgsCodeEditor.color = staticmethod(QgsCodeEditor.color)
QgsCodeEditor.setColor = staticmethod(QgsCodeEditor.setColor)
QgsCodeEditor.getMonospaceFont = staticmethod(QgsCodeEditor.getMonospaceFont)
QgsCodeEditor.isFixedPitch = staticmethod(QgsCodeEditor.isFixedPitch)
QgsCodeEditor.__signal_arguments__ = {'helpRequested': ['word: str']}
QgsCodeEditor.__group__ = ['codeeditors']
except NameError:
pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,14 @@ Emitted when the persistent history of commands run in the editor is cleared.
.. versionadded:: 3.30
%End


void helpRequested( QString word );
%Docstring
Emitted whent the F1 key is pressed while hovering over a word

.. versionadded:: 3.42
%End

protected:

static bool isFixedPitch( const QFont &font );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ Updates the editor capabilities.
Searches the selected text in the official PyQGIS online documentation.

.. versionadded:: 3.16
%End

virtual void showApiDocumentation( const QString &item );
%Docstring
Searches the given text in the official APIs (PyQGIS, C++ QGIS or Qt) documentation.

.. versionadded:: 3.42
%End

virtual void toggleComment();
Expand Down
12 changes: 12 additions & 0 deletions python/PyQt6/gui/auto_generated/qgisinterface.sip.in
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,18 @@ Unregister a previously registered tool factory from the development/debugging t
.. seealso:: :py:func:`registerDevToolWidgetFactory`

.. versionadded:: 3.14
%End

virtual void showApiDocumentation( const QString &api = QStringLiteral( "pyqgis" ), bool embedded = true, const QString &object = QString(), const QString &module = QString() ) = 0;
%Docstring
Show a page of the API documentation

:param api: "pyqgis" or "qgis" or "qt" or "pyqgis-search"
:param embedded: If ``True``, the documentation will be opened in the embedded devtools webview. Otherwise, use system web browser
:param object: object to show in the documentation
:param module: used only if api = "pyqgis"

.. versionadded:: 3.42
%End

virtual void registerApplicationExitBlocker( QgsApplicationExitBlockerInterface *blocker ) = 0;
Expand Down
19 changes: 12 additions & 7 deletions python/console/console_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ def __init__(self,
self.modificationChanged.connect(self.editor_tab.modified)
self.modificationAttempted.connect(self.fileReadOnly)

def showApiDocumentation(self, text):
self.console_widget.shell.showApiDocumentation(text)

def set_code_editor_widget(self, widget: QgsCodeEditorWidget):
self.code_editor_widget = widget
self.code_editor_widget.loadedExternalChanges.connect(
Expand Down Expand Up @@ -154,11 +157,15 @@ def contextMenuEvent(self, e):
runSelected.setShortcut('Ctrl+E') # spellok
menu.addAction(runSelected) # spellok

pyQGISHelpAction = QAction(QgsApplication.getThemeIcon("console/iconHelpConsole.svg"),
QCoreApplication.translate("PythonConsole", "Search Selection in PyQGIS Documentation"),
menu)
pyQGISHelpAction.triggered.connect(self.searchSelectedTextInPyQGISDocs)
menu.addAction(pyQGISHelpAction)
word = self.selectedText() or self.wordAtPoint(e.pos())
if word:
context_help_action = QAction(
QgsApplication.getThemeIcon("mActionHelpContents.svg"),
QCoreApplication.translate("PythonConsole", "Context Help"),
menu)
context_help_action.triggered.connect(partial(self.console_widget.shell.showApiDocumentation, word, force_search=True))
context_help_action.setShortcut('F1')
menu.addAction(context_help_action)

start_action = QAction(QgsApplication.getThemeIcon("mActionStart.svg"),
QCoreApplication.translate("PythonConsole", "Run Script"),
Expand Down Expand Up @@ -246,7 +253,6 @@ def contextMenuEvent(self, e):
self.console_widget.openSettings)
syntaxCheckAction.setEnabled(False)
pasteAction.setEnabled(False)
pyQGISHelpAction.setEnabled(False)
cutAction.setEnabled(False)
runSelected.setEnabled(False) # spellok
copyAction.setEnabled(False)
Expand All @@ -258,7 +264,6 @@ def contextMenuEvent(self, e):
runSelected.setEnabled(True) # spellok
copyAction.setEnabled(True)
cutAction.setEnabled(True)
pyQGISHelpAction.setEnabled(True)
if not self.text() == '':
selectAllAction.setEnabled(True)
syntaxCheckAction.setEnabled(True)
Expand Down
32 changes: 13 additions & 19 deletions python/console/console_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from __future__ import annotations

import sys
from functools import partial
from typing import TYPE_CHECKING

from qgis.PyQt import sip
Expand Down Expand Up @@ -239,11 +240,15 @@ def contextMenuEvent(self, e):
clearAction.triggered.connect(self.clearConsole)
menu.addAction(clearAction)

pyQGISHelpAction = QAction(QgsApplication.getThemeIcon("console/iconHelpConsole.svg"),
QCoreApplication.translate("PythonConsole", "Search Selection in PyQGIS Documentation"),
menu)
pyQGISHelpAction.triggered.connect(self.searchSelectedTextInPyQGISDocs)
menu.addAction(pyQGISHelpAction)
word = self.selectedText() or self.wordAtPoint(e.pos())
if word:
context_help_action = QAction(
QgsApplication.getThemeIcon("mActionHelpContents.svg"),
QCoreApplication.translate("PythonConsole", "Context Help"),
menu)
context_help_action.triggered.connect(partial(self.shell_editor.showApiDocumentation, word, force_search=True))
context_help_action.setShortcut('F1')
menu.addAction(context_help_action)

menu.addSeparator()
copyAction = QAction(
Expand Down Expand Up @@ -271,13 +276,11 @@ def contextMenuEvent(self, e):
runAction.setEnabled(False)
clearAction.setEnabled(False)
copyAction.setEnabled(False)
pyQGISHelpAction.setEnabled(False)
selectAllAction.setEnabled(False)
showEditorAction.setEnabled(True)
if self.hasSelectedText():
runAction.setEnabled(True)
copyAction.setEnabled(True)
pyQGISHelpAction.setEnabled(True)
if not self.text(3) == '':
selectAllAction.setEnabled(True)
clearAction.setEnabled(True)
Expand Down Expand Up @@ -311,17 +314,8 @@ def enteredSelected(self):
self.shell_editor.insertFromDropPaste(cmd)
self.shell_editor.entered()

def keyPressEvent(self, e):
# empty text indicates possible shortcut key sequence so stay in output
txt = e.text()
if len(txt) and txt >= " ":
self.shell_editor.append(txt)
self.shell_editor.moveCursorToEnd()
self.shell_editor.setFocus()
e.ignore()
else:
# possible shortcut key sequence, accept it
e.accept()

def widgetMessageBar(self, text: str):
self.infoBar.pushMessage(text, Qgis.MessageLevel.Info)

def showApiDocumentation(self, text):
self.shell_editor.showApiDocumentation(text)
97 changes: 68 additions & 29 deletions python/console/console_sci.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import re
import sys
import traceback
from functools import partial
from typing import (
Optional,
TYPE_CHECKING
Expand All @@ -33,12 +34,13 @@

from qgis.PyQt.Qsci import QsciScintilla
from qgis.PyQt.QtCore import Qt, QCoreApplication
from qgis.PyQt.QtGui import QKeySequence, QFontMetrics, QClipboard
from qgis.PyQt.QtWidgets import QShortcut, QApplication
from qgis.PyQt.QtGui import QKeySequence, QFontMetrics, QClipboard, QCursor
from qgis.PyQt.QtWidgets import QShortcut, QApplication, QAction
from qgis.core import (
QgsApplication,
Qgis,
QgsProcessingUtils
QgsProcessingUtils,
QgsSettingsTree,
)
from qgis.gui import (
QgsCodeEditorPython,
Expand Down Expand Up @@ -101,6 +103,40 @@ def __parse_object(object=None):
module = match[1]
obj = match[2]
return 'qt', module, obj
""",
r"""
def _help(object=None, api="pyqgis", embedded=True, force_search=False):
'''
Link to the C++ or PyQGIS API documentation for the given object.
If no object is given, the main PyQGIS API page is opened.
If the object is not part of the QGIS API but is a Qt object the Qt documentation is opened.
'''

if not object:
return iface.showApiDocumentation(api, embedded=embedded)

if isinstance(object, str):
try:
object = eval(object)
except (SyntaxError, NameError):
if embedded and not force_search:
return iface.showApiDocumentation(api, embedded=True)
else:
return iface.showApiDocumentation("pyqgis-search", object=object, embedded=False)

obj_info = __parse_object(object)
if not obj_info:
if force_search or isinstance(object, str) and not embedded:
return iface.showApiDocumentation("pyqgis-search", object=object, embedded=False)
else:
return iface.showApiDocumentation(api, embedded=embedded)

obj_type, module, class_name = obj_info
if obj_type == "qt":
api = "qt"

iface.showApiDocumentation(api, embedded=embedded, object=class_name, module=module)

""",
r"""
def _api(object=None):
Expand All @@ -109,18 +145,7 @@ def _api(object=None):
If no object is given, the main API page is opened.
If the object is not part of the QGIS API but is a Qt object the Qt documentation is opened.
'''
import webbrowser
api = __parse_object(object)

version = '' if 'master' in Qgis.QGIS_VERSION.lower() else re.findall(r'^\d.[0-9]*', Qgis.QGIS_VERSION)[0]

if not api:
webbrowser.open(f"https://qgis.org/api/{version}")
elif api[0] == 'qgis':
webbrowser.open(f"https://api.qgis.org/api/{version}/class{api[2]}.html")
elif api[0] == 'qt':
qtversion = '.'.join(qVersion().split(".")[:2])
webbrowser.open(f"https://doc.qt.io/qt-{qtversion}/{api[2].lower()}.html")
return _help(object, api="qgis")
""",
r"""
def _pyqgis(object=None):
Expand All @@ -129,18 +154,7 @@ def _pyqgis(object=None):
If no object is given, the main PyQGIS API page is opened.
If the object is not part of the QGIS API but is a Qt object the Qt documentation is opened.
'''
import webbrowser
api = __parse_object(object)

version = 'master' if 'master' in Qgis.QGIS_VERSION.lower() else re.findall(r'^\d.[0-9]*', Qgis.QGIS_VERSION)[0]

if not api:
webbrowser.open(f"https://qgis.org/pyqgis/{version}")
elif api[0] == 'qgis':
webbrowser.open(f"https://qgis.org/pyqgis/{version}/{api[1]}/{api[2]}.html")
elif api[0] == 'qt':
qtversion = '.'.join(qVersion().split(".")[:2])
webbrowser.open(f"https://doc.qt.io/qt-{qtversion}/{api[2].lower()}.html")
return _help(object, api="pyqgis")
"""
]

Expand Down Expand Up @@ -224,9 +238,9 @@ def execCommandImpl(self, cmd, show_input=True):
if cmd == "?":
self.shell.console_widget.shell_output.insertHelp()
elif cmd == '_pyqgis':
webbrowser.open("https://qgis.org/pyqgis/{}".format(version))
self.shell.showApi("pyqgis")
elif cmd == '_api':
webbrowser.open("https://qgis.org/api/{}".format('' if version == 'master' else version))
self.shell.showApi("qgis")
elif cmd == '_cookbook':
webbrowser.open(
"https://docs.qgis.org/{}/en/docs/pyqgis_developer_cookbook/".format(
Expand Down Expand Up @@ -458,3 +472,28 @@ def runFile(self, filename, override_file_name: Optional[str] = None):
self._interpreter.execCommandImpl("del __file__", False)
self._interpreter.execCommandImpl("sys.path.remove({0})".format(
QgsProcessingUtils.stringToPythonLiteral(dirname)), False)

def showApiDocumentation(self, text, force_search=False):

pythonSettingsTreeNode = QgsSettingsTree.node("gui").childNode("code-editor").childNode("python")

embedded = pythonSettingsTreeNode.childSetting('context-help-embedded').value()

self._interpreter.execCommandImpl(f'_help({repr(text)}, api="pyqgis", embedded={embedded}, force_search={force_search})', show_input=False)

def showApi(self, api):
pythonSettingsTreeNode = QgsSettingsTree.node("gui").childNode("code-editor").childNode("python")
embedded = pythonSettingsTreeNode.childSetting('context-help-embedded').value()
self._interpreter.execCommandImpl(f'_help(api="{api}", embedded={embedded})', show_input=False)

def populateContextMenu(self, menu):

word = self.selectedText() or self.wordAtPoint(self.mapFromGlobal(QCursor.pos()))
if word:
context_help_action = QAction(
QgsApplication.getThemeIcon("mActionHelpContents.svg"),
QCoreApplication.translate("PythonConsole", "Context Help"),
menu)
context_help_action.triggered.connect(partial(self.showApiDocumentation, word, force_search=True))
context_help_action.setShortcut('F1')
menu.addAction(context_help_action)
13 changes: 9 additions & 4 deletions python/console/console_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,16 @@ def saveSettings(self):

settings.setValue("pythonConsole/formatOnSave", self.formatOnSave.isChecked())

pythonSettingsTreeNode = QgsSettingsTree.node("gui").childNode("code-editor").childNode("python")
codeEditorTreeNode = QgsSettingsTree.node("gui").childNode("code-editor")
pythonSettingsTreeNode = codeEditorTreeNode.childNode("python")
pythonSettingsTreeNode.childSetting("sort-imports").setValue(self.sortImports.isChecked())
pythonSettingsTreeNode.childSetting("formatter").setValue(self.formatter.currentText())
pythonSettingsTreeNode.childSetting("autopep8-level").setValue(self.autopep8Level.value())
pythonSettingsTreeNode.childSetting("black-normalize-quotes").setValue(self.blackNormalizeQuotes.isChecked())
pythonSettingsTreeNode.childSetting("max-line-length").setValue(self.maxLineLength.value())
pythonSettingsTreeNode.childSetting('external-editor').setValue(
self.externalEditor.text())
pythonSettingsTreeNode.childSetting('external-editor').setValue(self.externalEditor.text())
pythonSettingsTreeNode.childSetting('context-help-embedded').setValue(self.contextHelpBrowser.currentIndex() == 0)
codeEditorTreeNode.childSetting('context-help-hover').setValue(self.contextHelpHover.isChecked())

def restoreSettings(self):
settings = QgsSettings()
Expand All @@ -244,14 +246,17 @@ def restoreSettings(self):
self.autoSurround.setChecked(settings.value("pythonConsole/autoSurround", True, type=bool))
self.autoInsertImport.setChecked(settings.value("pythonConsole/autoInsertImport", False, type=bool))

pythonSettingsTreeNode = QgsSettingsTree.node("gui").childNode("code-editor").childNode("python")
codeEditorTreeNode = QgsSettingsTree.node("gui").childNode("code-editor")
pythonSettingsTreeNode = codeEditorTreeNode.childNode("python")

self.formatOnSave.setChecked(settings.value("pythonConsole/formatOnSave", False, type=bool))
self.sortImports.setChecked(pythonSettingsTreeNode.childSetting("sort-imports").value())
self.formatter.setCurrentText(pythonSettingsTreeNode.childSetting("formatter").value())
self.autopep8Level.setValue(pythonSettingsTreeNode.childSetting("autopep8-level").value())
self.blackNormalizeQuotes.setChecked(pythonSettingsTreeNode.childSetting("black-normalize-quotes").value())
self.maxLineLength.setValue(pythonSettingsTreeNode.childSetting("max-line-length").value())
self.contextHelpBrowser.setCurrentIndex(0 if pythonSettingsTreeNode.childSetting('context-help-embedded').value() else 1)
self.contextHelpHover.setChecked(codeEditorTreeNode.childSetting('context-help-hover').value())

if settings.value("pythonConsole/autoCompleteSource") == 'fromDoc':
self.autoCompFromDoc.setChecked(True)
Expand Down
Loading
Loading