Plugins/UiExtensionPlugins/PipInterface/PipSearchDialog.py

Sat, 16 Feb 2019 10:27:50 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sat, 16 Feb 2019 10:27:50 +0100
branch
maintenance
changeset 6764
d14ddbfbbd36
parent 6645
ad476851d7e0
permissions
-rw-r--r--

Created global tag <release-19.02.1>.

# -*- coding: utf-8 -*-

# Copyright (c) 2015 - 2019 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


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, indexUrl, parent=None):
        """
        Constructor
        
        @param pip reference to the master object
        @type Pip
        @param indexUrl URL of XML RPC interface to the pypi index
        @type str
        @param parent reference to the parent widget
        @type 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.__installUserButton = self.buttonBox.addButton(
            self.tr("Install to &User-Site"), QDialogButtonBox.ActionRole)
        self.__installUserButton.setEnabled(False)
        
        self.__showDetailsButton = self.buttonBox.addButton(
            self.tr("&Show Details..."), QDialogButtonBox.ActionRole)
        self.__showDetailsButton.setEnabled(False)
        
        self.__pip = pip
        self.__client = E5XmlRpcClient(indexUrl, self)
        
        self.venvComboBox.addItem(self.__pip.getDefaultEnvironmentString())
        projectVenv = self.__pip.getProjectEnvironmentString()
        if projectVenv:
            self.venvComboBox.addItem(projectVenv)
        self.venvComboBox.addItems(self.__pip.getVirtualenvNames())
        
        self.searchEdit.setFocus(Qt.OtherFocusReason)
        
        self.__canceled = False
        self.__detailsData = {}
        self.__query = []
        
        self.__packageDetailsDialog = None
    
    def closeEvent(self, e):
        """
        Protected slot implementing a close event handler.
        
        @param e close event
        @type QCloseEvent
        """
        QApplication.restoreOverrideCursor()
        
        if self.__packageDetailsDialog is not None:
            self.__packageDetailsDialog.close()
        
        e.accept()
    
    @pyqtSlot(str)
    def on_searchEdit_textChanged(self, txt):
        """
        Private slot handling a change of the search term.
        
        @param txt search term
        @type str
        """
        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.__installUserButton.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
        @type 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.__installUserButton:
            self.__install(userSite=True)
        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 with hits in the first element
        @type tuple
        """
        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
        @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.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
        @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
    
    def __install(self, userSite=False):
        """
        Private slot to install the selected packages.
        
        @param userSite flag indicating to install to the user directory
        @type bool
        """
        venvName = self.venvComboBox.currentText()
        
        packages = []
        for itm in self.resultList.selectedItems():
            packages.append(itm.text(0).strip())
        if packages:
            self.__pip.installPackages(packages, venvName=venvName,
                                       userSite=userSite)
    
    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 with downloads information in the first element
        @type tuple
        """
        from .PipPackageDetailsDialog import PipPackageDetailsDialog
        
        self.__finish()
        self.__showDetailsButton.setEnabled(True)
        
        if self.__packageDetailsDialog is not None:
            self.__packageDetailsDialog.close()
        
        self.__packageDetailsDialog = \
            PipPackageDetailsDialog(self.__detailsData, data[0], self)
        self.__packageDetailsDialog.show()
    
    def __detailsError(self, errorCode, errorString):
        """
        Private method handling a details error.
        
        @param errorCode code of the error
        @type int
        @param errorString error message
        @type str
        """
        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
        @type QTreeWidgetItem
        @param column activated column
        @type int
        """
        self.__showDetails()

eric ide

mercurial