diff -r 9dd854f05c83 -r cca6a35f3ad2 PipInterface/PipPackagesWidget.py --- a/PipInterface/PipPackagesWidget.py Tue Feb 19 19:56:24 2019 +0100 +++ b/PipInterface/PipPackagesWidget.py Wed Feb 20 19:44:13 2019 +0100 @@ -9,12 +9,17 @@ from __future__ import unicode_literals -from PyQt5.QtCore import pyqtSlot, Qt +import textwrap + +from PyQt5.QtCore import pyqtSlot, Qt, QEventLoop, QRegExp from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QWidget, QToolButton, QApplication, QHeaderView, \ QTreeWidgetItem from E5Gui.E5Application import e5App +from E5Gui import E5MessageBox + +from E5Network.E5XmlRpcClient import E5XmlRpcClient from .Ui_PipPackagesWidget import Ui_PipPackagesWidget @@ -32,6 +37,15 @@ ShowProcessEntryPointsMode = 2 ShowProcessFilesListMode = 3 + SearchStopwords = { + "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", + } + SearchVersionRole = Qt.UserRole + 1 + def __init__(self, parent=None): """ Constructor @@ -55,6 +69,7 @@ self.searchToggleButton.setIcon(UI.PixmapCache.getIcon("find")) self.__pip = Pip(self) + self.__client = E5XmlRpcClient(self.__pip.getIndexUrlXml(), self) self.packagesList.header().setSortIndicator(0, Qt.AscendingOrder) @@ -105,6 +120,23 @@ self.environmentsComboBox.addItem(projectVenv) self.environmentsComboBox.addItems(self.__pip.getVirtualenvNames()) + def __isPipAvailable(self): + """ + Private method to check, if the pip package is available for the + selected environment. + + @return flag indicating availability + @rtype bool + """ + available = False + + venvName = self.environmentsComboBox.currentText() + if venvName: + available = len(self.packagesList.findItems( + "pip", Qt.MatchExactly | Qt.MatchCaseSensitive)) == 1 + + return available + ####################################################################### ## Slots handling widget signals below ####################################################################### @@ -140,12 +172,17 @@ """ Private method to set the state of the action buttons. """ - self.upgradeButton.setEnabled( - bool(self.__selectedUpdateableItems())) - self.uninstallButton.setEnabled( - bool(self.packagesList.selectedItems())) - self.upgradeAllButton.setEnabled( - bool(self.__allUpdateableItems())) + if self.__isPipAvailable(): + self.upgradeButton.setEnabled( + bool(self.__selectedUpdateableItems())) + self.uninstallButton.setEnabled( + bool(self.packagesList.selectedItems())) + self.upgradeAllButton.setEnabled( + bool(self.__allUpdateableItems())) + else: + self.upgradeButton.setEnabled(False) + self.uninstallButton.setEnabled(False) + self.upgradeAllButton.setEnabled(False) def __refreshPackagesList(self): """ @@ -201,6 +238,7 @@ self.__updateActionButtons() self.__updateSearchActionButtons() + self.__updateSearchButton() @pyqtSlot(int) def on_environmentsComboBox_currentIndexChanged(self, index): @@ -409,12 +447,27 @@ """ Private method to update the action button states of the search widget. """ - # TODO: adjust this like search dialog - enable = len(self.searchResultList.selectedItems()) == 1 - self.installButton.setEnabled( - enable and self.environmentsComboBox.currentIndex() > 0) + installEnable = ( + len(self.searchResultList.selectedItems()) > 0 and + self.environmentsComboBox.currentIndex() > 0 and + self.__isPipAvailable() + ) + self.installButton.setEnabled(installEnable) + self.installUserSiteButton.setEnabled(installEnable) + self.showDetailsButton.setEnabled( - enable and bool(self.searchResultList.selectedItems()[0].parent())) + len(self.searchResultList.selectedItems()) == 1 and + self.__isPipAvailable() + ) + + def __updateSearchButton(self): + """ + Private method to update the state of the search button. + """ + self.searchButton.setEnabled( + bool(self.searchEdit.text()) and + self.__isPipAvailable() + ) @pyqtSlot(bool) def on_searchToggleButton_toggled(self, checked): @@ -431,6 +484,206 @@ self.searchEdit.selectAll() self.__updateSearchActionButtons() + self.__updateSearchButton() + + @pyqtSlot(str) + def on_searchEdit_textChanged(self, txt): + """ + Private slot handling a change of the search term. + + @param txt search term + @type str + """ + self.__updateSearchButton() + + @pyqtSlot() + def on_searchButton_clicked(self): + """ + Private slot handling a press of the search button. + """ + self.__search() + + @pyqtSlot() + def on_searchResultList_itemSelectionChanged(self): + """ + Private slot handling changes of the search result selection. + """ + self.__updateSearchActionButtons() + + def __search(self): + """ + Private method to perform the search. + """ + self.searchResultList.clear() + self.searchInfoLabel.clear() + + self.searchButton.setEnabled(False) + QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) + + self.__query = [term for term in self.searchEdit.text().strip().split() + if term not in self.SearchStopwords] + 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 with hits in the first element + @type tuple + """ + if data: + packages = self.__transformHits(data[0]) + if packages: + self.searchInfoLabel.setText( + self.tr("%n package(s) found.", "", len(packages))) + wrapper = textwrap.TextWrapper(width=80) + count = 0 + total = 0 + for package in packages: + itm = QTreeWidgetItem( + self.searchResultList, [ + package['name'].strip(), + "{0:4d}".format(package['score']), + "\n".join([ + wrapper.fill(line) for line in + package['summary'].strip().splitlines() + ]) + ]) + itm.setData(0, self.SearchVersionRole, 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.searchInfoLabel.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.searchInfoLabel.setText( + self.tr("""<p>The package search did not return anything.""" + """</p>""")) + + header = self.searchResultList.header() + self.searchResultList.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.__finishSearch() + + def __finishSearch(self): + """ + Private slot performing the search finishing actions. + """ + QApplication.restoreOverrideCursor() + + self.__updateSearchActionButtons() + self.__updateSearchButton() + + self.searchEdit.setFocus(Qt.OtherFocusReason) + + def __searchError(self, errorCode, errorString): + """ + Private method handling a search error. + + @param errorCode code of the error + @type int + @param errorString error message + @type str + """ + self.__finish() + E5MessageBox.warning( + self, + self.tr("Search PyPI"), + self.tr("""<p>The package search failed.</p><p>Reason: {0}</p>""") + .format(errorString)) + self.searchInfoLabel.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 + @type list of dict + @return list of packages + @rtype 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: + 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 ####################################################################### ## Menu related methods below