diff -r 7ef7d47a0ad5 -r e6af0dcfbb35 Plugins/UiExtensionPlugins/PipInterface/PipSearchDialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Plugins/UiExtensionPlugins/PipInterface/PipSearchDialog.py Sat Dec 09 18:32:08 2017 +0100 @@ -0,0 +1,432 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2015 - 2017 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a dialog to search PyPI. +""" + +from __future__ import unicode_literals + +import textwrap + +from PyQt5.QtCore import pyqtSlot, Qt, QEventLoop, QRegExp +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QAbstractButton, \ + QApplication, QTreeWidgetItem, QHeaderView, QInputDialog + +from E5Gui import E5MessageBox +try: + from E5Network.E5XmlRpcClient import E5XmlRpcClient +except ImportError: + from .E5XmlRpcClient import E5XmlRpcClient + +from .Ui_PipSearchDialog import Ui_PipSearchDialog + +from . import DefaultIndexUrl + + +class PipSearchDialog(QDialog, Ui_PipSearchDialog): + """ + Class implementing a dialog to search PyPI. + """ + VersionRole = Qt.UserRole + 1 + + Stopwords = { + "a", "and", "are", "as", "at", "be", "but", "by", + "for", "if", "in", "into", "is", "it", + "no", "not", "of", "on", "or", "such", + "that", "the", "their", "then", "there", "these", + "they", "this", "to", "was", "will", + } + + def __init__(self, pip, plugin, parent=None): + """ + Constructor + + @param pip reference to the master object (Pip) + @param plugin reference to the plugin object (ToolPipPlugin) + @param parent reference to the parent widget (QWidget) + """ + super(PipSearchDialog, self).__init__(parent) + self.setupUi(self) + self.setWindowFlags(Qt.Window) + + self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False) + + self.__installButton = self.buttonBox.addButton( + self.tr("&Install"), QDialogButtonBox.ActionRole) + self.__installButton.setEnabled(False) + + self.__showDetailsButton = self.buttonBox.addButton( + self.tr("&Show Details..."), QDialogButtonBox.ActionRole) + self.__showDetailsButton.setEnabled(False) + + self.__pip = pip + self.__client = E5XmlRpcClient( + plugin.getPreferences("PipSearchIndex") or DefaultIndexUrl, + self) + + self.__default = self.tr("<Default>") + pipExecutables = sorted(plugin.getPreferences("PipExecutables")) + self.pipComboBox.addItem(self.__default) + self.pipComboBox.addItems(pipExecutables) + + self.searchEdit.setFocus(Qt.OtherFocusReason) + + self.__canceled = False + self.__detailsData = {} + self.__query = [] + + def closeEvent(self, e): + """ + Protected slot implementing a close event handler. + + @param e close event (QCloseEvent) + """ + QApplication.restoreOverrideCursor() + e.accept() + + @pyqtSlot(str) + def on_searchEdit_textChanged(self, txt): + """ + Private slot handling a change of the search term. + + @param txt search term (string) + """ + self.searchButton.setEnabled(bool(txt)) + + @pyqtSlot() + def on_searchButton_clicked(self): + """ + Private slot handling a press of the search button. + """ + self.__search() + + @pyqtSlot() + def on_resultList_itemSelectionChanged(self): + """ + Private slot handling changes of the selection. + """ + self.__installButton.setEnabled( + len(self.resultList.selectedItems()) > 0) + self.__showDetailsButton.setEnabled( + len(self.resultList.selectedItems()) == 1) + + @pyqtSlot(QAbstractButton) + def on_buttonBox_clicked(self, button): + """ + Private slot called by a button of the button box clicked. + + @param button button that was clicked (QAbstractButton) + """ + if button == self.buttonBox.button(QDialogButtonBox.Close): + self.close() + elif button == self.buttonBox.button(QDialogButtonBox.Cancel): + self.__client.abort() + self.__canceled = True + elif button == self.__installButton: + self.__install() + elif button == self.__showDetailsButton: + self.__showDetails() + + def __search(self): + """ + Private method to perform the search. + """ + self.resultList.clear() + self.infoLabel.clear() + + self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) + self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True) + self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) + self.searchButton.setEnabled(False) + QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) + + QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) + + self.__canceled = False + + self.__query = [term for term in self.searchEdit.text().strip().split() + if term not in PipSearchDialog.Stopwords] + self.__client.call( + "search", + ({"name": self.__query, "summary": self.__query}, "or"), + self.__processSearchResult, + self.__searchError + ) + + def __processSearchResult(self, data): + """ + Private method to process the search result data from PyPI. + + @param data result data (tuple) with hits in the first element + """ + if data: + packages = self.__transformHits(data[0]) + if packages: + self.infoLabel.setText(self.tr("%n package(s) found.", "", + len(packages))) + wrapper = textwrap.TextWrapper(width=80) + count = 0 + total = 0 + for package in packages: + if self.__canceled: + self.infoLabel.setText( + self.tr("Canceled - only {0} out of %n package(s)" + " shown", "", len(packages)).format(total)) + break + itm = QTreeWidgetItem( + self.resultList, [ + package['name'].strip(), + "{0:4d}".format(package['score']), + "\n".join([ + wrapper.fill(line) for line in + package['summary'].strip().splitlines() + ]) + ]) + itm.setData(0, self.VersionRole, package['version']) + count += 1 + total += 1 + if count == 100: + count = 0 + QApplication.processEvents() + else: + QApplication.restoreOverrideCursor() + E5MessageBox.warning( + self, + self.tr("Search PyPI"), + self.tr("""<p>The package search did not return""" + """ anything.</p>""")) + self.infoLabel.setText( + self.tr("""<p>The package search did not return""" + """ anything.</p>""")) + else: + QApplication.restoreOverrideCursor() + E5MessageBox.warning( + self, + self.tr("Search PyPI"), + self.tr("""<p>The package search did not return anything.""" + """</p>""")) + self.infoLabel.setText( + self.tr("""<p>The package search did not return anything.""" + """</p>""")) + + header = self.resultList.header() + self.resultList.sortItems(1, Qt.DescendingOrder) + header.setStretchLastSection(False) + header.resizeSections(QHeaderView.ResizeToContents) + headerSize = 0 + for col in range(header.count()): + headerSize += header.sectionSize(col) + if headerSize < header.width(): + header.setStretchLastSection(True) + + self.__finish() + + def __finish(self): + """ + Private slot performing the finishing actions. + """ + QApplication.restoreOverrideCursor() + self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True) + self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False) + self.searchButton.setEnabled(True) + self.searchButton.setDefault(True) + self.searchEdit.setFocus(Qt.OtherFocusReason) + + def __searchError(self, errorCode, errorString): + """ + Private method handling a search error. + + @param errorCode code of the error (integer) + @param errorString error message (string) + """ + self.__finish() + E5MessageBox.warning( + self, + self.tr("Search PyPI"), + self.tr("""<p>The package search failed.</p><p>Reason: {0}</p>""") + .format(errorString)) + self.infoLabel.setText(self.tr("Error: {0}").format(errorString)) + + def __transformHits(self, hits): + """ + Private method to convert the list returned from pypi into a + packages list. + + @param hits list returned from pypi (list of dict) + @return list of packages (list of dict) + """ + # we only include the record with the highest score + packages = {} + for hit in hits: + name = hit['name'].strip() + summary = (hit['summary'] or "").strip() + version = hit['version'].strip() + score = self.__score(name, summary) + # cleanup the summary + if summary in ["UNKNOWN", "."]: + summary = "" + + if name not in packages: + packages[name] = { + 'name': name, + 'summary': summary, + 'version': [version.strip()], + 'score': score} + else: + # TODO: allow for multiple versions using highest score + if score > packages[name]['score']: + packages[name]['score'] = score + packages[name]['summary'] = summary + packages[name]['version'].append(version.strip()) + + return list(packages.values()) + + def __score(self, name, summary): + """ + Private method to calculate some score for a search result. + + @param name name of the returned package + @type str + @param summary summary text for the package + @type str + @return score value + @rtype int + """ + score = 0 + for queryTerm in self.__query: + if queryTerm.lower() in name.lower(): + score += 4 + if queryTerm.lower() == name.lower(): + score += 4 + + if queryTerm.lower() in summary.lower(): + if QRegExp(r'\b{0}\b'.format(QRegExp.escape(queryTerm)), + Qt.CaseInsensitive).indexIn(summary) != -1: + # word match gets even higher score + score += 2 + else: + score += 1 + + return score + + def __install(self): + """ + Private slot to install the selected packages. + """ + command = self.pipComboBox.currentText() + if command == self.__default: + command = "" + + packages = [] + for itm in self.resultList.selectedItems(): + packages.append(itm.text(0).strip()) + if packages: + self.__pip.installPackages(packages, cmd=command) + + def __showDetails(self): + """ + Private slot to show details about the selected package. + """ + self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False) + self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True) + self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True) + self.__showDetailsButton.setEnabled(False) + QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) + + self.__detailsData = {} + + itm = self.resultList.selectedItems()[0] + packageVersions = itm.data(0, self.VersionRole) + if len(packageVersions) == 1: + packageVersion = packageVersions[0] + elif len(packageVersions) == 0: + packageVersion = "" + else: + packageVersion, ok = QInputDialog.getItem( + self, + self.tr("Show Package Details"), + self.tr("Select the package version:"), + packageVersions, + 0, False) + if not ok: + return + + packageName = itm.text(0) + self.__client.call( + "release_data", + (packageName, packageVersion), + lambda d: self.__getPackageDownloadsData(packageVersion, d), + self.__detailsError + ) + + def __getPackageDownloadsData(self, packageVersion, data): + """ + Private method to store the details data and get downloads + information. + + @param packageVersion version info + @type str + @param data result data with package details in the first + element + @type tuple + """ + if data and data[0]: + self.__detailsData = data[0] + itm = self.resultList.selectedItems()[0] + packageName = itm.text(0) + self.__client.call( + "release_urls", + (packageName, packageVersion), + self.__displayPackageDetails, + self.__detailsError + ) + else: + self.__finish() + E5MessageBox.warning( + self, + self.tr("Search PyPI"), + self.tr("""<p>No package details info available.</p>""")) + + def __displayPackageDetails(self, data): + """ + Private method to display the returned package details. + + @param data result data (tuple) with downloads information in the first + element + """ + self.__finish() + self.__showDetailsButton.setEnabled(True) + from .PipPackageDetailsDialog import PipPackageDetailsDialog + dlg = PipPackageDetailsDialog(self.__detailsData, data[0], self) + dlg.exec_() + + def __detailsError(self, errorCode, errorString): + """ + Private method handling a details error. + + @param errorCode code of the error (integer) + @param errorString error message (string) + """ + self.__finish() + self.__showDetailsButton.setEnabled(True) + E5MessageBox.warning( + self, + self.tr("Search PyPI"), + self.tr("""<p>Package details info could not be retrieved.</p>""" + """<p>Reason: {0}</p>""") + .format(errorString)) + + @pyqtSlot(QTreeWidgetItem, int) + def on_resultList_itemActivated(self, item, column): + """ + Private slot reacting on an item activation. + + @param item reference to the activated item (QTreeWidgetItem) + @param column activated column (integer) + """ + self.__showDetails()